fix(github): classify access, network and server failures; cancel a listing promptly #154

Merged
james.maes merged 2 commits from fix/153-failure-classification into main 2026-11-10 21:39:19 +00:00
20 changed files with 987 additions and 70 deletions

No files matched your search

+4 -1
View File
@@ -180,7 +180,10 @@ gclo sync --org contoso --target ~/src/contoso --json |
| --- | --- |
| 0 | Everything succeeded. |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). |
| 2 | Fatal: bad arguments, missing/empty/rejected token, or organization not found. |
| 2 | Fatal: bad arguments, missing or empty token, or organization not found. |
| 3 | GitHub rejected the token or refused it access: fix the token, do not retry as is. |
| 4 | A rate limit, a network failure, a timeout, or an error on GitHub's side: retry later. |
| 70 | An unexpected error inside gclo; details are in the activity log. |
Full reference — all options, output streams, Ctrl+C behavior, and more examples: [docs/CLI.md](docs/CLI.md).
+8 -4
View File
@@ -403,12 +403,16 @@ its offset from UTC (`2026-11-10 14:03:22.123 -06:00 [INFO] ...`). Text that cam
| 0 | Everything succeeded (or `--dry-run` / a listing completed). | — |
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). `--json`'s `wasCanceled` tells the two apart. | Inspect the failures; retry them. |
| 2 | Fatal: bad arguments, missing/empty token, organization not found, unknown account, missing account token, unusable target folder, or an account command on a non-Windows OS. | Fix the invocation. |
| 3 | GitHub rejected the token (401) or the token may not see the organization (403). | Fix the token or its access; do not retry as is. |
| 4 | GitHub's primary or secondary rate limit, or a temporary throttle. | Retry later (the message says when). |
| 3 | GitHub rejected the token (401), or refused it access (403: the token may not see the organization, or the organization requires SAML single sign-on and the token is not authorized for it). The message carries GitHub's reason when it gave one. | Fix the token or its access; do not retry as is. |
| 4 | Not your doing: GitHub's primary or secondary rate limit (403 or 429), a temporary throttle, a network failure (DNS, connection, TLS, proxy), a request that timed out, or an error on GitHub's side (HTTP 5xx). | Retry later (for a rate limit, the message says when). |
| 70 | An unexpected error inside gclo. The exception type is printed and the full details are in the activity log. | Please report it. |
Codes 3 and 4 were part of code 2 before version 1.1; a script that tested
`-eq 2` for "usage error" should treat `3` and `4` as distinct outcomes.
Before version 1.1.1, a refusal of access other than an invisible organization
(403), a rate limit answered with 429, a network failure, a timeout, and an
error on GitHub's side ended with code 70; they now end with `3` (the refusal)
or `4` (the rest).
A repository recovered by `--sanitize-paths` counts as a success: if every other
repository also succeeds, the exit code is `0`. With `--json` or `--json-lines`,
@@ -452,7 +456,7 @@ gclo sync --org contoso --target D:\mirror\contoso --parallel 16 --quiet
switch ($LASTEXITCODE) {
0 { }
1 { Write-Warning "some repositories failed" }
4 { Write-Warning "rate limited; will retry next run" }
4 { Write-Warning "rate limit, network or GitHub-side failure; will retry next run" }
default { Write-Error "sync ended with code $LASTEXITCODE" }
}
@@ -507,7 +511,7 @@ gclo sync --org contoso --target /srv/mirror/contoso --parallel 16 --quiet
case $? in
0) ;;
1) echo "some repositories failed" ;;
4) echo "rate limited; retrying next run" ;;
4) echo "rate limit, network or GitHub-side failure; retrying next run" ;;
*) echo "sync ended with code $?" >&2 ;;
esac
+8
View File
@@ -144,6 +144,14 @@ public sealed class OrgsCommandTests
var throttled = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "slow down") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(throttled, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
var unavailable = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.Unavailable, "no answer") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(unavailable, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
var refused = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "refused") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(refused, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
}
+4
View File
@@ -158,6 +158,10 @@ public sealed class ReposCommandTests
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
Assert.Contains("no access", ex.Message);
var unavailable = new FakeRepoLister { Throw = new GitHubAccessException(GitHubAccessKind.Unavailable, "no answer") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(unavailable, "--org", "acme", "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
}
+10
View File
@@ -335,6 +335,16 @@ public sealed class SyncCommandTests : IDisposable
ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(missing, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Fatal, ex.ExitCode);
// Not the caller's doing (network, timeout, an error on GitHub's side): retry later.
var unavailable = new FakeRepoLister
{
Throw = new GitHubAccessException(GitHubAccessKind.Unavailable, "GitHub did not answer in time; retry later."),
};
ex = await Assert.ThrowsAsync<CliErrorException>(
() => Run(unavailable, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
Assert.Equal("GitHub did not answer in time; retry later.", ex.Message);
}
+5 -2
View File
@@ -29,7 +29,10 @@ internal static class ExitCodes
/// <summary>GitHub rejected the token or refused access (401/403): fix the token; never retry as is.</summary>
public const int Auth = 3;
/// <summary>A rate limit or temporary throttle: retry later.</summary>
/// <summary>
/// Not the caller's doing: a rate limit or temporary throttle, a network failure,
/// a timeout, or an error on GitHub's side. Retry later.
/// </summary>
public const int Transient = 4;
/// <summary>An unexpected exception inside gclo; details are in the activity log.</summary>
@@ -41,7 +44,7 @@ internal static class ExitCodes
public static int ForAccess(GitHubAccessKind kind) => kind switch
{
GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth,
GitHubAccessKind.RateLimited => Transient,
GitHubAccessKind.RateLimited or GitHubAccessKind.Unavailable => Transient,
_ => Fatal,
};
}
+2 -2
View File
@@ -33,8 +33,8 @@ internal static class OrgsCommand
0 listed successfully (even when only the account login is visible)
1 canceled (Ctrl+C)
2 fatal: bad arguments, or missing token
3 the token was rejected (401)
4 rate limited; retry later
3 the token was rejected (401) or refused access (403)
4 rate limit, network failure, timeout, or an error on GitHub's side; retry later
Security:
There is deliberately no '--token <value>' option: process command lines
+2 -1
View File
@@ -60,7 +60,8 @@ internal static class Program
Exit codes:
0 success, 1 completed with failures or canceled, 2 usage or fatal error,
3 token rejected or access denied, 4 rate limited (retry later),
3 token rejected or access denied, 4 rate limit, network or GitHub-side
failure (retry later),
70 unexpected error (details in the activity log).
""";
+1 -1
View File
@@ -42,7 +42,7 @@ internal static class ReposCommand
1 canceled (Ctrl+C)
2 fatal: bad arguments, missing token, organization not found
3 the token was rejected or may not see the organization
4 rate limited; retry later
4 rate limit, network failure, timeout, or an error on GitHub's side; retry later
""";
+2 -1
View File
@@ -92,7 +92,8 @@ internal static class SyncCommand
account or missing account token, unusable target folder
3 the token was rejected (401) or may not see the organization (403):
fix the token, do not retry as is
4 rate limited or temporarily throttled: retry later
4 not your doing (rate limit, network failure, timeout, or an error on
GitHub's side): retry later
70 an unexpected error inside gclo; see the activity log
With --json or --json-lines, exits 2-70 print no document on stdout.
+439 -2
View File
@@ -125,9 +125,9 @@ public sealed class GitHubListerTests
/// <summary>Octokit's Response type is internal; its IResponse is not.</summary>
private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null) : IResponse
private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null, string body = "") : IResponse
{
public object? Body => "";
public object? Body => body;
@@ -504,4 +504,441 @@ public sealed class GitHubListerTests
Assert.NotNull(new GitHubRepositoryLister());
Assert.NotNull(new GitHubOrganizationLister());
}
// ---------------------------------------------------------------- failures that are not the request's fault (#153)
private static ApiException ServerError() => new("Bad Gateway", HttpStatusCode.BadGateway);
private static HttpRequestException NetworkFailure()
=> new(HttpRequestError.NameResolutionError, "No such host is known. (api.github.com:443)");
/// <summary>A refusal the way the API sends it: a status and a reason in a JSON document.</summary>
private static ForbiddenException ForbiddenBecause(string reason)
=> new(new FakeResponse(
HttpStatusCode.Forbidden,
body: System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string>(StringComparer.Ordinal) { ["message"] = reason })));
/// <summary>What an HTTP client throws when a request runs into its timeout: a cancellation nobody asked for.</summary>
private static TaskCanceledException TimedOut() => new("The request was canceled due to the configured HttpClient.Timeout.");
[Fact]
public async Task Repos_AccessRefused_IsForbidden_WithGitHubsReason()
{
_gateway.OrgPages = (_, _) => throw ForbiddenBecause("Resource protected by organization SAML enforcement");
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.Forbidden, ex.Kind);
Assert.Equal(
"GitHub refused access (403): Resource protected by organization SAML enforcement. "
+ "If the organization uses SAML single sign-on, authorize the token for it; "
+ "otherwise check what the token is allowed to access.",
ex.Message);
Assert.IsType<ForbiddenException>(ex.InnerException);
}
[Fact]
public async Task Repos_AccessRefused_WhileResolvingAUserAccount_IsForbiddenToo()
{
// The refusal can come from any of the requests, not only the first one.
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => throw Forbidden();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.Forbidden, ex.Kind);
}
[Fact]
public async Task Repos_ErrorOnGitHubsSide_IsUnavailable()
{
_gateway.OrgPages = (_, _) => throw ServerError();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.Unavailable, ex.Kind);
Assert.Contains("HTTP 502", ex.Message);
Assert.Contains("retry later", ex.Message);
}
[Fact]
public async Task Repos_NetworkFailure_IsUnavailable_WithTheReason()
{
_gateway.OrgPages = (_, _) => throw NetworkFailure();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.Unavailable, ex.Kind);
Assert.Equal(
"GitHub could not be reached: its name could not be resolved (DNS). Check the network connection and retry.",
ex.Message);
}
[Fact]
public async Task Repos_RequestTimedOut_IsUnavailable_NotACancellation()
{
_gateway.OrgPages = (_, _) => throw TimedOut();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.Unavailable, ex.Kind);
Assert.Contains("did not answer in time", ex.Message);
}
[Fact]
public async Task Repos_AFailureWithNoKnownMeaning_StaysWhatItIs()
{
_gateway.OrgPages = (_, _) => throw new ApiException("Validation Failed", HttpStatusCode.UnprocessableEntity);
ApiException ex = await Assert.ThrowsAsync<ApiException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.StatusCode);
}
[Fact]
public async Task Repos_CancellationDuringARequestThatHangs_DoesNotWaitForIt()
{
using var cts = new CancellationTokenSource();
var requested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var never = new TaskCompletionSource<IReadOnlyList<GitHubRepo>>(TaskCreationOptions.RunContinuationsAsynchronously);
_gateway.OrgPages = (_, _) =>
{
requested.SetResult();
return never.Task;
};
Task<IReadOnlyList<RepoDescriptor>> listing = RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token);
await requested.Task.WaitAsync(TimeSpan.FromSeconds(30));
cts.Cancel();
OperationCanceledException ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => listing.WaitAsync(TimeSpan.FromSeconds(30)));
Assert.Equal(cts.Token, ex.CancellationToken); // the caller's cancellation, not a timeout
}
[Fact]
public async Task Repos_CancellationWhileTheAccountKindIsLookedUp_DoesNotWaitForIt()
{
using var cts = new CancellationTokenSource();
var requested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var never = new TaskCompletionSource<GitHubAccountKind>(TaskCreationOptions.RunContinuationsAsynchronously);
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ =>
{
requested.SetResult();
return never.Task;
};
Task<IReadOnlyList<RepoDescriptor>> listing = RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token);
await requested.Task.WaitAsync(TimeSpan.FromSeconds(30));
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listing.WaitAsync(TimeSpan.FromSeconds(30)));
}
[Fact]
public async Task Orgs_AccessRefusedForTheAccountItself_IsForbidden()
{
// Only a refusal of the organization list is the missing-scope case; this one is not.
_gateway.CurrentUser = () => throw Forbidden();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Equal(GitHubAccessKind.Forbidden, ex.Kind);
}
[Fact]
public async Task Orgs_ErrorOnGitHubsSide_NetworkFailure_AndTimeout_AreUnavailable()
{
_gateway.OrgLogins = () => throw ServerError();
GitHubAccessException server = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
_gateway.OrgLogins = () => throw NetworkFailure();
GitHubAccessException network = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
_gateway.OrgLogins = () => throw TimedOut();
GitHubAccessException timeout = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Equal(GitHubAccessKind.Unavailable, server.Kind);
Assert.Equal(GitHubAccessKind.Unavailable, network.Kind);
Assert.Equal(GitHubAccessKind.Unavailable, timeout.Kind);
}
[Fact]
public async Task Orgs_CancellationDuringARequestThatHangs_DoesNotWaitForIt()
{
using var cts = new CancellationTokenSource();
var requested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var never = new TaskCompletionSource<IReadOnlyList<string>>(TaskCreationOptions.RunContinuationsAsynchronously);
_gateway.OrgLogins = () =>
{
requested.SetResult();
return never.Task;
};
Task<IReadOnlyList<string>> listing = OrgLister().ListOrganizationsAsync("tok-1234567890", cts.Token);
await requested.Task.WaitAsync(TimeSpan.FromSeconds(30));
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listing.WaitAsync(TimeSpan.FromSeconds(30)));
}
// ---------------------------------------------------------------- the shared mapping itself (#153)
[Fact]
public void Failures_EveryKnownFailure_HasItsKind()
{
(Exception Failure, GitHubAccessKind Kind)[] known =
[
(Unauthorized(), GitHubAccessKind.Unauthorized),
(RateLimited(), GitHubAccessKind.RateLimited),
(SecondaryRateLimited(), GitHubAccessKind.RateLimited),
(Abuse(), GitHubAccessKind.RateLimited),
(LoginAttemptsExceeded(), GitHubAccessKind.RateLimited),
(Forbidden(), GitHubAccessKind.Forbidden),
(new LegalRestrictionException("Repository access blocked", (HttpStatusCode)451), GitHubAccessKind.Forbidden),
(new ApiException("Too Many Requests", HttpStatusCode.TooManyRequests), GitHubAccessKind.RateLimited),
(new ApiException("Proxy Authentication Required", HttpStatusCode.ProxyAuthenticationRequired), GitHubAccessKind.Unavailable),
(ServerError(), GitHubAccessKind.Unavailable),
(new ApiException("Service Unavailable", HttpStatusCode.ServiceUnavailable), GitHubAccessKind.Unavailable),
(NetworkFailure(), GitHubAccessKind.Unavailable),
(TimedOut(), GitHubAccessKind.Unavailable),
];
Assert.All(known, k =>
{
Assert.True(GitHubFailures.Knows(k.Failure, CancellationToken.None), k.Failure.GetType().Name);
GitHubAccessException translated = GitHubFailures.Translate(k.Failure);
Assert.Equal(k.Kind, translated.Kind);
Assert.Same(k.Failure, translated.InnerException);
});
}
[Fact]
public void Failures_ALegalRestriction_SaysItsStatusAndReason()
{
var blocked = new LegalRestrictionException(new FakeResponse(
(HttpStatusCode)451, body: "{\"message\":\"Repository access blocked\"}"));
GitHubAccessException translated = GitHubFailures.Translate(blocked);
Assert.StartsWith("GitHub refused access (451): Repository access blocked. If the organization", translated.Message);
}
[Fact]
public void Failures_WhatHasNoKnownMeaning_IsNotTranslated()
{
using var canceled = new CancellationTokenSource();
canceled.Cancel();
Exception[] unknown =
[
NotFound(), // each lister decides what "not found" means where it happens
new ApiException("Validation Failed", HttpStatusCode.UnprocessableEntity),
new InvalidOperationException("a bug"),
new GitHubAccessException(GitHubAccessKind.Forbidden, "already translated"),
];
Assert.All(unknown, failure => Assert.False(GitHubFailures.Knows(failure, CancellationToken.None), failure.GetType().Name));
// A cancellation the caller asked for is a cancellation, not a timeout.
Assert.False(GitHubFailures.Knows(new OperationCanceledException(canceled.Token), canceled.Token));
Assert.Throws<ArgumentException>(() => GitHubFailures.Translate(new InvalidOperationException("a bug")));
}
[Fact]
public void Failures_ACancellationThatArrivesAfterTheDecision_DoesNotChangeIt()
{
// The request timed out while the caller had not canceled: that is decided once.
using var caller = new CancellationTokenSource();
TaskCanceledException timeout = TimedOut();
bool known = GitHubFailures.Knows(timeout, caller.Token);
caller.Cancel(); // lands between the decision and the translation
GitHubAccessException translated = GitHubFailures.Translate(timeout);
Assert.True(known);
Assert.Equal(GitHubAccessKind.Unavailable, translated.Kind);
}
[Fact]
public async Task Repos_AFailureThatArrivesWhenTheRequestEnds_IsTranslatedLikeOneThrownAtOnce()
{
// The real gateway fails the request it returned; it does not throw before returning one.
_gateway.OrgPages = (_, _) => Task.FromException<IReadOnlyList<GitHubRepo>>(ServerError());
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.Unavailable, ex.Kind);
}
[Fact]
public async Task Repos_ARequestAbandonedOnCancellation_MayFailLater_WithoutAnyoneBeingTold()
{
using var cts = new CancellationTokenSource();
var requested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var request = new TaskCompletionSource<IReadOnlyList<GitHubRepo>>(TaskCreationOptions.RunContinuationsAsynchronously);
_gateway.OrgPages = (_, _) =>
{
requested.SetResult();
return request.Task;
};
Task<IReadOnlyList<RepoDescriptor>> listing = RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token);
await requested.Task.WaitAsync(TimeSpan.FromSeconds(30));
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listing.WaitAsync(TimeSpan.FromSeconds(30)));
request.SetException(NetworkFailure()); // the abandoned request ends on its own, badly
// The listing ended as a cancellation and stays one; the late failure belongs to nobody.
Assert.True(listing.IsCanceled);
await Assert.ThrowsAsync<HttpRequestException>(() => request.Task);
}
[Fact]
public async Task Repos_TooManyRequests_IsARateLimit_AlsoAsStatus429()
{
_gateway.OrgPages = (_, _) => throw new ApiException("Too Many Requests", HttpStatusCode.TooManyRequests);
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Equal(GitHubAccessKind.RateLimited, ex.Kind);
Assert.Contains("429", ex.Message);
}
[Theory]
[InlineData(HttpRequestError.NameResolutionError, "its name could not be resolved (DNS)")]
[InlineData(HttpRequestError.ConnectionError, "the connection could not be made")]
[InlineData(HttpRequestError.SecureConnectionError, "a secure (TLS) connection could not be established")]
[InlineData(HttpRequestError.ProxyTunnelError, "the proxy on the way refused the connection")]
[InlineData(HttpRequestError.Unknown, "the request failed on the way.")]
[InlineData(HttpRequestError.ResponseEnded, "the request failed on the way (ResponseEnded)")]
public void Failures_ANetworkFailure_IsDescribedByItsKind(HttpRequestError error, string expected)
{
GitHubAccessException translated = GitHubFailures.Translate(new HttpRequestException(error, "whatever the platform says"));
Assert.StartsWith("GitHub could not be reached: ", translated.Message);
Assert.Contains(expected, translated.Message);
Assert.EndsWith(" Check the network connection and retry.", translated.Message);
}
[Fact]
public void Failures_ANetworkFailure_NeverCopiesThePlatformsText_WhichCanNameAProxyWithItsPassword()
{
var failure = new HttpRequestException(
HttpRequestError.ProxyTunnelError,
"The proxy tunnel request to proxy 'http://proxyuser:PROXYPASSWORD@10.0.0.1:8080/' failed with status code '407'.");
GitHubAccessException translated = GitHubFailures.Translate(failure);
Assert.DoesNotContain("PROXYPASSWORD", translated.Message);
Assert.DoesNotContain("proxyuser", translated.Message);
Assert.Same(failure, translated.InnerException); // the full text stays with the cause, for the log
}
[Fact]
public void Failures_ARefusalWithoutAReasonFromTheApi_SaysNone_AndCopiesNoPage()
{
// A web filter or a proxy answers 403 with a page of its own; that is not GitHub's reason.
var blockPage = new ForbiddenException(new FakeResponse(
HttpStatusCode.Forbidden, body: "<html><body><h1>Blocked by policy</h1>" + new string('x', 3000) + "</body></html>"));
GitHubAccessException translated = GitHubFailures.Translate(blockPage);
Assert.Equal(
"GitHub refused access (403). If the organization uses SAML single sign-on, authorize the token for it; "
+ "otherwise check what the token is allowed to access.",
translated.Message);
}
[Theory]
[InlineData("")]
[InlineData("[]")]
[InlineData("{\"other\":1}")]
[InlineData("{\"message\":42}")]
[InlineData("{\"message\":\" \"}")]
public void Failures_ARefusalWhoseAnswerHasNoReasonInIt_SaysNone(string body)
{
GitHubAccessException translated = GitHubFailures.Translate(
new ForbiddenException(new FakeResponse(HttpStatusCode.Forbidden, body: body)));
Assert.StartsWith("GitHub refused access (403). If the organization", translated.Message);
}
[Fact]
public void Failures_AReasonFromTheApi_IsPutOnOneLine_AndCut()
{
string emoji = char.ConvertFromUtf32(0x1F600);
GitHubAccessException lines = GitHubFailures.Translate(ForbiddenBecause(" first line\r\n second\tline? "));
GitHubAccessException longReason = GitHubFailures.Translate(ForbiddenBecause(new string('r', 400)));
GitHubAccessException cutAtAPair = GitHubFailures.Translate(ForbiddenBecause(new string('r', 299) + emoji + "tail"));
Assert.StartsWith("GitHub refused access (403): first line second line? If the organization", lines.Message);
Assert.StartsWith("GitHub refused access (403): " + new string('r', 300) + "... If the organization", longReason.Message);
Assert.StartsWith("GitHub refused access (403): " + new string('r', 299) + "... If the organization", cutAtAPair.Message);
}
}
+48
View File
@@ -594,4 +594,52 @@ public sealed class OrgSyncEngineTests : IDisposable
Assert.True(cts.IsCancellationRequested);
Assert.Equal(new SyncSummary(Total: 1, Cloned: 1, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
// ---------------------------------------------------------------- nothing after a final state (#153)
[Fact]
public async Task Sync_ProgressFromAnAbandonedClone_IsNotReportedAfterTheRepositoryWasCanceled()
{
_lister.Repositories = Repos("stuck");
var neverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Action<double>? transfer = null;
_git.CloneHandler = (_, _, _, onProgress, _) =>
{
transfer = onProgress;
return neverDone.Task; // ignores cancellation, as a wedged transport does
};
using var cts = new CancellationTokenSource();
Task<SyncSummary> syncTask = CreateEngine().SyncAsync(CreateRequest(), _progress, cts.Token);
await WaitUntilAsync(() => transfer is not null, "the clone to start");
transfer!(0.25);
cts.Cancel();
await syncTask.WaitAsync(TimeSpan.FromSeconds(10));
transfer(0.5); // the abandoned operation is still running
neverDone.SetResult();
Assert.Equal(
[SyncStatus.Queued, SyncStatus.Cloning, SyncStatus.Cloning, SyncStatus.Canceled],
_progress.StatusesFor("stuck"));
Assert.Equal(0.25, _progress.Reports.Last(r => r.Status == SyncStatus.Cloning).Percent);
}
[Fact]
public async Task Sync_ASecondRun_ReportsTheSameRepositoryAgain()
{
// The rule is per run: what a repository reported last time holds nothing back now.
_lister.Repositories = Repos("solo");
OrgSyncEngine engine = CreateEngine();
await engine.SyncAsync(CreateRequest(), _progress);
await engine.SyncAsync(CreateRequest(), _progress);
Assert.Equal(
[SyncStatus.Queued, SyncStatus.Cloning, SyncStatus.Done, SyncStatus.Queued, SyncStatus.Cloning, SyncStatus.Done],
_progress.StatusesFor("solo"));
}
}
+133
View File
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="RunProgress"/> passes every report on, except what arrives for a
/// repository after its final state in the run (#153).
/// </summary>
public sealed class RunProgressTests
{
private readonly RecordingProgress _seen = new();
private RunProgress NewRun() => new(_seen);
[Fact]
public void Report_BeforeTheFinalState_IsPassedOn_InOrder()
{
RunProgress run = NewRun();
run.Report(new RepoProgress("alpha", SyncStatus.Queued));
run.Report(new RepoProgress("alpha", SyncStatus.Cloning));
run.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.5));
run.Report(new RepoProgress("alpha", SyncStatus.Done));
Assert.Equal([SyncStatus.Queued, SyncStatus.Cloning, SyncStatus.Cloning, SyncStatus.Done], _seen.StatusesFor("alpha"));
Assert.Equal(0.5, _seen.Reports[2].Percent);
}
[Theory]
[InlineData(SyncStatus.Done)]
[InlineData(SyncStatus.Failed)]
[InlineData(SyncStatus.Canceled)]
public void Report_AfterAFinalState_IsDropped_WhateverItSays(SyncStatus final)
{
RunProgress run = NewRun();
run.Report(new RepoProgress("alpha", final));
run.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.9));
run.Report(new RepoProgress("alpha", SyncStatus.Pulling));
run.Report(new RepoProgress("alpha", SyncStatus.Queued));
run.Report(new RepoProgress("alpha", SyncStatus.Done));
run.Report(new RepoProgress("alpha", SyncStatus.Failed, "late"));
run.Report(new RepoProgress("ALPHA", SyncStatus.Cloning)); // one repository, whatever its case
Assert.Equal([final], _seen.StatusesFor("alpha"));
Assert.Empty(_seen.StatusesFor("ALPHA"));
}
[Fact]
public void Report_ForAnotherRepository_IsNotHeldBack()
{
RunProgress run = NewRun();
run.Report(new RepoProgress("alpha", SyncStatus.Canceled));
run.Report(new RepoProgress("bravo", SyncStatus.Cloning));
run.Report(new RepoProgress("bravo", SyncStatus.Done));
Assert.Equal([SyncStatus.Cloning, SyncStatus.Done], _seen.StatusesFor("bravo"));
}
[Fact]
public void Report_FromManyThreads_NeverPutsProgressAfterTheFinalState()
{
for(int round = 0; round < 50; round++)
{
var seen = new RecordingProgress();
var run = new RunProgress(seen);
Parallel.Invoke(
() =>
{
for(int i = 0; i < 200; i++)
{
run.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: i / 200.0));
}
},
() => run.Report(new RepoProgress("alpha", SyncStatus.Canceled)));
IReadOnlyList<SyncStatus> statuses = seen.StatusesFor("alpha");
Assert.Equal(SyncStatus.Canceled, statuses[^1]);
Assert.Single(statuses, s => s == SyncStatus.Canceled);
}
}
/// <summary>A caller's progress that fails on the first final state it is given.</summary>
private sealed class ThrowsOnDone(RecordingProgress seen) : IProgress<RepoProgress>
{
public void Report(RepoProgress value)
{
if(value.Status == SyncStatus.Done)
{
throw new InvalidOperationException("the caller's progress failed");
}
seen.Report(value);
}
}
[Fact]
public void Report_AFinalStateTheCallerDidNotTake_DoesNotCount_SoTheNextOneGetsThrough()
{
var run = new RunProgress(new ThrowsOnDone(_seen));
Assert.Throws<InvalidOperationException>(() => run.Report(new RepoProgress("alpha", SyncStatus.Done)));
run.Report(new RepoProgress("alpha", SyncStatus.Failed, "the caller's progress failed"));
run.Report(new RepoProgress("alpha", SyncStatus.Cloning));
Assert.Equal([SyncStatus.Failed], _seen.StatusesFor("alpha"));
}
[Fact]
public void Constructor_RefusesAMissingProgress()
=> Assert.Throws<ArgumentNullException>(() => new RunProgress(null!));
}
+1 -1
View File
@@ -7,7 +7,7 @@ namespace gclo.Engine;
/// <summary>
/// A GitHub API refusal translated into an actionable message. Derives from
/// A failed GitHub API request (a refusal, or GitHub not answering) translated into an actionable message. Derives from
/// <see cref="InvalidOperationException"/> so every existing catch keeps working;
/// <see cref="Kind"/> lets a caller tell "fix your token" from "wait and retry"
/// without parsing the message — the CLI maps it to distinct exit codes.
+6
View File
@@ -20,4 +20,10 @@ public enum GitHubAccessKind
/// <summary>No organization or user account with that login is visible to the token.</summary>
NotFound,
/// <summary>
/// GitHub could not be reached, did not answer in time, or failed on its side
/// (a network error, a timeout, HTTP 5xx): nothing is wrong with the request, retry later.
/// </summary>
Unavailable,
}
+227
View File
@@ -0,0 +1,227 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Text.Json;
using Octokit;
namespace gclo.Engine;
/// <summary>
/// The one place that says what a failed GitHub request means to a caller: which
/// failures are the token's (fix it), which are access (grant it), and which are
/// not the caller's at all (the network, a timeout, GitHub itself: retry later).
/// Both listers go through it, so they cannot drift apart, and a failure it does
/// not know stays what it is: an unexpected error.
/// </summary>
internal static class GitHubFailures
{
/// <summary>The longest reason from GitHub that is put into a message.</summary>
private const int MaxReasonLength = 300;
/// <summary>
/// Waits for one gateway request, or for the caller's cancellation, whichever
/// comes first. The gateway cannot cancel a request, so a canceled one is left
/// to end on its own (at the HTTP timeout at the latest); whatever it ends with
/// is looked at here, so it never surfaces as a failure nobody observed.
/// </summary>
public static Task<T> WaitFor<T>(Task<T> request, CancellationToken cancellationToken)
{
_ = request.ContinueWith(
static abandoned => _ = abandoned.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return request.WaitAsync(cancellationToken);
}
/// <summary>
/// True when <paramref name="exception"/> is a failure with a known meaning, which
/// <see cref="Translate"/> then puts into words.
/// </summary>
/// <param name="exception">What a gateway call threw.</param>
/// <param name="cancellationToken">
/// The caller's token: a canceled request is a timeout only when the caller did
/// not cancel it. This is the one moment the token is looked at.
/// </param>
public static bool Knows(Exception exception, CancellationToken cancellationToken)
=> Find(exception, canceledByCaller: cancellationToken.IsCancellationRequested) is not null;
/// <summary>
/// The <see cref="GitHubAccessException"/> that says what <paramref name="exception"/>
/// means to a caller. Only for a failure that <see cref="Knows"/> accepted: the
/// decision whether a canceled request was the caller's doing was made there and
/// is not made again here, so a cancellation that arrives in between changes nothing.
/// </summary>
/// <exception cref="ArgumentException">The failure has no known meaning.</exception>
public static GitHubAccessException Translate(Exception exception)
=> Find(exception, canceledByCaller: false)
?? throw new ArgumentException("This failure has no known meaning; ask Knows first.", nameof(exception));
private static GitHubAccessException? Find(Exception exception, bool canceledByCaller)
{
///////////////////////////////////////////////////////////////////////////////
// Order matters: the throttling exceptions derive from ForbiddenException, //
// and every one of these but the last two derives from ApiException. //
///////////////////////////////////////////////////////////////////////////////
return exception switch
{
AuthorizationException => new GitHubAccessException(
GitHubAccessKind.Unauthorized,
"GitHub rejected the token (401). Check that the PAT is complete, has not expired or been revoked, "
+ "and has 'repo' (classic) or repository read access (fine-grained).",
exception),
RateLimitExceededException limit => new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {limit.Reset:u}.", exception),
// A burst of requests, not a scope problem; Octokit models it beside (not
// under) AbuseException, so both get the same translation.
SecondaryRateLimitExceededException => new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.",
exception),
AbuseException abuse => new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); "
+ $"retry in {abuse.RetryAfterSeconds ?? 60} seconds.",
exception),
LoginAttemptsExceededException => new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub temporarily blocked this token after too many failed attempts; wait a few minutes and retry.",
exception),
// GitHub answers a rate limit with 403 or with 429; Octokit has no type for the second.
ApiException { StatusCode: HttpStatusCode.TooManyRequests } => new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub is limiting requests from this token (429: too many requests); retry in a minute.",
exception),
ForbiddenException or LegalRestrictionException => new GitHubAccessException(
GitHubAccessKind.Forbidden, Refused((ApiException)exception), exception),
ApiException { StatusCode: HttpStatusCode.ProxyAuthenticationRequired } => new GitHubAccessException(
GitHubAccessKind.Unavailable,
"A proxy on the way to GitHub asked gclo to sign in (HTTP 407), which it cannot do. "
+ "Check the proxy settings of this machine.",
exception),
ApiException { StatusCode: >= HttpStatusCode.InternalServerError } server => new GitHubAccessException(
GitHubAccessKind.Unavailable,
$"GitHub answered with an error on its side (HTTP {(int)server.StatusCode}); retry later.",
exception),
HttpRequestException network => new GitHubAccessException(
GitHubAccessKind.Unavailable, Unreachable(network.HttpRequestError), exception),
OperationCanceledException when !canceledByCaller => new GitHubAccessException(
GitHubAccessKind.Unavailable, "GitHub did not answer in time; retry later.", exception),
_ => null,
};
}
/// <summary>
/// The words for a refused request. GitHub's own reason is part of them only when
/// it came as the reason of an API answer; a page from something in between (a web
/// filter, a proxy) is not a reason and is left out.
/// </summary>
private static string Refused(ApiException refusal)
{
const string Advice = "If the organization uses SAML single sign-on, authorize the token for it; "
+ "otherwise check what the token is allowed to access.";
string reason = ReasonOf(refusal);
return reason.Length == 0
? $"GitHub refused access ({(int)refusal.StatusCode}). {Advice}"
: $"GitHub refused access ({(int)refusal.StatusCode}): {reason} {Advice}";
}
/// <summary>
/// The reason GitHub's API gave for a refusal: the "message" of the JSON document it
/// answered with. Anything else (no body, a page of HTML) has no reason in it;
/// Octokit would hand over such a body whole.
/// </summary>
private static string ReasonOf(ApiException refusal)
{
if(refusal.HttpResponse?.Body is not string body)
{
return "";
}
try
{
using var document = JsonDocument.Parse(body);
return document.RootElement.ValueKind == JsonValueKind.Object
&& document.RootElement.TryGetProperty("message", out JsonElement message)
&& message.ValueKind == JsonValueKind.String
? OneLine(message.GetString())
: "";
}
catch(JsonException)
{
return "";
}
}
/// <summary>
/// The words for a request that never got an answer. Chosen by the kind of failure,
/// never copied from the failure's own text: that text can name a proxy together
/// with the user name and password used for it.
/// </summary>
private static string Unreachable(HttpRequestError error)
{
string what = error switch
{
HttpRequestError.NameResolutionError => "its name could not be resolved (DNS)",
HttpRequestError.ConnectionError => "the connection could not be made",
HttpRequestError.SecureConnectionError
=> "a secure (TLS) connection could not be established; a proxy or a security product that inspects traffic is a common cause",
HttpRequestError.ProxyTunnelError => "the proxy on the way refused the connection",
HttpRequestError.Unknown => "the request failed on the way",
_ => $"the request failed on the way ({error})",
};
return $"GitHub could not be reached: {what}. Check the network connection and retry.";
}
/// <summary>
/// A reason from GitHub as one line of bounded length that ends a sentence; empty
/// when there is none.
/// </summary>
private static string OneLine(string? text)
{
string line = string.Join(' ', (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
if(line.Length == 0)
{
return "";
}
if(line.Length > MaxReasonLength)
{
// Cut between characters, never through a pair of code units.
int keep = char.IsHighSurrogate(line[MaxReasonLength - 1]) ? MaxReasonLength - 1 : MaxReasonLength;
return line[..keep] + "...";
}
return line[^1] is '.' or '!' or '?' ? line : line + ".";
}
}
+10 -28
View File
@@ -43,12 +43,16 @@ public sealed class GitHubOrganizationLister : IOrganizationLister
try
{
// The token's own account is a valid sync target too (personal repos
// live under /users, not /orgs), so it heads the list.
userLogin = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
// live under /users, not /orgs), so it heads the list. The gateway cannot
// be canceled, so each request is awaited with the token: one that hangs
// is abandoned and ends on its own, at the HTTP timeout.
userLogin = await GitHubFailures.WaitFor(gateway.GetCurrentUserLoginAsync(), cancellationToken)
.ConfigureAwait(false);
try
{
organizations = await gateway.GetOrganizationLoginsAsync().ConfigureAwait(false);
organizations = await GitHubFailures.WaitFor(gateway.GetOrganizationLoginsAsync(), cancellationToken)
.ConfigureAwait(false);
}
catch(ForbiddenException ex)
when(ex is not RateLimitExceededException
@@ -65,32 +69,10 @@ public sealed class GitHubOrganizationLister : IOrganizationLister
return [userLogin];
}
}
catch(AuthorizationException ex)
catch(Exception ex) when(GitHubFailures.Knows(ex, cancellationToken))
{
throw new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401). Check the PAT.", ex);
}
catch(RateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch(SecondaryRateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch(AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
catch(LoginAttemptsExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub temporarily blocked this token after too many failed attempts; wait a few minutes and retry.", ex);
// What the failure means to a caller is decided in one place, for both listers.
throw GitHubFailures.Translate(ex);
}
cancellationToken.ThrowIfCancellationRequested();
+12 -27
View File
@@ -53,14 +53,17 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
IGitHubGateway gateway = _gatewayFactory(token);
// Pages manually (instead of one GetAll* call) so cancellation is honored
// between round trips on owners with hundreds of repositories.
// between round trips on owners with hundreds of repositories. Each round
// trip is awaited with the token as well: the gateway cannot be canceled, so
// a request that hangs is abandoned (it ends on its own, at the HTTP timeout).
async Task<List<GitHubRepo>> PageAsync(Func<int, Task<IReadOnlyList<GitHubRepo>>> fetchPage)
{
var all = new List<GitHubRepo>();
for(int page = 1; ; page++)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<GitHubRepo> batch = await fetchPage(page).ConfigureAwait(false);
IReadOnlyList<GitHubRepo> batch = await GitHubFailures.WaitFor(fetchPage(page), cancellationToken)
.ConfigureAwait(false);
all.AddRange(batch);
if(batch.Count < IGitHubGateway.PageSize)
{
@@ -87,7 +90,8 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
// organization would list only its public repositories and report a
// green sync that silently omitted the rest, so the account kind is
// resolved first and an invisible organization is an error (#31).
GitHubAccountKind kind = await gateway.GetAccountKindAsync(organization).ConfigureAwait(false);
GitHubAccountKind kind = await GitHubFailures
.WaitFor(gateway.GetAccountKindAsync(organization), cancellationToken).ConfigureAwait(false);
if(kind == GitHubAccountKind.Organization)
{
throw new GitHubAccessException(
@@ -106,7 +110,8 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
// A user account: the token's own account gets its owned repos
// (including private); any other user account yields the repos the
// token can see there (public).
string currentUser = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
string currentUser = await GitHubFailures
.WaitFor(gateway.GetCurrentUserLoginAsync(), cancellationToken).ConfigureAwait(false);
if(string.Equals(currentUser, organization, StringComparison.OrdinalIgnoreCase))
{
repositories = await PageAsync(gateway.GetOwnRepositoriesPageAsync)
@@ -129,30 +134,10 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
}
}
}
catch(AuthorizationException ex)
catch(Exception ex) when(GitHubFailures.Knows(ex, cancellationToken))
{
throw new GitHubAccessException(
GitHubAccessKind.Unauthorized,
"GitHub rejected the token (401). Check the PAT and make sure it has 'repo' (classic) or repository read access (fine-grained).", ex);
}
catch(RateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch(SecondaryRateLimitExceededException ex)
{
// A burst of requests, not a scope problem; Octokit models it beside (not
// under) AbuseException, so both get the same translation.
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch(AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
// What the failure means to a caller is decided in one place, for both listers.
throw GitHubFailures.Translate(ex);
}
cancellationToken.ThrowIfCancellationRequested();
+3
View File
@@ -71,6 +71,9 @@ public sealed class OrgSyncEngine
ArgumentException.ThrowIfNullOrWhiteSpace(request.Token, nameof(request));
ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot, nameof(request));
// Nothing is reported for a repository after its final state in this run.
progress = progress is null ? null : new RunProgress(progress);
var repos = repositories
// Names key the pending dictionary and the target folders; a duplicate
// (possible from pagination shifts) must not crash the run or race two
+62
View File
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// What one run reports through. Every report is passed on, except a report for a
/// repository that has already reported its final state in this run: a git
/// operation that was abandoned on cancellation keeps running for a while and
/// keeps reporting transfer progress, and without this a row that says Canceled
/// would go back to "Cloning 40%". A repository therefore ends a run in exactly
/// one final state, and nothing follows it.
/// </summary>
internal sealed class RunProgress : IProgress<RepoProgress>
{
private readonly Lock _gate = new();
private readonly IProgress<RepoProgress> _inner;
// The engine treats names that differ only by case as one repository.
private readonly HashSet<string> _finished = new(StringComparer.OrdinalIgnoreCase);
/// <param name="inner">The caller's progress, which receives what is passed on.</param>
public RunProgress(IProgress<RepoProgress> inner)
{
ArgumentNullException.ThrowIfNull(inner);
_inner = inner;
}
/// <inheritdoc/>
public void Report(RepoProgress value)
{
///////////////////////////////////////////////////////////////////////////////
// Passed on inside the lock: a progress report racing the final one on //
// another thread is then either before it, or dropped; never after it. //
///////////////////////////////////////////////////////////////////////////////
lock(_gate)
{
if(_finished.Contains(value.RepoName))
{
return;
}
_inner.Report(value);
// Only a final state the caller has actually been given counts: if its
// progress threw, the repository has not finished as far as it knows, and
// the failure the engine then reports for it must still get through.
if(value.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled)
{
_finished.Add(value.RepoName);
}
}
}
}