Skip to content

Commit fc21383

Browse files
committed
feat: port ref download, go- switch aliases, 2w mtime, dotnet/go base (goal)
1 parent 4637052 commit fc21383

16 files changed

Lines changed: 839 additions & 30 deletions

src/Tests/EndToEndTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,69 @@ public void Publish_r2r_second_run_hits_cache()
7878
}
7979
}
8080

81+
[Fact]
82+
public void Remote_ref_default_command_runs_twice_identical_and_uses_dotnet_go()
83+
{
84+
var scratch = @"C:\Users\kzu\AppData\Local\Temp\grok-goal-4376de8fe197\implementer";
85+
Directory.CreateDirectory(scratch);
86+
var remoteRef = "gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381";
87+
try
88+
{
89+
// warm-up populate (may emit build logs)
90+
RunGo(remoteRef, "--", "-v:q");
91+
92+
// two runs that should hit cache for identical clean output
93+
var (exit1, output1) = RunGo(remoteRef, "--", "-v:q");
94+
File.WriteAllText(Path.Combine(scratch, "remote-run1.log"), output1);
95+
Assert.Equal(0, exit1);
96+
Assert.Contains("run.cs", output1);
97+
98+
var (exit2, output2) = RunGo(remoteRef, "--", "-v:q");
99+
File.WriteAllText(Path.Combine(scratch, "remote-run2.log"), output2);
100+
Assert.Equal(0, exit2);
101+
Assert.Contains("run.cs", output2);
102+
Assert.Equal(output1, output2);
103+
104+
// basedir evidence
105+
var root = GetTempRoot();
106+
var listing = string.Join(Environment.NewLine, Directory.GetDirectories(root, "*", SearchOption.AllDirectories).Take(10).Select(d => d.Replace('\\', '/')));
107+
File.WriteAllText(Path.Combine(scratch, "basedir.log"), listing);
108+
Assert.Contains("/dotnet/go/", listing);
109+
}
110+
finally
111+
{
112+
try { RunGo("clean", remoteRef); } catch { }
113+
}
114+
}
115+
116+
[Fact]
117+
public void Remote_ref_dev_command_runs_twice_identical_and_uses_dotnet_go()
118+
{
119+
var scratch = @"C:\Users\kzu\AppData\Local\Temp\grok-goal-4376de8fe197\implementer";
120+
Directory.CreateDirectory(scratch);
121+
var remoteRef = "gist.github.com/kzu/0ac826dc7de666546aaedd38e5965381";
122+
try
123+
{
124+
// warm-up
125+
RunGo("dev", remoteRef, "--", "-v:q");
126+
127+
var (exit1, output1) = RunGo("dev", remoteRef, "--", "-v:q");
128+
File.WriteAllText(Path.Combine(scratch, "remote-dev1.log"), output1);
129+
Assert.Equal(0, exit1);
130+
Assert.Contains("run.cs", output1);
131+
132+
var (exit2, output2) = RunGo("dev", remoteRef, "--", "-v:q");
133+
File.WriteAllText(Path.Combine(scratch, "remote-dev2.log"), output2);
134+
Assert.Equal(0, exit2);
135+
Assert.Contains("run.cs", output2);
136+
Assert.Equal(output1, output2);
137+
}
138+
finally
139+
{
140+
try { RunGo("clean", remoteRef); } catch { }
141+
}
142+
}
143+
81144
static string CreateApp(string marker)
82145
{
83146
var dir = CreateTempDir();

src/Tests/GoArgsTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,35 @@ public void ApplyPublishMode_leaves_args_unchanged_when_disabled()
5555

5656
Assert.Equal(["-c", "Release"], args);
5757
}
58+
59+
[Fact]
60+
public void Normalize_maps_go_prefixed_switches_to_bare_forms()
61+
{
62+
var normalized = GoArgs.Normalize(["--go-force", "--go-debug", "--go-r2r", "owner/repo", "--", "apparg"]);
63+
64+
Assert.Equal(["--force", "--debug", "--r2r", "owner/repo", "--", "apparg"], normalized);
65+
}
66+
67+
[Fact]
68+
public void Normalize_leaves_bare_and_unknown_untouched()
69+
{
70+
var normalized = GoArgs.Normalize(["--force", "--debug", "--r2r", "--other", "input.cs"]);
71+
72+
Assert.Equal(["--force", "--debug", "--r2r", "--other", "input.cs"], normalized);
73+
}
74+
75+
[Fact]
76+
public void Normalize_handles_mixed_case_and_all_forms()
77+
{
78+
var normalized = GoArgs.Normalize(["--Go-Force", "--DEBUG", "ref"]);
79+
80+
Assert.Equal(["--force", "--debug", "ref"], normalized);
81+
}
82+
83+
[Fact]
84+
public void Normalize_empty_and_null()
85+
{
86+
Assert.Empty(GoArgs.Normalize([]));
87+
Assert.Empty(GoArgs.Normalize(null!));
88+
}
5889
}

src/Tests/GoBuildCacheTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,78 @@ static string WriteFile(string dir, string name, string content)
9797
File.WriteAllText(path, content);
9898
return path;
9999
}
100+
101+
[Fact]
102+
public void TryResolveEntryPoint_prefers_FileExists_then_fallsback_to_RemoteRefTryParse()
103+
{
104+
var dir = CreateTempDir();
105+
var local = WriteFile(dir, "app.cs", "Console.WriteLine(1);");
106+
var full = Path.GetFullPath(local);
107+
108+
var ok1 = Devlooped.RemoteSourceResolver.TryResolveEntryPoint(local, out var eff1, out var pdir1, out var stamp1);
109+
Assert.True(ok1);
110+
Assert.Equal(full, eff1);
111+
Assert.False(string.IsNullOrEmpty(pdir1));
112+
113+
// non existing local + invalid ref => fail
114+
var bad = Path.Combine(dir, "nope.cs");
115+
var okBad = Devlooped.RemoteSourceResolver.TryResolveEntryPoint(bad, out _, out _, out _);
116+
Assert.False(okBad);
117+
118+
// remote ref string (no local file) => parses, returns target path under go base
119+
var okRef = Devlooped.RemoteSourceResolver.TryResolveEntryPoint("kzu/runfile:run.cs", out var effRef, out var pdirRef, out _);
120+
Assert.True(okRef);
121+
Assert.Contains("run.cs", effRef);
122+
Assert.Contains("dotnet/go", effRef.Replace('\\', '/'));
123+
}
124+
125+
[Fact]
126+
public void GetRemoteEntryPointPath_uses_path_or_program_or_persisted_marker()
127+
{
128+
// use distinct owner/repo to avoid collision with persisted markers from other tests/runs
129+
var uniq = Guid.NewGuid().ToString("N")[..8];
130+
var remoteNoPath = new Devlooped.RemoteRef("o" + uniq, "r" + uniq, null, null, null);
131+
var p = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteNoPath);
132+
Assert.EndsWith("program.cs", p);
133+
134+
var withPath = new Devlooped.RemoteRef("o" + uniq, "r" + uniq, null, "src/app.cs", null);
135+
p = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(withPath);
136+
Assert.EndsWith("src/app.cs", p.Replace('\\', '/'));
137+
138+
// simulate persist
139+
var dir = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteNoPath);
140+
var tempDir = Path.GetDirectoryName(dir)!;
141+
Directory.CreateDirectory(tempDir);
142+
var marker = Path.Combine(tempDir, ".go-entry");
143+
File.WriteAllText(marker, "actual.cs");
144+
p = Devlooped.RemoteSourceResolver.GetRemoteEntryPointPath(remoteNoPath);
145+
Assert.EndsWith("actual.cs", p);
146+
147+
// cleanup marker for hygiene
148+
try { File.Delete(marker); } catch { }
149+
}
150+
151+
[Fact]
152+
public void IsRemoteDownloadStale_uses_2week_window_on_mtime()
153+
{
154+
var dir = CreateTempDir();
155+
var f = WriteFile(dir, "src.cs", "// test");
156+
File.SetLastWriteTimeUtc(f, DateTime.UtcNow.AddDays(-20));
157+
Assert.True(Devlooped.RemoteSourceResolver.IsRemoteDownloadStale(f));
158+
159+
File.SetLastWriteTimeUtc(f, DateTime.UtcNow.AddDays(-3));
160+
Assert.False(Devlooped.RemoteSourceResolver.IsRemoteDownloadStale(f));
161+
162+
var missing = Path.Combine(dir, "no.cs");
163+
Assert.True(Devlooped.RemoteSourceResolver.IsRemoteDownloadStale(missing));
164+
}
165+
166+
[Fact]
167+
public async Task GetEffectiveSourceAsync_returns_local_existing_without_net()
168+
{
169+
var dir = CreateTempDir();
170+
var f = WriteFile(dir, "local.cs", "Console.WriteLine(\"hi\");");
171+
var eff = await Devlooped.RemoteSourceResolver.GetEffectiveSourceAsync(f, force: false);
172+
Assert.Equal(Path.GetFullPath(f), eff);
173+
}
100174
}

src/go/DownloadProvider.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.Diagnostics;
2+
using System.Net;
3+
using Devlooped.Http;
4+
5+
namespace Devlooped;
6+
7+
public abstract class DownloadProvider
8+
{
9+
public static DownloadProvider Create(RemoteRef location) => (location.Host?.ToLowerInvariant() ?? "github.com") switch
10+
{
11+
"gitlab.com" => new GitLabDownloadProvider(),
12+
"dev.azure.com" => new AzureDevOpsDownloadProvider(),
13+
//"bitbucket.org" => new BitbucketDownloadProvider(),
14+
"gist.github.com" => new GitHubDownloadProvider(gist: true),
15+
_ => new GitHubDownloadProvider(),
16+
};
17+
18+
public abstract Task<HttpResponseMessage> GetAsync(RemoteRef location);
19+
}
20+
21+
public class GitHubDownloadProvider(bool gist = false) : DownloadProvider
22+
{
23+
static readonly HttpClient http = new(new GitHubAuthHandler(
24+
new RedirectingHttpHandler(
25+
new HttpClientHandler
26+
{
27+
AllowAutoRedirect = false,
28+
AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip
29+
})))
30+
{
31+
Timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(15)
32+
};
33+
34+
public override async Task<HttpResponseMessage> GetAsync(RemoteRef location)
35+
{
36+
if (location.ResolvedUri != null)
37+
{
38+
return await http.SendAsync(
39+
new HttpRequestMessage(HttpMethod.Get, location.ResolvedUri).WithTag(location.ETag),
40+
HttpCompletionOption.ResponseHeadersRead);
41+
}
42+
43+
var subdomain = gist ? "gist." : "";
44+
var request = new HttpRequestMessage(HttpMethod.Get,
45+
// Direct archive link works for branch, tag, sha
46+
new Uri($"https://{subdomain}github.com/{location.Owner}/{location.Repo}/archive/{location.Ref ?? "main"}.zip"))
47+
.WithTag(location.ETag);
48+
49+
return await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
50+
}
51+
}
52+
53+
public class GitLabDownloadProvider : DownloadProvider
54+
{
55+
static readonly HttpClient http = new(new GitLabAuthHandler(
56+
new RedirectingHttpHandler(
57+
new HttpClientHandler
58+
{
59+
AllowAutoRedirect = false,
60+
AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip,
61+
})))
62+
{
63+
Timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(15)
64+
};
65+
66+
public override async Task<HttpResponseMessage> GetAsync(RemoteRef location)
67+
{
68+
if (location.ResolvedUri != null)
69+
{
70+
return await http.SendAsync(
71+
new HttpRequestMessage(HttpMethod.Get, location.ResolvedUri).WithTag(location.ETag),
72+
HttpCompletionOption.ResponseHeadersRead);
73+
}
74+
75+
var url = $"https://gitlab.com/api/v4/projects/{Uri.EscapeDataString(location.Owner + "/" + location.Repo)}/repository/archive.zip?sha={location.Ref ?? "main"}";
76+
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
77+
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
78+
79+
return response;
80+
}
81+
}
82+
83+
public class AzureDevOpsDownloadProvider : DownloadProvider
84+
{
85+
static readonly HttpClient http = new(new AzureRepoAuthHandler(
86+
new RedirectingHttpHandler(
87+
new HttpClientHandler
88+
{
89+
AllowAutoRedirect = false,
90+
AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip,
91+
}, "visualstudio.com")))
92+
{
93+
Timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(15)
94+
};
95+
96+
static AzureDevOpsDownloadProvider()
97+
{
98+
http.DefaultRequestHeaders.TryAddWithoutValidation("X-TFS-FedAuthRedirect", "Suppress");
99+
}
100+
101+
public override async Task<HttpResponseMessage> GetAsync(RemoteRef location)
102+
{
103+
if (location.ResolvedUri != null)
104+
{
105+
return await http.SendAsync(
106+
new HttpRequestMessage(HttpMethod.Get, location.ResolvedUri).WithTag(location.ETag),
107+
HttpCompletionOption.ResponseHeadersRead);
108+
}
109+
110+
// For Azure DevOps we support dev.azure.com/org/project/repo, defaulting project=repo if not specified
111+
var project = location.Project ?? location.Repo;
112+
113+
// Branch/ref support
114+
var version = location.Ref ?? "main";
115+
var url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&$format=zip&api-version=7.1";
116+
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
117+
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
118+
119+
if (response.StatusCode == HttpStatusCode.NotFound)
120+
{
121+
// try tag & commit
122+
url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&versionType=tag&$format=zip&api-version=7.1";
123+
request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
124+
response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
125+
126+
if (response.StatusCode == HttpStatusCode.NotFound)
127+
{
128+
url = $"https://dev.azure.com/{location.Owner}/{project}/_apis/git/repositories/{location.Repo}/items?download=true&version={Uri.EscapeDataString(version)}&versionType=commit&$format=zip&api-version=7.1";
129+
request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)).WithTag(location.ETag);
130+
response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
131+
}
132+
}
133+
134+
return response;
135+
}
136+
}

src/go/GoArgs.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,43 @@ public static class GoArgs
44
{
55
public static readonly string[] ReadyToRunPublishArgs = ["/p:PublishAot=false", "/p:PublishReadyToRun=true"];
66

7+
static readonly string[] GoSwitchNames = ["force", "debug", "r2r"];
8+
9+
/// <summary>
10+
/// Normalizes go-specific switches so both prefix-less (--force) and --go- forms (--go-force)
11+
/// are accepted; maps --go-* aliases to the bare form the command methods declare.
12+
/// </summary>
13+
public static string[] Normalize(string[] args)
14+
{
15+
if (args is null or { Length: 0 })
16+
return args ?? [];
17+
18+
var result = new List<string>(args.Length);
19+
foreach (var arg in args)
20+
{
21+
if (arg.StartsWith("--go-", StringComparison.OrdinalIgnoreCase))
22+
{
23+
var name = arg[5..];
24+
if (GoSwitchNames.Any(s => s.Equals(name, StringComparison.OrdinalIgnoreCase)))
25+
{
26+
result.Add("--" + name.ToLowerInvariant());
27+
continue;
28+
}
29+
}
30+
else if (arg.StartsWith("--", StringComparison.Ordinal))
31+
{
32+
var name = arg[2..];
33+
if (GoSwitchNames.Any(s => s.Equals(name, StringComparison.OrdinalIgnoreCase)))
34+
{
35+
result.Add("--" + name.ToLowerInvariant());
36+
continue;
37+
}
38+
}
39+
result.Add(arg);
40+
}
41+
return [.. result];
42+
}
43+
744
public static (string[] Dotnet, string[] App) Split(string[] extraArgs)
845
{
946
var separator = Array.IndexOf(extraArgs, "--");
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Devlooped.Http;
2+
3+
/// <summary>
4+
/// Minimal passthrough handler for public refs. (Auth paths omitted per non-goals.)
5+
/// </summary>
6+
public class AzureRepoAuthHandler(HttpMessageHandler inner, string? _ = null) : DelegatingHandler(inner)
7+
{
8+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
9+
=> base.SendAsync(request, cancellationToken);
10+
}

src/go/Http/GitHubAuthHandler.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System.Net.Http.Headers;
2+
3+
namespace Devlooped.Http;
4+
5+
/// <summary>
6+
/// Minimal passthrough handler for public refs. (Auth paths omitted per non-goals.)
7+
/// </summary>
8+
public class GitHubAuthHandler(HttpMessageHandler inner) : DelegatingHandler(inner)
9+
{
10+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
11+
=> base.SendAsync(request, cancellationToken);
12+
}

0 commit comments

Comments
 (0)