diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4598571..f512340 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,16 +34,18 @@ jobs:
- name: Build solution (x64, warnings as errors)
run: dotnet build gclo.slnx -p:Platform=x64 -warnaserror
- - name: Run tests with coverage
- # The solution build above ran with the x64 *solution* platform, but
- # gclo.Engine.Tests is an AnyCPU-only test project, so exactly where its
- # outputs land depends on how the .slnx maps solution platform -> project
- # platform. Rather than couple `dotnet test --no-build` to that mapping,
- # rebuild just the test project (plus gclo.Engine) with its default
- # AnyCPU platform here - it is a small, deterministic incremental build.
- run: dotnet test gclo.Engine.Tests --collect:"XPlat Code Coverage" --results-directory TestResults
+ # coverage.runsettings scopes collection to the three library assemblies and
+ # excludes source-generated code and the [ExcludeFromCodeCoverage] native/
+ # network adapters (LibGit2GitClient, OctokitGateway). gclo.Engine.Tests
+ # exercises gclo.Engine and gclo.ViewModels; gclo.Cli.Tests covers the CLI
+ # (its assembly is 'gclo'). Both write into TestResults; the gate reads all.
+ - name: Run engine and view-model tests with coverage
+ run: dotnet test gclo.Engine.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
- - name: Enforce gclo.Engine line coverage >= 60%
+ - name: Run CLI tests with coverage
+ run: dotnet test gclo.Cli.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
+
+ - name: Enforce 100% line coverage (Engine, ViewModels, Cli)
shell: pwsh
run: |
$files = Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml
@@ -51,24 +53,33 @@ jobs:
Write-Error 'No coverage.cobertura.xml found under TestResults. Did the coverage collector run?'
exit 1
}
- $file = $files | Sort-Object LastWriteTime -Descending | Select-Object -First 1
- Write-Host "Parsing coverage report: $($file.FullName)"
- [xml]$cobertura = Get-Content -LiteralPath $file.FullName
- $packages = @($cobertura.coverage.packages.package)
- $engine = $packages | Where-Object { $_.name -eq 'gclo.Engine' }
- if (-not $engine) {
- $names = ($packages | ForEach-Object { $_.name }) -join ', '
- Write-Error "Package 'gclo.Engine' not found in the coverage report. Packages present: $names"
- exit 1
+ # Merge every collected report: the two test projects each emit their own,
+ # and a package's covered lines can be split across them.
+ # The CLI's assembly (and thus its coverage package) is 'gclo' — it ships as gclo.exe.
+ $required = @('gclo.Engine', 'gclo.ViewModels', 'gclo')
+ $failed = $false
+ foreach ($name in $required) {
+ $rates = foreach ($f in $files) {
+ [xml]$doc = Get-Content -LiteralPath $f.FullName
+ $pkg = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
+ if ($pkg) { [double]::Parse($pkg.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture) }
+ }
+ if (-not $rates) {
+ Write-Error "Package '$name' not found in any coverage report."
+ $failed = $true
+ continue
+ }
+ # The report with the most coverage for this package is authoritative.
+ $best = ($rates | Measure-Object -Maximum).Maximum
+ $percent = [math]::Round($best * 100, 2)
+ Write-Host "$name line coverage: $percent% (required 100%)"
+ if ($best -lt 1.0) {
+ Write-Error "Coverage gate FAILED: $name line-rate $best is below the required 1.00."
+ $failed = $true
+ }
}
- $lineRate = [double]::Parse($engine.'line-rate', [System.Globalization.CultureInfo]::InvariantCulture)
- $percent = [math]::Round($lineRate * 100, 2)
- Write-Host "gclo.Engine line coverage: $percent% (line-rate $lineRate, required >= 0.60)"
- if ($lineRate -lt 0.60) {
- Write-Error "Coverage gate FAILED: gclo.Engine line-rate $lineRate is below the required 0.60."
- exit 1
- }
- Write-Host 'Coverage gate passed.'
+ if ($failed) { exit 1 }
+ Write-Host 'Coverage gate passed: all measured assemblies at 100%.'
- name: Upload coverage artifact
if: always()
diff --git a/coverage.runsettings b/coverage.runsettings
new file mode 100644
index 0000000..aec9ee6
--- /dev/null
+++ b/coverage.runsettings
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+ cobertura
+
+ [gclo.Engine]*,[gclo.ViewModels]*,[gclo]*
+ GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute
+
+
+
+
+
diff --git a/gclo.Cli.Tests/.editorconfig b/gclo.Cli.Tests/.editorconfig
new file mode 100644
index 0000000..929cc6b
--- /dev/null
+++ b/gclo.Cli.Tests/.editorconfig
@@ -0,0 +1,9 @@
+# Analyzer tuning local to gclo.Cli.Tests (inherits the repo root .editorconfig).
+# Mirrors the gclo.Engine.Tests suppressions, which are keyed to that project's
+# path in the root file and therefore do not reach this one.
+[*.cs]
+# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention.
+dotnet_diagnostic.CA1707.severity = none
+# CA1861: inline array arguments keep test data at the assertion site; each test runs once,
+# so the "repeated allocation" concern does not apply.
+dotnet_diagnostic.CA1861.severity = none
diff --git a/gclo.Cli.Tests/AccountsCommandTests.cs b/gclo.Cli.Tests/AccountsCommandTests.cs
new file mode 100644
index 0000000..2328006
--- /dev/null
+++ b/gclo.Cli.Tests/AccountsCommandTests.cs
@@ -0,0 +1,105 @@
+using gclo.Engine;
+using gclo.ViewModels;
+
+namespace gclo.Cli.Tests;
+
+/// Covers 'gclo accounts': parsing, empty/populated listings, and JSON output.
+public sealed class AccountsCommandTests : IDisposable
+{
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
+ private readonly InMemoryVault _vault = new();
+ private AccountsStore? _store;
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_dir, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+
+ private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
+
+ private Func Open => _ => (Store, _vault);
+
+ private void Seed(string name, string org, string target, DateTimeOffset? lastSync = null)
+ {
+ Store.Save(
+ new Account { Id = Guid.NewGuid(), Name = name, Organization = org, TargetRoot = target },
+ "tok-" + name);
+ if (lastSync is { } when)
+ {
+ Account saved = Store.FindByName(name)!;
+ Store.RecordSyncResult(saved.Id, when, "Finished.");
+ }
+ }
+
+ [Fact]
+ public void Help_PrintsUsage()
+ {
+ using var console = new ConsoleCapture();
+
+ int code = AccountsCommand.Run(["--help"], Open, new NullLog());
+
+ Assert.Equal(0, code);
+ Assert.Contains("Usage: gclo accounts", console.Out);
+ }
+
+ [Fact]
+ public void UnknownOption_Throws()
+ => Assert.Throws(() => AccountsCommand.Run(["--nope"], Open, new NullLog()));
+
+ [Fact]
+ public void NoAccounts_WritesHintToStderr_AndReturnsZero()
+ {
+ using var console = new ConsoleCapture();
+
+ int code = AccountsCommand.Run([], Open, new NullLog());
+
+ Assert.Equal(0, code);
+ Assert.Equal("", console.Out); // stdout stays clean
+ Assert.Contains("No accounts yet", console.Error);
+ }
+
+ [Fact]
+ public void Populated_PrintsAlignedColumns_WithNeverAndTimestamp()
+ {
+ Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
+ Seed("work", "acme", @"C:\work", new DateTimeOffset(2026, 7, 4, 15, 30, 0, TimeSpan.Zero));
+ using var console = new ConsoleCapture();
+
+ int code = AccountsCommand.Run([], Open, new NullLog());
+
+ Assert.Equal(0, code);
+ Assert.Contains("kof", console.Out);
+ Assert.Contains("never", console.Out); // kof never synced
+ Assert.Contains("2026-07-04", console.Out); // work's last sync (local date)
+ }
+
+ [Fact]
+ public void Json_PrintsSummaries()
+ {
+ Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
+ using var console = new ConsoleCapture();
+
+ int code = AccountsCommand.Run(["--json"], Open, new NullLog());
+
+ Assert.Equal(0, code);
+ Assert.Contains("\"name\":\"kof\"", console.Out);
+ Assert.Contains("\"organization\":\"KofTwentyTwo\"", console.Out);
+ Assert.Contains("\"lastSync\":null", console.Out);
+ }
+
+ [Fact]
+ public void OpenThrows_Propagates()
+ {
+ Func failing =
+ _ => throw new CliErrorException("Accounts require Windows credential storage; ...");
+
+ Assert.Throws(() => AccountsCommand.Run([], failing, new NullLog()));
+ }
+}
diff --git a/gclo.Cli.Tests/AssemblyInfo.cs b/gclo.Cli.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..cf566e0
--- /dev/null
+++ b/gclo.Cli.Tests/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+// These tests capture the process-global Console (Out/Error/In) to assert CLI
+// output. Running test classes in parallel would let one class's redirect clobber
+// another's, so the whole assembly runs sequentially.
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/gclo.Cli.Tests/CliTestSupport.cs b/gclo.Cli.Tests/CliTestSupport.cs
new file mode 100644
index 0000000..b666f5c
--- /dev/null
+++ b/gclo.Cli.Tests/CliTestSupport.cs
@@ -0,0 +1,117 @@
+using System.Collections.Concurrent;
+using gclo.Engine;
+
+namespace gclo.Cli.Tests;
+
+/// Captures Console.Out and Console.Error for the duration of a block.
+internal sealed class ConsoleCapture : IDisposable
+{
+ private readonly TextWriter _originalOut = Console.Out;
+ private readonly TextWriter _originalError = Console.Error;
+ private readonly StringWriter _out = new();
+ private readonly StringWriter _error = new();
+
+ public ConsoleCapture()
+ {
+ Console.SetOut(_out);
+ Console.SetError(_error);
+ }
+
+ public string Out => _out.ToString();
+ public string Error => _error.ToString();
+
+ public void Dispose()
+ {
+ Console.SetOut(_originalOut);
+ Console.SetError(_originalError);
+ }
+}
+
+/// Redirects Console.In to a fixed string for the duration of a block.
+internal sealed class StdinRedirect : IDisposable
+{
+ private readonly TextReader _original = Console.In;
+
+ public StdinRedirect(string input) => Console.SetIn(new StringReader(input));
+
+ public void Dispose() => Console.SetIn(_original);
+}
+
+/// An organization lister returning a fixed list or throwing a fixed exception.
+internal sealed class FakeOrgLister : IOrganizationLister
+{
+ public IReadOnlyList Result { get; set; } = [];
+ public Exception? Throw { get; set; }
+
+ public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Throw is not null
+ ? Task.FromException>(Throw)
+ : Task.FromResult(Result);
+ }
+}
+
+/// A repository lister returning a fixed list or throwing a fixed exception.
+internal sealed class FakeRepoLister : IRepositoryLister
+{
+ public IReadOnlyList Result { get; set; } = [];
+ public Exception? Throw { get; set; }
+
+ public Task> ListOrganizationRepositoriesAsync(
+ string organization, string token, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Throw is not null
+ ? Task.FromException>(Throw)
+ : Task.FromResult(Result);
+ }
+}
+
+/// A git client configured with delegates; records clone/pull/recovery calls.
+internal sealed class FakeGit : IGitClient
+{
+ private readonly ConcurrentQueue _clones = new();
+ private readonly ConcurrentQueue _recoveries = new();
+
+ public Func IsValid { get; set; } = _ => false;
+ public Func?, CancellationToken, Task> OnClone { get; set; }
+ = (_, _, _, _, _) => Task.CompletedTask;
+ public Func OnPull { get; set; } = (_, _, _) => Task.CompletedTask;
+ public Func OnApplyRecovery { get; set; } = (_, _, _) => Task.CompletedTask;
+
+ public IReadOnlyList ClonedRepoNames => _clones.Select(Path.GetFileName).ToArray()!;
+ public IReadOnlyList RecoveredRepoNames => _recoveries.Select(Path.GetFileName).ToArray()!;
+
+ public bool IsValidRepository(string path) => IsValid(path);
+
+ public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken)
+ {
+ _clones.Enqueue(path);
+ return OnClone(url, path, token, onProgress, cancellationToken);
+ }
+
+ public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
+ => OnPull(path, token, cancellationToken);
+
+ public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken)
+ {
+ _recoveries.Enqueue(path);
+ return OnApplyRecovery(path, recovery, cancellationToken);
+ }
+}
+
+/// Discards every activity-log entry.
+internal sealed class NullLog : IActivityLog
+{
+ public void Info(string message)
+ {
+ }
+
+ public void Error(string message, Exception? exception = null)
+ {
+ }
+
+ public string LogDirectory => "";
+ public string CurrentLogFilePath => "";
+}
diff --git a/gclo.Cli.Tests/OptionReaderTests.cs b/gclo.Cli.Tests/OptionReaderTests.cs
new file mode 100644
index 0000000..bbf16b0
--- /dev/null
+++ b/gclo.Cli.Tests/OptionReaderTests.cs
@@ -0,0 +1,86 @@
+namespace gclo.Cli.Tests;
+
+/// Covers the shared argument reader: both value forms and the flag/value guards.
+public sealed class OptionReaderTests
+{
+ [Fact]
+ public void MoveNext_SplitsInlineValue_AndStripsItFromCurrent()
+ {
+ var reader = new OptionReader(["--org=acme"]);
+
+ Assert.True(reader.MoveNext());
+ Assert.Equal("--org", reader.Current);
+ Assert.Equal("acme", reader.RequireValue());
+ }
+
+ [Fact]
+ public void MoveNext_PlainToken_KeepsItWhole()
+ {
+ var reader = new OptionReader(["sync"]);
+
+ Assert.True(reader.MoveNext());
+ Assert.Equal("sync", reader.Current);
+ }
+
+ [Fact]
+ public void MoveNext_PastEnd_ReturnsFalse()
+ {
+ var reader = new OptionReader([]);
+
+ Assert.False(reader.MoveNext());
+ }
+
+ [Fact]
+ public void RequireValue_TakesTheFollowingArgument()
+ {
+ var reader = new OptionReader(["--org", "acme"]);
+ reader.MoveNext();
+
+ Assert.Equal("acme", reader.RequireValue());
+ }
+
+ [Fact]
+ public void RequireValue_EmptyInlineValue_Throws()
+ {
+ var reader = new OptionReader(["--org="]);
+ reader.MoveNext();
+
+ Assert.Throws(() => reader.RequireValue());
+ }
+
+ [Fact]
+ public void RequireValue_AtEnd_Throws()
+ {
+ var reader = new OptionReader(["--org"]);
+ reader.MoveNext();
+
+ Assert.Throws(() => reader.RequireValue());
+ }
+
+ [Fact]
+ public void RequireValue_FollowedByAnotherOption_Throws()
+ {
+ var reader = new OptionReader(["--org", "--target"]);
+ reader.MoveNext();
+
+ Assert.Throws(() => reader.RequireValue());
+ }
+
+ [Fact]
+ public void RejectValue_InlineValueOnAFlag_Throws()
+ {
+ var reader = new OptionReader(["--json=true"]);
+ reader.MoveNext();
+
+ Assert.Throws(() => reader.RejectValue());
+ }
+
+ [Fact]
+ public void RejectValue_NoInlineValue_IsFine()
+ {
+ var reader = new OptionReader(["--json"]);
+ reader.MoveNext();
+
+ reader.RejectValue(); // must not throw
+ }
+}
diff --git a/gclo.Cli.Tests/OrgsCommandTests.cs b/gclo.Cli.Tests/OrgsCommandTests.cs
new file mode 100644
index 0000000..3b6101c
--- /dev/null
+++ b/gclo.Cli.Tests/OrgsCommandTests.cs
@@ -0,0 +1,102 @@
+namespace gclo.Cli.Tests;
+
+/// Covers 'gclo orgs': parsing, plain and JSON output, and error translation.
+public sealed class OrgsCommandTests
+{
+ private const string EnvVar = "GCLO_ORGS_TEST_TOKEN";
+
+ private static Task Run(FakeOrgLister lister, params string[] args)
+ => OrgsCommand.RunAsync(args, lister, new NullLog(), CancellationToken.None);
+
+ private static IDisposable Token(string value)
+ {
+ Environment.SetEnvironmentVariable(EnvVar, value);
+ return new TokenScope();
+ }
+
+ private sealed class TokenScope : IDisposable
+ {
+ public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
+ }
+
+ [Fact]
+ public async Task Help_PrintsUsage_AndReturnsZero()
+ {
+ using var console = new ConsoleCapture();
+
+ int code = await Run(new FakeOrgLister(), "--help");
+
+ Assert.Equal(0, code);
+ Assert.Contains("Usage: gclo orgs", console.Out);
+ }
+
+ [Fact]
+ public async Task Plain_ListsOneLoginPerLine()
+ {
+ using var console = new ConsoleCapture();
+ using var _ = Token("ghp_x");
+ var lister = new FakeOrgLister { Result = ["me", "acme"] };
+
+ int code = await Run(lister, "--token-env", EnvVar);
+
+ Assert.Equal(0, code);
+ string[] lines = console.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(["me", "acme"], lines);
+ }
+
+ [Fact]
+ public async Task Json_PrintsAnArray()
+ {
+ using var console = new ConsoleCapture();
+ using var _ = Token("ghp_x");
+ var lister = new FakeOrgLister { Result = ["me", "acme"] };
+
+ int code = await Run(lister, "--json", "--token-env", EnvVar);
+
+ Assert.Equal(0, code);
+ Assert.Contains("[\"me\",\"acme\"]", console.Out);
+ }
+
+ [Fact]
+ public async Task UnknownOption_Throws()
+ {
+ await Assert.ThrowsAsync(() => Run(new FakeOrgLister(), "--nope"));
+ }
+
+ [Fact]
+ public async Task MissingToken_Throws()
+ {
+ string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
+ Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
+ try
+ {
+ await Assert.ThrowsAsync(() => Run(new FakeOrgLister()));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
+ }
+ }
+
+ [Fact]
+ public async Task ListerInvalidOperation_BecomesCliError()
+ {
+ using var _ = Token("ghp_x");
+ var lister = new FakeOrgLister { Throw = new InvalidOperationException("bad token (401)") };
+
+ var ex = await Assert.ThrowsAsync(() => Run(lister, "--token-env", EnvVar));
+ Assert.Contains("401", ex.Message);
+ }
+
+ [Fact]
+ public async Task Canceled_Propagates()
+ {
+ using var _ = Token("ghp_x");
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var lister = new FakeOrgLister { Result = ["me"] };
+
+ await Assert.ThrowsAnyAsync(
+ () => OrgsCommand.RunAsync(["--token-env", EnvVar], lister, new NullLog(), cts.Token));
+ }
+}
diff --git a/gclo.Cli.Tests/ProgressPrinterTests.cs b/gclo.Cli.Tests/ProgressPrinterTests.cs
new file mode 100644
index 0000000..c03073e
--- /dev/null
+++ b/gclo.Cli.Tests/ProgressPrinterTests.cs
@@ -0,0 +1,58 @@
+using gclo.Engine;
+
+namespace gclo.Cli.Tests;
+
+/// Covers the console progress printer: dedup, stdout/stderr routing, and gating.
+public sealed class ProgressPrinterTests
+{
+ [Fact]
+ public void Report_PrintsTransitionsToStdout_AndSuppressesRepeats()
+ {
+ using var console = new ConsoleCapture();
+ var printer = new ProgressPrinter(printProgress: true, printFailures: true);
+
+ printer.Report(new RepoProgress("alpha", SyncStatus.Cloning));
+ printer.Report(new RepoProgress("alpha", SyncStatus.Cloning, Percent: 0.5)); // repeat status: suppressed
+ printer.Report(new RepoProgress("alpha", SyncStatus.Done));
+
+ string[] lines = console.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(["alpha Cloning", "alpha Done"], lines);
+ }
+
+ [Fact]
+ public void Report_Failure_GoesToStderr_AndIsCollected()
+ {
+ using var console = new ConsoleCapture();
+ var printer = new ProgressPrinter(printProgress: true, printFailures: true);
+
+ printer.Report(new RepoProgress("bravo", SyncStatus.Failed, "boom"));
+
+ Assert.Contains("bravo Failed boom", console.Error);
+ var failure = Assert.Single(printer.Failures);
+ Assert.Equal("bravo", failure.Repo);
+ Assert.Equal("boom", failure.Error);
+ }
+
+ [Fact]
+ public void Report_Failure_NullError_RecordsUnknown()
+ {
+ using var console = new ConsoleCapture();
+ var printer = new ProgressPrinter(printProgress: false, printFailures: false);
+
+ printer.Report(new RepoProgress("bravo", SyncStatus.Failed));
+
+ Assert.Equal("unknown error", Assert.Single(printer.Failures).Error);
+ Assert.Equal("", console.Error); // printFailures off: nothing on stderr
+ }
+
+ [Fact]
+ public void Report_ProgressOff_PrintsNoTransitions()
+ {
+ using var console = new ConsoleCapture();
+ var printer = new ProgressPrinter(printProgress: false, printFailures: true);
+
+ printer.Report(new RepoProgress("alpha", SyncStatus.Cloning));
+
+ Assert.Equal("", console.Out);
+ }
+}
diff --git a/gclo.Cli.Tests/SyncCommandTests.cs b/gclo.Cli.Tests/SyncCommandTests.cs
new file mode 100644
index 0000000..7591010
--- /dev/null
+++ b/gclo.Cli.Tests/SyncCommandTests.cs
@@ -0,0 +1,398 @@
+using gclo.Engine;
+using gclo.ViewModels;
+
+namespace gclo.Cli.Tests;
+
+///
+/// Covers 'gclo sync' end to end over fake collaborators: argument and account
+/// resolution, token resolution, the sync run and its exit codes, JSON/quiet
+/// output, path sanitization, and account result recording.
+///
+public sealed class SyncCommandTests : IDisposable
+{
+ private const string EnvVar = "GCLO_SYNC_TEST_TOKEN";
+
+ private readonly string _dir =
+ Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
+ private readonly InMemoryVault _vault = new();
+ private AccountsStore? _store;
+
+ public SyncCommandTests()
+ {
+ Environment.SetEnvironmentVariable(EnvVar, "ghp_test");
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable(EnvVar, null);
+ try
+ {
+ Directory.Delete(_dir, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+
+ private string Target => Path.Combine(_dir, "target");
+ private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
+ private Func Open => _ => (Store, _vault);
+
+ private static RepoDescriptor Repo(string name) => new(name, $"https://x/{name}.git", "main", false);
+
+ private Task Run(FakeRepoLister lister, FakeGit git, params string[] args)
+ => SyncCommand.RunAsync(args, lister, git, Open, new NullLog(), CancellationToken.None);
+
+ private Account Seed(string name, string org, bool orgSubfolder = false, int parallel = 8)
+ {
+ Store.Save(
+ new Account
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ Organization = org,
+ TargetRoot = Target,
+ CreateOrgSubfolder = orgSubfolder,
+ MaxConcurrency = parallel,
+ },
+ "tok-" + name);
+ return Store.FindByName(name)!;
+ }
+
+ // ---------------------------------------------------------------- parsing
+
+ [Fact]
+ public async Task Help_PrintsUsage()
+ {
+ using var console = new ConsoleCapture();
+
+ int code = await Run(new FakeRepoLister(), new FakeGit(), "--help");
+
+ Assert.Equal(0, code);
+ Assert.Contains("Usage: gclo sync", console.Out);
+ }
+
+ [Fact]
+ public async Task MissingOrg_Throws()
+ => await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--target", Target));
+
+ [Fact]
+ public async Task MissingTarget_Throws()
+ => await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme"));
+
+ [Theory]
+ [InlineData("zero")]
+ [InlineData("0")]
+ [InlineData("-3")]
+ public async Task BadParallel_Throws(string value)
+ => await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value));
+
+ [Fact]
+ public async Task UnknownOption_Throws()
+ => await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--nope"));
+
+ // ---------------------------------------------------------------- sync runs
+
+ [Fact]
+ public async Task AllRepositoriesClone_ReturnsZero_AndPrintsSummary()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] };
+ var git = new FakeGit(); // clone succeeds by default
+
+ int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
+
+ Assert.Equal(0, code);
+ Assert.Equal(["a", "b"], git.ClonedRepoNames.OrderBy(n => n));
+ Assert.Contains("Finished:", console.Out);
+ Assert.Contains("2 cloned", console.Out);
+ }
+
+ [Fact]
+ public async Task SomeRepositoriesFail_ReturnsOne()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] };
+ var git = new FakeGit
+ {
+ OnClone = (_, path, _, _, _) => Path.GetFileName(path) == "b"
+ ? Task.FromException(new InvalidOperationException("boom"))
+ : Task.CompletedTask,
+ };
+
+ int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
+
+ Assert.Equal(1, code);
+ Assert.Contains("1 failed", console.Out);
+ Assert.Contains("b Failed boom", console.Error);
+ }
+
+ [Fact]
+ public async Task Quiet_SuppressesTransitionsButKeepsSummaryAndFailures()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--quiet");
+
+ Assert.Equal(0, code);
+ Assert.DoesNotContain("a Cloning", console.Out);
+ Assert.Contains("Finished:", console.Out);
+ }
+
+ [Fact]
+ public async Task Json_PrintsMachineReadableSummary()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json");
+
+ Assert.Equal(0, code);
+ Assert.Contains("\"total\":1", console.Out);
+ Assert.Contains("\"cloned\":1", console.Out);
+ Assert.Contains("\"failures\":[]", console.Out);
+ }
+
+ [Fact]
+ public async Task ValidParallel_IsAccepted()
+ {
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--parallel", "4");
+
+ Assert.Equal(0, code);
+ }
+
+ [Fact]
+ public async Task TargetFolderUnusable_BecomesCliError()
+ {
+ // A file sits where the target directory should be, so the engine's
+ // Directory.CreateDirectory throws — surfaced as a target-folder CliError.
+ string target = Path.Combine(_dir, "target-as-file");
+ File.WriteAllText(target, "not a directory");
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ var ex = await Assert.ThrowsAsync(
+ () => Run(lister, new FakeGit(), "--org", "acme", "--target", target, "--token-env", EnvVar));
+ Assert.Contains("Cannot use target folder", ex.Message);
+ }
+
+ [Fact]
+ public async Task Account_RecordResultFails_WarnsButStillSucceeds()
+ {
+ using var console = new ConsoleCapture();
+ Seed("work", "acme");
+ // Block the store's atomic write: a directory where its temp file must go.
+ Directory.CreateDirectory(Path.Combine(_dir, "accounts.json.tmp"));
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, new FakeGit(), "--account", "work");
+
+ Assert.Equal(0, code); // the repos are on disk; a bookkeeping failure must not change that
+ Assert.Contains("could not record the sync result", console.Error);
+ }
+
+ [Fact]
+ public async Task SanitizePaths_CanceledBeforeRecovery_LeavesRepoFailed()
+ {
+ using var console = new ConsoleCapture();
+ using var cts = new CancellationTokenSource();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+ var git = new FakeGit
+ {
+ OnClone = (_, _, _, _, _) =>
+ {
+ cts.Cancel(); // cancellation lands during the run, before sanitization
+ return Task.FromException(InvalidPaths());
+ },
+ };
+
+ int code = await SyncCommand.RunAsync(
+ ["--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"],
+ lister, git, Open, new NullLog(), cts.Token);
+
+ Assert.Equal(1, code);
+ Assert.Empty(git.RecoveredRepoNames); // recovery was never attempted
+ }
+
+ [Fact]
+ public async Task ListerInvalidOperation_BecomesCliError()
+ {
+ var lister = new FakeRepoLister { Throw = new InvalidOperationException("org not found (404)") };
+
+ var ex = await Assert.ThrowsAsync(
+ () => Run(lister, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar));
+ Assert.Contains("404", ex.Message);
+ }
+
+ [Fact]
+ public async Task Canceled_ReturnsOne()
+ {
+ var lister = new FakeRepoLister { Throw = new OperationCanceledException() };
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ int code = await SyncCommand.RunAsync(
+ ["--org", "acme", "--target", Target, "--token-env", EnvVar],
+ lister, new FakeGit(), Open, new NullLog(), cts.Token);
+
+ Assert.Equal(1, code);
+ }
+
+ // ---------------------------------------------------------------- accounts
+
+ [Fact]
+ public async Task Account_SeedsOrgTargetAndParallel()
+ {
+ using var console = new ConsoleCapture();
+ Seed("work", "acme", orgSubfolder: false, parallel: 3);
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, new FakeGit(), "--account", "work");
+
+ Assert.Equal(0, code);
+ Assert.Contains("Finished:", console.Out);
+ // The account's stored token was used (no --token option given).
+ Assert.NotNull(Store.FindByName("work")!.LastSyncSummary);
+ }
+
+ [Fact]
+ public async Task Account_WithOrgSubfolder_NestsTheTarget()
+ {
+ Seed("work", "acme", orgSubfolder: true);
+ var git = new FakeGit();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, git, "--account", "work");
+
+ Assert.Equal(0, code);
+ // Target became \acme\a.
+ Assert.Contains("a", git.ClonedRepoNames);
+ Assert.True(Directory.Exists(Path.Combine(Target, "acme")));
+ }
+
+ [Fact]
+ public async Task UnknownAccount_NoAccounts_Throws()
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost"));
+ Assert.Contains("No accounts exist yet", ex.Message);
+ }
+
+ [Fact]
+ public async Task UnknownAccount_WithOtherAccounts_ListsThem()
+ {
+ Seed("work", "acme");
+
+ var ex = await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost"));
+ Assert.Contains("Available accounts: work", ex.Message);
+ }
+
+ [Fact]
+ public async Task Account_MissingVaultToken_Throws()
+ {
+ // Save metadata, then delete the vault entry so no token is retrievable.
+ Account account = Seed("work", "acme");
+ _vault.Delete(account.Id);
+
+ var ex = await Assert.ThrowsAsync(
+ () => Run(new FakeRepoLister(), new FakeGit(), "--account", "work"));
+ Assert.Contains("no token", ex.Message);
+ }
+
+ [Fact]
+ public async Task Account_ExplicitTokenOption_OverridesVault()
+ {
+ Account account = Seed("work", "acme");
+ _vault.Delete(account.Id); // vault empty, but --token-env supplies one
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+
+ int code = await Run(lister, new FakeGit(), "--account", "work", "--token-env", EnvVar);
+
+ Assert.Equal(0, code); // did not fall through to the missing-vault-token error
+ }
+
+ // ---------------------------------------------------------------- sanitize-paths
+
+ private static InvalidRepositoryPathsException InvalidPaths() => new(
+ [
+ new InvalidPathInfo("bad:name.txt", "contains a character that is invalid on Windows", "bad_name.txt"),
+ new InvalidPathInfo("dup", "differs only by case from 'DUP'", null), // no suggestion => skipped
+ ]);
+
+ [Fact]
+ public async Task SanitizePaths_RecoversAnInvalidPathRepo_AndCountsItCloned()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+ var git = new FakeGit
+ {
+ OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()),
+ OnApplyRecovery = (_, _, _) => Task.CompletedTask, // recovery succeeds
+ };
+
+ int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths");
+
+ Assert.Equal(0, code);
+ Assert.Contains("a", git.RecoveredRepoNames);
+ Assert.Contains("with sanitized paths", console.Out);
+ Assert.Contains("Sanitized", console.Error);
+ }
+
+ [Fact]
+ public async Task SanitizePaths_RecoveryFails_LeavesRepoFailed()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+ var git = new FakeGit
+ {
+ OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()),
+ OnApplyRecovery = (_, _, _) => Task.FromException(new IOException("disk full")),
+ };
+
+ int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths");
+
+ Assert.Equal(1, code);
+ Assert.Contains("path sanitization failed", console.Error);
+ }
+
+ [Fact]
+ public async Task InvalidPathFailure_WithoutSanitize_PrintsOffendingPaths()
+ {
+ using var console = new ConsoleCapture();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+ var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()) };
+
+ int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
+
+ Assert.Equal(1, code);
+ Assert.Contains("bad:name.txt", console.Error);
+ Assert.Contains("invalid on Windows", console.Error);
+ }
+
+ [Fact]
+ public async Task InvalidPathFailure_ManyPaths_TruncatesTheListing()
+ {
+ using var console = new ConsoleCapture();
+ var many = Enumerable.Range(0, 15)
+ .Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt"))
+ .ToList();
+ var lister = new FakeRepoLister { Result = [Repo("a")] };
+ var git = new FakeGit
+ {
+ OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)),
+ };
+
+ int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar);
+
+ Assert.Equal(1, code);
+ Assert.Contains("and 5 more invalid paths", console.Error);
+ }
+}
diff --git a/gclo.Cli.Tests/TokenOptionsTests.cs b/gclo.Cli.Tests/TokenOptionsTests.cs
new file mode 100644
index 0000000..4e4b8ce
--- /dev/null
+++ b/gclo.Cli.Tests/TokenOptionsTests.cs
@@ -0,0 +1,151 @@
+namespace gclo.Cli.Tests;
+
+///
+/// Covers token-source parsing and resolution: environment, file, and redirected
+/// stdin, plus the multi-source and missing-token errors. The interactive no-echo
+/// read is excluded from coverage (it needs a real terminal).
+///
+public sealed class TokenOptionsTests
+{
+ private static TokenOptions Parse(params string[] args)
+ {
+ var options = new TokenOptions();
+ var reader = new OptionReader(args);
+ while (reader.MoveNext())
+ {
+ Assert.True(options.TryConsume(reader), $"expected a token option, got '{reader.Current}'");
+ }
+ return options;
+ }
+
+ [Fact]
+ public void TryConsume_NonTokenOption_ReturnsFalse()
+ {
+ var reader = new OptionReader(["--json"]);
+ reader.MoveNext();
+
+ Assert.False(new TokenOptions().TryConsume(reader));
+ }
+
+ [Fact]
+ public void HasExplicitSource_TrueOnlyAfterATokenOption()
+ {
+ Assert.False(new TokenOptions().HasExplicitSource);
+ Assert.True(Parse("--token-env", "X").HasExplicitSource);
+ Assert.True(Parse("--token-stdin").HasExplicitSource);
+ }
+
+ [Fact]
+ public void Resolve_MoreThanOneSource_Throws()
+ {
+ var options = Parse("--token-env", "A", "--token-file", "b.txt");
+
+ Assert.Throws(() => options.Resolve());
+ }
+
+ [Fact]
+ public void Resolve_ExplicitEnvVar_ReturnsTrimmedValue()
+ {
+ const string var = "GCLO_TEST_TOKEN_ENV";
+ Environment.SetEnvironmentVariable(var, " ghp_from_env ");
+ try
+ {
+ Assert.Equal("ghp_from_env", Parse("--token-env", var).Resolve());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(var, null);
+ }
+ }
+
+ [Fact]
+ public void Resolve_ExplicitEnvVar_Missing_Throws()
+ {
+ var ex = Assert.Throws(
+ () => Parse("--token-env", "GCLO_DEFINITELY_UNSET_VAR").Resolve());
+ Assert.Contains("not set", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_DefaultEnvVar_WhenUnset_ThrowsWithGuidance()
+ {
+ string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
+ Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
+ try
+ {
+ var ex = Assert.Throws(() => new TokenOptions().Resolve());
+ Assert.Contains("--token-env", ex.Message);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
+ }
+ }
+
+ [Fact]
+ public void Resolve_File_ReturnsFirstNonBlankLine()
+ {
+ string file = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(file, "\n \nghp_from_file\nsecond\n");
+ Assert.Equal("ghp_from_file", Parse("--token-file", file).Resolve());
+ }
+ finally
+ {
+ File.Delete(file);
+ }
+ }
+
+ [Fact]
+ public void Resolve_File_AllBlank_Throws()
+ {
+ string file = Path.GetTempFileName();
+ try
+ {
+ File.WriteAllText(file, "\n \n\t\n");
+ var ex = Assert.Throws(() => Parse("--token-file", file).Resolve());
+ Assert.Contains("empty", ex.Message);
+ }
+ finally
+ {
+ File.Delete(file);
+ }
+ }
+
+ [Fact]
+ public void Resolve_File_Unreadable_Throws()
+ {
+ string missing = Path.Combine(Path.GetTempPath(), "gclo-no-such-" + Guid.NewGuid().ToString("N") + ".txt");
+
+ var ex = Assert.Throws(() => Parse("--token-file", missing).Resolve());
+ Assert.Contains("Cannot read token file", ex.Message);
+ }
+
+ [Fact]
+ public void Resolve_Stdin_ReturnsTheLine()
+ {
+ // The test host's stdin is redirected, so Resolve reads Console.In.
+ using var _ = new StdinRedirect(" ghp_from_stdin \n");
+
+ Assert.Equal("ghp_from_stdin", Parse("--token-stdin").Resolve());
+ }
+
+ [Fact]
+ public void Resolve_Stdin_Empty_Throws()
+ {
+ using var _ = new StdinRedirect("");
+
+ var ex = Assert.Throws(() => Parse("--token-stdin").Resolve());
+ Assert.Contains("standard input", ex.Message);
+ }
+
+ [Fact]
+ public void TryConsume_StdinWithInlineValue_Throws()
+ {
+ var reader = new OptionReader(["--token-stdin=oops"]);
+ reader.MoveNext();
+
+ Assert.Throws(() => new TokenOptions().TryConsume(reader));
+ }
+}
diff --git a/gclo.Cli.Tests/gclo.Cli.Tests.csproj b/gclo.Cli.Tests/gclo.Cli.Tests.csproj
new file mode 100644
index 0000000..62e69ff
--- /dev/null
+++ b/gclo.Cli.Tests/gclo.Cli.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gclo.Cli/AccountsCommand.cs b/gclo.Cli/AccountsCommand.cs
index a8b1a30..39cf6cb 100644
--- a/gclo.Cli/AccountsCommand.cs
+++ b/gclo.Cli/AccountsCommand.cs
@@ -33,7 +33,12 @@ internal static class AccountsCommand
2 fatal: bad arguments, or not running on Windows
""";
- public static int Run(string[] args)
+ /// Composition root: wires the real Credential Manager store and file log.
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Wires the real Credential Manager and file log; delegates to the covered core.")]
+ public static int Run(string[] args) => Run(args, Open, new FileActivityLog());
+
+ internal static int Run(string[] args, Func open, IActivityLog log)
{
bool json = false;
@@ -54,13 +59,11 @@ internal static class AccountsCommand
}
}
- // One activity log per invocation; tokens are never read here, let alone logged.
- var log = new FileActivityLog();
log.Info($"accounts started: json={json}");
try
{
- (AccountsStore store, _) = Open(log);
+ (AccountsStore store, _) = open(log);
IReadOnlyList accounts = store.GetAll();
log.Info($"accounts finished: {accounts.Count} account(s) listed.");
@@ -109,6 +112,8 @@ internal static class AccountsCommand
/// 'gclo sync' and 'gclo orgs' never touch the credential store.
///
/// The current OS is not Windows.
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Wires the real Credential Manager vault; its non-Windows guard mirrors the vault's own tested guard.")]
internal static (AccountsStore Store, ITokenVault Vault) Open(IActivityLog log)
{
if (!OperatingSystem.IsWindows())
diff --git a/gclo.Cli/OrgsCommand.cs b/gclo.Cli/OrgsCommand.cs
index da1d6fb..075a905 100644
--- a/gclo.Cli/OrgsCommand.cs
+++ b/gclo.Cli/OrgsCommand.cs
@@ -33,7 +33,14 @@ internal static class OrgsCommand
argument would leak. Use --token-env, --token-file, or --token-stdin.
""";
- public static async Task RunAsync(string[] args, CancellationToken cancellationToken)
+ /// Composition root: wires the real GitHub lister and file log.
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Wires the real network lister and file log; delegates to the covered core.")]
+ public static Task RunAsync(string[] args, CancellationToken cancellationToken)
+ => RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(), cancellationToken);
+
+ internal static async Task RunAsync(
+ string[] args, IOrganizationLister lister, IActivityLog log, CancellationToken cancellationToken)
{
bool json = false;
var tokenOptions = new TokenOptions();
@@ -59,8 +66,6 @@ internal static class OrgsCommand
}
}
- // One activity log per invocation; the token itself is never written to it.
- var log = new FileActivityLog();
log.Info($"orgs started: json={json}");
try
@@ -70,7 +75,7 @@ internal static class OrgsCommand
IReadOnlyList logins;
try
{
- logins = await new GitHubOrganizationLister()
+ logins = await lister
.ListOrganizationsAsync(token, cancellationToken)
.ConfigureAwait(false);
}
diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs
index 33410c1..97cfbbd 100644
--- a/gclo.Cli/Program.cs
+++ b/gclo.Cli/Program.cs
@@ -1,8 +1,16 @@
+using System.Diagnostics.CodeAnalysis;
using gclo.Engine;
namespace gclo.Cli;
-/// Entry point: command dispatch, Ctrl+C wiring, and top-level error handling.
+///
+/// Entry point: command dispatch, Ctrl+C wiring, and top-level error handling.
+/// Excluded from coverage as the composition root — it wires the real network/native
+/// collaborators (GitHub listers, LibGit2 client, Credential Manager) and the process
+/// Console/Ctrl+C, none reproducible offline. The command logic it dispatches to lives
+/// in the injectable internal cores, which are covered to 100%.
+///
+[ExcludeFromCodeCoverage(Justification = "Composition root: wires real collaborators and the process Console/Ctrl+C.")]
internal static class Program
{
private const string RootHelp = """
diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs
index db0e1e4..e77ab54 100644
--- a/gclo.Cli/SyncCommand.cs
+++ b/gclo.Cli/SyncCommand.cs
@@ -75,7 +75,22 @@ internal static class SyncCommand
argument would leak. Use --token-env, --token-file, or --token-stdin.
""";
- public static async Task RunAsync(string[] args, CancellationToken cancellationToken)
+ /// Composition root: wires the real GitHub lister, LibGit2 client, and file log.
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Wires the real network/native collaborators; delegates to the covered core.")]
+ public static Task RunAsync(string[] args, CancellationToken cancellationToken)
+ => RunAsync(
+ args,
+ new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(),
+ cancellationToken);
+
+ internal static async Task RunAsync(
+ string[] args,
+ IRepositoryLister lister,
+ IGitClient git,
+ Func openAccounts,
+ IActivityLog log,
+ CancellationToken cancellationToken)
{
string? org = null;
string? target = null;
@@ -134,10 +149,6 @@ internal static class SyncCommand
}
}
- // One activity log per invocation. Only non-secret parameters are ever
- // written to it; the token is not.
- var log = new FileActivityLog();
-
try
{
// --account: seed org/target/parallel from the saved account. Explicit
@@ -147,7 +158,7 @@ internal static class SyncCommand
ITokenVault? vault = null;
if (accountName is not null)
{
- (store, vault) = AccountsCommand.Open(log);
+ (store, vault) = openAccounts(log);
account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store);
org ??= account.Organization;
target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot;
@@ -171,8 +182,7 @@ internal static class SyncCommand
string token = ResolveToken(tokenOptions, account, vault);
var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json);
- var git = new LibGit2GitClient();
- var engine = new OrgSyncEngine(new GitHubRepositoryLister(), git);
+ var engine = new OrgSyncEngine(lister, git);
var request = new SyncRequest(org.Trim(), token, target.Trim(), effectiveParallel);
var progress = new SyncProgressHandler(printer, log, printDetails: !json, collectForSanitize: sanitizePaths);
diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs
index a970993..50a930b 100644
--- a/gclo.Cli/TokenOptions.cs
+++ b/gclo.Cli/TokenOptions.cs
@@ -105,20 +105,9 @@ internal sealed class TokenOptions
private static string FromStdin()
{
- string? line;
- if (Console.IsInputRedirected)
- {
- line = Console.In.ReadLine()?.Trim();
- }
- else
- {
- // Interactive use: prompt on stderr (redirected stdout stays clean) and
- // read WITHOUT echo, like git/gh credential prompts — an echoed token
- // would sit on screen and in the terminal scrollback.
- Console.Error.Write("Token: ");
- line = ReadLineNoEcho().Trim();
- Console.Error.WriteLine();
- }
+ string? line = Console.IsInputRedirected
+ ? Console.In.ReadLine()?.Trim()
+ : ReadInteractiveToken();
if (string.IsNullOrEmpty(line))
{
@@ -127,6 +116,21 @@ internal sealed class TokenOptions
return line;
}
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Interactive prompt + no-echo read; reachable only from a real terminal, not an offline test.")]
+ private static string ReadInteractiveToken()
+ {
+ // Interactive use: prompt on stderr (redirected stdout stays clean) and read
+ // WITHOUT echo, like git/gh credential prompts — an echoed token would sit on
+ // screen and in the terminal scrollback.
+ Console.Error.Write("Token: ");
+ string line = ReadLineNoEcho().Trim();
+ Console.Error.WriteLine();
+ return line;
+ }
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Interactive no-echo console read; keystrokes cannot be simulated in an offline test.")]
private static string ReadLineNoEcho()
{
var buffer = new System.Text.StringBuilder();
diff --git a/gclo.Cli/gclo.Cli.csproj b/gclo.Cli/gclo.Cli.csproj
index 915edbc..e4452c3 100644
--- a/gclo.Cli/gclo.Cli.csproj
+++ b/gclo.Cli/gclo.Cli.csproj
@@ -15,4 +15,9 @@
+
+
+
+
+
diff --git a/gclo.Engine.Tests/AppSettingsTests.cs b/gclo.Engine.Tests/AppSettingsTests.cs
index b755d1c..8c30ff6 100644
--- a/gclo.Engine.Tests/AppSettingsTests.cs
+++ b/gclo.Engine.Tests/AppSettingsTests.cs
@@ -14,6 +14,39 @@ public sealed class AppSettingsTests
Assert.Equal(5_000, settings.SplashMilliseconds);
}
+ [Fact]
+ public void Load_SettingsFileFromOlderVersion_MigratesSplashToDefault()
+ {
+ // A pre-splash settings.json has no SplashMilliseconds field; it must load
+ // as the 5s default, not as 0 clamped to the minimum.
+ string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
+ try
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(
+ Path.Combine(dir, "settings.json"),
+ """{ "DefaultTargetFolder": "C:\\src", "DefaultMaxConcurrency": 8, "Theme": "Dark" }""");
+
+ var loaded = AppSettings.Load();
+
+ Assert.Equal(AppSettings.DefaultSplashMilliseconds, loaded.SplashMilliseconds);
+ Assert.True(loaded.ShowSplashScreen);
+ Assert.Equal("Dark", loaded.Theme); // existing values still honored
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
[Fact]
public void Save_ClampsSplashDuration()
{
diff --git a/gclo.Engine.Tests/CoverageResidueTests.cs b/gclo.Engine.Tests/CoverageResidueTests.cs
new file mode 100644
index 0000000..e14fb9d
--- /dev/null
+++ b/gclo.Engine.Tests/CoverageResidueTests.cs
@@ -0,0 +1,238 @@
+using gclo.ViewModels;
+
+namespace gclo.Engine.Tests;
+
+///
+/// Pins the defensive edges the mainline suites never reach: validator collision
+/// branches, settings failure fallbacks, store corruption handling, and the
+/// remaining status-text arms. Part of the 100%-coverage contract.
+///
+public sealed class CoverageResidueTests
+{
+ // ------------------------------------------------- WindowsPathValidator
+
+ [Fact]
+ public void ValidatePaths_LeafCollidingWithDirectory_IsInvalid()
+ {
+ var invalid = WindowsPathValidator.ValidatePaths(["a/b", "a"]);
+
+ Assert.Contains(invalid, i => i.RepoPath == "a" && i.Reason.Contains("file and a directory"));
+ }
+
+ [Fact]
+ public void ValidatePaths_DirectoryCollidingWithFile_IsInvalid()
+ {
+ var invalid = WindowsPathValidator.ValidatePaths(["a", "a/b"]);
+
+ Assert.Contains(invalid, i => i.RepoPath == "a" && i.Reason.Contains("file and a directory"));
+ }
+
+ [Fact]
+ public void ValidatePaths_EmptySegment_IsInvalidWithSuggestion()
+ {
+ var invalid = WindowsPathValidator.ValidatePaths(["a//b"]);
+
+ Assert.Contains(invalid, i => i.Reason == "empty path segment" && i.SuggestedName == "_");
+ }
+
+ // ------------------------------------------------- AppSettings failure paths
+
+ private static (string Dir, IDisposable Scope) SettingsDir()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
+ return (dir, new EnvScope(dir));
+ }
+
+ private sealed class EnvScope(string dir) : IDisposable
+ {
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ [Fact]
+ public void Load_CorruptSettingsFile_FallsBackToDefaults()
+ {
+ (string dir, IDisposable scope) = SettingsDir();
+ using (scope)
+ {
+ File.WriteAllText(Path.Combine(dir, "settings.json"), "{ not json");
+
+ var settings = AppSettings.Load();
+
+ Assert.Equal("System", settings.Theme);
+ Assert.Equal(AppSettings.DefaultConcurrency, settings.DefaultMaxConcurrency);
+ }
+ }
+
+ [Fact]
+ public void Load_FileDeserializingToNull_FallsBackToDefaults()
+ {
+ (string dir, IDisposable scope) = SettingsDir();
+ using (scope)
+ {
+ File.WriteAllText(Path.Combine(dir, "settings.json"), "null");
+
+ Assert.Equal("System", AppSettings.Load().Theme);
+ }
+ }
+
+ [Fact]
+ public void Load_InvalidTheme_SanitizesToSystem()
+ {
+ (string dir, IDisposable scope) = SettingsDir();
+ using (scope)
+ {
+ File.WriteAllText(
+ Path.Combine(dir, "settings.json"), """{ "Theme": "Hotdog" }""");
+
+ Assert.Equal("System", AppSettings.Load().Theme);
+ }
+ }
+
+ [Fact]
+ public void Save_UnwritableFile_IsSwallowed()
+ {
+ (string dir, IDisposable scope) = SettingsDir();
+ using (scope)
+ {
+ string path = Path.Combine(dir, "settings.json");
+ using var exclusiveLock = new FileStream(
+ path, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
+
+ new AppSettings().Save(); // must not throw
+ }
+ }
+
+ [Fact]
+ public void DefaultTokenVaultId_IsStable()
+ => Assert.Equal(new Guid("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"), AppSettings.DefaultTokenVaultId);
+
+ // ------------------------------------------------- AccountsStore edges
+
+ private static Account MakeAccount(string name = "acc") => new()
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ Organization = "org",
+ TargetRoot = @"C:\x",
+ };
+
+ [Fact]
+ public void RecordSyncResult_UnknownAccount_LogsAndLeavesTheListAlone()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ var log = new RecordingLog();
+ var store = new AccountsStore(new InMemoryVault(), dir, log);
+ store.Save(MakeAccount(), "tok");
+
+ store.RecordSyncResult(Guid.NewGuid(), DateTimeOffset.UtcNow, "summary");
+
+ Assert.Contains(log.Errors, e => e.Contains("no account with id"));
+ Assert.Null(store.GetAll()[0].LastSyncSummary);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Load_FileDeserializingToNull_PreservesItAsCorrupt()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "accounts.json"), "null");
+
+ var store = new AccountsStore(new InMemoryVault(), dir, new RecordingLog());
+
+ Assert.Empty(store.GetAll());
+ Assert.Single(Directory.GetFiles(dir, "accounts.json.corrupt-*"));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Load_CorruptFileThatCannotBeMovedAside_LogsBothFailures()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(dir);
+ string path = Path.Combine(dir, "accounts.json");
+ File.WriteAllText(path, "{ not json");
+ var log = new RecordingLog();
+
+ // No delete/move sharing: PreserveCorruptFile's File.Move must fail too.
+ using (new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
+ {
+ var store = new AccountsStore(new InMemoryVault(), dir, log);
+ Assert.Empty(store.GetAll());
+ }
+
+ Assert.Contains(log.Errors, e => e.Contains("Failed to load accounts"));
+ Assert.Contains(log.Errors, e => e.Contains("Could not preserve"));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ // ------------------------------------------------- RepoItemViewModel texts
+
+ [Theory]
+ [InlineData(SyncStatus.Cloning, null, "Cloning")]
+ [InlineData(SyncStatus.Cloning, 0.5, "Cloning 50%")]
+ [InlineData(SyncStatus.Pulling, null, "Pulling")]
+ [InlineData(SyncStatus.Done, null, "Done")]
+ [InlineData(SyncStatus.Failed, null, "Failed")]
+ [InlineData(SyncStatus.Canceled, null, "Canceled")]
+ public void StatusText_CoversEveryStatusArm(SyncStatus status, double? percent, string expected)
+ {
+ var item = new RepoItemViewModel(GitTestHelpers.Repo("r"))
+ {
+ Status = status,
+ Percent = percent,
+ };
+
+ Assert.Equal(expected, item.StatusText);
+ }
+
+ [Fact]
+ public void StatusText_UnknownStatusValue_FallsBackToToString()
+ {
+ // Defensive default arm: an out-of-range enum value renders via ToString.
+ var item = new RepoItemViewModel(GitTestHelpers.Repo("r")) { Status = (SyncStatus)999 };
+
+ Assert.Equal("999", item.StatusText);
+ }
+
+ private sealed class RecordingLog : IActivityLog
+ {
+ public List Errors { get; } = [];
+ public void Info(string message)
+ {
+ }
+ public void Error(string message, Exception? exception = null) => Errors.Add(message);
+ public string LogDirectory => "";
+ public string CurrentLogFilePath => "";
+ }
+}
diff --git a/gclo.Engine.Tests/GitHubListerTests.cs b/gclo.Engine.Tests/GitHubListerTests.cs
new file mode 100644
index 0000000..e8dec2b
--- /dev/null
+++ b/gclo.Engine.Tests/GitHubListerTests.cs
@@ -0,0 +1,280 @@
+using System.Net;
+using Octokit;
+
+namespace gclo.Engine.Tests;
+
+///
+/// Pins the GitHub listers' pagination, account-fallback, ordering, dedupe, and
+/// exception-translation logic over a fake — the
+/// production gateway is thin Octokit delegation and stays outside the offline suite.
+///
+public sealed class GitHubListerTests
+{
+ private sealed class FakeGateway : IGitHubGateway
+ {
+ public Func>> OrgPages { get; set; } =
+ (_, _) => Task.FromResult>([]);
+ public Func>> OwnPages { get; set; } =
+ _ => Task.FromResult>([]);
+ public Func>> UserPages { get; set; } =
+ (_, _) => Task.FromResult>([]);
+ public Func> CurrentUser { get; set; } = () => Task.FromResult("me");
+ public Func>> OrgLogins { get; set; } =
+ () => Task.FromResult>([]);
+
+ public Task> GetOrganizationRepositoriesPageAsync(string organization, int page)
+ => OrgPages(organization, page);
+ public Task> GetOwnRepositoriesPageAsync(int page) => OwnPages(page);
+ public Task> GetUserRepositoriesPageAsync(string user, int page)
+ => UserPages(user, page);
+ public Task GetCurrentUserLoginAsync() => CurrentUser();
+ public Task> GetOrganizationLoginsAsync() => OrgLogins();
+ }
+
+ private readonly FakeGateway _gateway = new();
+ private string? _tokenSeen;
+
+ private GitHubRepositoryLister RepoLister()
+ => new(token =>
+ {
+ _tokenSeen = token;
+ return _gateway;
+ });
+
+ private GitHubOrganizationLister OrgLister()
+ => new(token =>
+ {
+ _tokenSeen = token;
+ return _gateway;
+ });
+
+ private static GitHubRepo Repo(string name) => new(name, $"https://x/{name}.git", "main", false);
+
+ private static NotFoundException NotFound() => new("nope", HttpStatusCode.NotFound);
+
+ private static AuthorizationException Unauthorized() => new(HttpStatusCode.Unauthorized, null);
+
+ private static ForbiddenException Forbidden() => new(new FakeResponse(HttpStatusCode.Forbidden));
+
+ private static RateLimitExceededException RateLimited()
+ => new(new FakeResponse(HttpStatusCode.Forbidden, new RateLimit(60, 0, 1735689600)));
+
+ /// Octokit's Response type is internal; its IResponse is not.
+ private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null) : IResponse
+ {
+ public object? Body => "";
+ public IReadOnlyDictionary Headers { get; } = new Dictionary();
+ public ApiInfo ApiInfo { get; } = new(
+ new Dictionary(), [], [], "", rateLimit ?? new RateLimit(60, 60, 0));
+ public HttpStatusCode StatusCode => status;
+ public string? ContentType => "application/json";
+ }
+
+ // ---------------------------------------------------------------- repositories
+
+ [Fact]
+ public async Task Repos_PagesUntilAShortBatch_AndSortsByName()
+ {
+ var first = Enumerable.Range(0, IGitHubGateway.PageSize).Select(i => Repo($"z{i:D3}")).ToList();
+ _gateway.OrgPages = (org, page) => Task.FromResult>(
+ page == 1 ? first : [Repo("alpha")]);
+
+ var repos = await RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890");
+
+ Assert.Equal(IGitHubGateway.PageSize + 1, repos.Count);
+ Assert.Equal("alpha", repos[0].Name); // sorted, so the page-2 repo leads
+ Assert.Equal("tok-1234567890", _tokenSeen);
+ }
+
+ [Fact]
+ public async Task Repos_DuplicateNamesAcrossPageBoundary_AreDeduplicated()
+ {
+ _gateway.OrgPages = (_, _) => Task.FromResult>(
+ [Repo("same"), new GitHubRepo("SAME", "https://x/dup.git", "dev", true)]);
+
+ var repos = await RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890");
+
+ Assert.Single(repos); // case-insensitive: the first wins
+ Assert.Equal("same", repos[0].Name);
+ }
+
+ [Fact]
+ public async Task Repos_MapsFieldsIntoDescriptors()
+ {
+ _gateway.OrgPages = (_, _) => Task.FromResult>(
+ [new GitHubRepo("r", "https://x/r.git", null, true)]);
+
+ var repos = await RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890");
+
+ Assert.Equal(new RepoDescriptor("r", "https://x/r.git", null, true), repos[0]);
+ }
+
+ [Fact]
+ public async Task Repos_OrgNotFound_ButItIsTheTokensOwnAccount_ListsOwnedRepos()
+ {
+ _gateway.OrgPages = (_, _) => throw NotFound();
+ _gateway.CurrentUser = () => Task.FromResult("Kof");
+ _gateway.OwnPages = _ => Task.FromResult>([Repo("mine")]);
+
+ var repos = await RepoLister().ListOrganizationRepositoriesAsync("kof", "tok-1234567890");
+
+ Assert.Equal("mine", Assert.Single(repos).Name); // login match is case-insensitive
+ }
+
+ [Fact]
+ public async Task Repos_OrgNotFound_OtherUserAccount_ListsThatUsersRepos()
+ {
+ _gateway.OrgPages = (_, _) => throw NotFound();
+ _gateway.UserPages = (user, _) => Task.FromResult>([Repo(user + "-repo")]);
+
+ var repos = await RepoLister().ListOrganizationRepositoriesAsync("somebody", "tok-1234567890");
+
+ Assert.Equal("somebody-repo", Assert.Single(repos).Name);
+ }
+
+ [Fact]
+ public async Task Repos_NeitherOrgNorUser_ThrowsAUsefulMessage()
+ {
+ _gateway.OrgPages = (_, _) => throw NotFound();
+ _gateway.UserPages = (_, _) => throw NotFound();
+
+ var ex = await Assert.ThrowsAsync(
+ () => RepoLister().ListOrganizationRepositoriesAsync("ghost", "tok-1234567890"));
+
+ Assert.Contains("'ghost'", ex.Message);
+ Assert.Contains("404", ex.Message);
+ }
+
+ [Fact]
+ public async Task Repos_BadToken_TranslatesTo401Guidance()
+ {
+ _gateway.OrgPages = (_, _) => throw Unauthorized();
+
+ var ex = await Assert.ThrowsAsync(
+ () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
+
+ Assert.Contains("401", ex.Message);
+ Assert.Contains("PAT", ex.Message);
+ }
+
+ [Fact]
+ public async Task Repos_RateLimited_TranslatesWithResetTime()
+ {
+ _gateway.OrgPages = (_, _) => throw RateLimited();
+
+ var ex = await Assert.ThrowsAsync(
+ () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
+
+ Assert.Contains("rate limit", ex.Message);
+ }
+
+ [Fact]
+ public async Task Repos_CancellationBetweenPages_Throws()
+ {
+ using var cts = new CancellationTokenSource();
+ var full = Enumerable.Range(0, IGitHubGateway.PageSize).Select(i => Repo($"r{i:D3}")).ToList();
+ _gateway.OrgPages = (_, _) =>
+ {
+ cts.Cancel(); // requested mid-listing; the next page boundary must observe it
+ return Task.FromResult>(full);
+ };
+
+ await Assert.ThrowsAnyAsync(
+ () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token));
+ }
+
+ [Fact]
+ public async Task Repos_AlreadyCanceled_ThrowsBeforeAnyCall()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token));
+ Assert.Null(_tokenSeen); // the gateway was never created
+ }
+
+ [Theory]
+ [InlineData("", "tok")]
+ [InlineData("acme", "")]
+ public async Task Repos_BlankArguments_Throw(string organization, string token)
+ => await Assert.ThrowsAsync(
+ () => RepoLister().ListOrganizationRepositoriesAsync(organization, token));
+
+ // ---------------------------------------------------------------- organizations
+
+ [Fact]
+ public async Task Orgs_OwnLoginFirst_ThenOrgsSorted_OwnLoginNeverDuplicated()
+ {
+ _gateway.CurrentUser = () => Task.FromResult("Kof");
+ _gateway.OrgLogins = () => Task.FromResult>(["zeta", "acme", "KOF"]);
+
+ var orgs = await OrgLister().ListOrganizationsAsync("tok-1234567890");
+
+ Assert.Equal(["Kof", "acme", "zeta"], orgs);
+ }
+
+ [Fact]
+ public async Task Orgs_MissingReadOrgScope_DegradesToJustThePersonalAccount()
+ {
+ _gateway.CurrentUser = () => Task.FromResult("Kof");
+ _gateway.OrgLogins = () => throw Forbidden();
+
+ var orgs = await OrgLister().ListOrganizationsAsync("tok-1234567890");
+
+ Assert.Equal(["Kof"], orgs);
+ }
+
+ [Fact]
+ public async Task Orgs_BadToken_TranslatesTo401Guidance()
+ {
+ _gateway.CurrentUser = () => throw Unauthorized();
+
+ var ex = await Assert.ThrowsAsync(
+ () => OrgLister().ListOrganizationsAsync("tok-1234567890"));
+
+ Assert.Contains("401", ex.Message);
+ }
+
+ [Fact]
+ public async Task Orgs_RateLimited_TranslatesWithResetTime()
+ {
+ _gateway.OrgLogins = () => throw RateLimited();
+
+ var ex = await Assert.ThrowsAsync(
+ () => OrgLister().ListOrganizationsAsync("tok-1234567890"));
+
+ Assert.Contains("rate limit", ex.Message);
+ }
+
+ [Fact]
+ public async Task Orgs_AlreadyCanceled_ThrowsBeforeAnyCall()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => OrgLister().ListOrganizationsAsync("tok-1234567890", cts.Token));
+ Assert.Null(_tokenSeen);
+ }
+
+ [Fact]
+ public async Task Orgs_BlankToken_Throws()
+ => await Assert.ThrowsAsync(() => OrgLister().ListOrganizationsAsync(" "));
+
+ [Fact]
+ public void Listers_NullGatewayFactory_Throw()
+ {
+ Assert.Throws(() => new GitHubRepositoryLister(null!));
+ Assert.Throws(() => new GitHubOrganizationLister(null!));
+ }
+
+ [Fact]
+ public void Listers_ParameterlessConstructor_Constructs()
+ {
+ // The production wiring is exercised (the Octokit factory itself is excluded);
+ // no API call is made, so this stays offline.
+ Assert.NotNull(new GitHubRepositoryLister());
+ Assert.NotNull(new GitHubOrganizationLister());
+ }
+}
diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs
index 62af0db..6e5a6d8 100644
--- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs
+++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs
@@ -261,4 +261,91 @@ public sealed class SyncAllCoordinatorTests : IDisposable
Assert.False(workspace.HasLoadedRepos);
Assert.Empty(git.CloneCalls);
}
+
+ // ---------------------------------------------------------------- queue states
+
+ private List<(Guid Id, SyncAllAccountState State)> RecordStates()
+ {
+ var states = new List<(Guid, SyncAllAccountState)>();
+ _coordinator.AccountStateChanged = (id, state) => states.Add((id, state));
+ return states;
+ }
+
+ [Fact]
+ public async Task RunAsync_AnnouncesQueuedForAll_ThenRunningAndTerminalPerAccount()
+ {
+ var alpha = CreateWorkspace("alpha");
+ var beta = CreateWorkspace("beta");
+ var states = RecordStates();
+
+ await _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
+
+ Assert.Equal(
+ new[]
+ {
+ (alpha.AccountId!.Value, SyncAllAccountState.Queued),
+ (beta.AccountId!.Value, SyncAllAccountState.Queued),
+ (alpha.AccountId!.Value, SyncAllAccountState.Running),
+ (alpha.AccountId!.Value, SyncAllAccountState.Succeeded),
+ (beta.AccountId!.Value, SyncAllAccountState.Running),
+ (beta.AccountId!.Value, SyncAllAccountState.Succeeded),
+ },
+ states);
+ }
+
+ [Fact]
+ public async Task RunAsync_AccountWithFailedRepos_AnnouncesFailed()
+ {
+ var git = new FakeGitClient
+ {
+ CloneHandler = (_, _, _, _, _) =>
+ Task.FromException(new InvalidOperationException("boom")),
+ };
+ var workspace = CreateWorkspace("alpha", git);
+ var states = RecordStates();
+
+ await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
+
+ Assert.Equal(SyncAllAccountState.Failed, states[^1].State);
+ }
+
+ [Fact]
+ public async Task RunAsync_BusyAccount_AnnouncesSkippedWithoutRunning()
+ {
+ var workspace = CreateWorkspace("alpha");
+ workspace.IsResolvingPaths = true; // busy: must be skipped
+ var states = RecordStates();
+
+ await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
+
+ Assert.Equal(
+ new[]
+ {
+ (workspace.AccountId!.Value, SyncAllAccountState.Queued),
+ (workspace.AccountId!.Value, SyncAllAccountState.Skipped),
+ },
+ states);
+ }
+
+ [Fact]
+ public async Task RunAsync_CanceledQueue_AnnouncesSkippedForUnstartedAccounts()
+ {
+ var alpha = CreateWorkspace("alpha");
+ var beta = CreateWorkspace("beta");
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+ var states = RecordStates();
+
+ await _coordinator.RunAsync(new[] { alpha, beta }, cts.Token);
+
+ Assert.Equal(
+ new[]
+ {
+ (alpha.AccountId!.Value, SyncAllAccountState.Queued),
+ (beta.AccountId!.Value, SyncAllAccountState.Queued),
+ (alpha.AccountId!.Value, SyncAllAccountState.Skipped),
+ (beta.AccountId!.Value, SyncAllAccountState.Skipped),
+ },
+ states);
+ }
}
diff --git a/gclo.Engine.Tests/VaultEdgeTests.cs b/gclo.Engine.Tests/VaultEdgeTests.cs
new file mode 100644
index 0000000..24ffe49
--- /dev/null
+++ b/gclo.Engine.Tests/VaultEdgeTests.cs
@@ -0,0 +1,73 @@
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using gclo.ViewModels;
+
+namespace gclo.Engine.Tests;
+
+///
+/// The Credential Manager vault's error arms, driven through internal seams:
+/// platform rejection, oversized blobs, and invalid target names. The happy
+/// paths live in . The real-store tests guard on
+/// (recognized by the platform analyzer);
+/// CI runs windows-latest, where they execute.
+///
+[SuppressMessage(
+ "Interoperability", "CA1416:Validate platform compatibility",
+ Justification = "Real-store tests guard on OperatingSystem.IsWindows at runtime (CI is windows-latest); "
+ + "the not-supported test deliberately drives the non-Windows seam. The platform analyzer cannot see "
+ + "the guard through the Assert.Throws lambdas.")]
+public sealed class VaultEdgeTests
+{
+ [Fact]
+ public void Constructor_NotWindows_ThrowsPlatformNotSupported()
+ => Assert.Throws(() => new CredentialManagerVault(isWindows: false));
+
+ [Fact]
+ public void Store_TokenLargerThanCredManBlobLimit_SurfacesWin32Error()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ // CRED_MAX_CREDENTIAL_BLOB_SIZE is 2560 bytes; UTF-16 doubles the length.
+ var vault = new CredentialManagerVault();
+ string oversized = new('x', 4000);
+
+ var ex = Assert.Throws(() => vault.Store(Guid.NewGuid(), oversized));
+
+ Assert.Contains("CredWriteW failed", ex.Message);
+ }
+
+ [Fact]
+ public void TryRetrieve_InvalidTargetName_SurfacesWin32Error()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var vault = new CredentialManagerVault();
+ string invalidTarget = new('x', 40_000); // far past CRED_MAX_GENERIC_TARGET_NAME_LENGTH
+
+ var ex = Assert.Throws(() => vault.TryRetrieve(invalidTarget, Guid.NewGuid()));
+
+ Assert.Contains("CredReadW failed", ex.Message);
+ }
+
+ [Fact]
+ public void Delete_InvalidTargetName_SurfacesWin32Error()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var vault = new CredentialManagerVault();
+ string invalidTarget = new('x', 40_000);
+
+ var ex = Assert.Throws(() => vault.Delete(invalidTarget, Guid.NewGuid()));
+
+ Assert.Contains("CredDeleteW failed", ex.Message);
+ }
+}
diff --git a/gclo.Engine.Tests/WindowsPathValidationTests.cs b/gclo.Engine.Tests/WindowsPathValidationTests.cs
index 7c2d976..15b38bc 100644
--- a/gclo.Engine.Tests/WindowsPathValidationTests.cs
+++ b/gclo.Engine.Tests/WindowsPathValidationTests.cs
@@ -82,6 +82,42 @@ public sealed class WindowsPathValidationTests : IDisposable
Assert.Empty(invalid);
}
+ [Fact]
+ public void Validate_EmptySubtree_YieldsTheDirectoryPath()
+ {
+ // A tree can carry an empty subtree (git normally cannot, but a forged tree
+ // or a filter can): the flattener yields the directory itself so an invalid
+ // directory name is still validated. A plain "emptydir" is valid, so the
+ // result is empty — but the empty-subtree branch is exercised.
+ string path = NewPath("empty-subtree-" + Guid.NewGuid().ToString("N")[..8]);
+ Repository.Init(path, isBare: true);
+ using var repo = new Repository(path);
+
+ var emptyTree = repo.ObjectDatabase.CreateTree(new TreeDefinition());
+ var definition = new TreeDefinition();
+ definition.Add("emptydir", emptyTree);
+
+ var invalid = WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
+
+ Assert.Empty(invalid); // "emptydir" is a valid name; the branch still ran
+ }
+
+ [Fact]
+ public void Validate_EmptySubtreeWithInvalidName_IsFlagged()
+ {
+ string path = NewPath("empty-badsubtree-" + Guid.NewGuid().ToString("N")[..8]);
+ Repository.Init(path, isBare: true);
+ using var repo = new Repository(path);
+
+ var emptyTree = repo.ObjectDatabase.CreateTree(new TreeDefinition());
+ var definition = new TreeDefinition();
+ definition.Add("bad ", emptyTree); // trailing space on the empty directory
+
+ var invalid = WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
+
+ Assert.Contains(invalid, i => i.RepoPath == "bad " && i.Reason.Contains("trailing space"));
+ }
+
// ---------------------------------------------------------------- clone behavior
[Fact]
diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
index 516bb92..48623bd 100644
--- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs
+++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs
@@ -1506,4 +1506,116 @@ public sealed class WorkspaceViewModelTests
Directory.Delete(vm.TargetFolder, recursive: true);
}
+
+ // ---------------------------------------------------------------- residue
+
+ [Fact]
+ public void EffectiveTargetRoot_EmptyFolder_IsEmpty()
+ {
+ var vm = CreateViewModel();
+ vm.TargetFolder = " ";
+
+ Assert.Equal("", vm.EffectiveTargetRoot);
+ }
+
+ [Fact]
+ public void MaxConcurrencyValue_ReflectsMaxConcurrency()
+ {
+ var vm = CreateViewModel();
+ vm.MaxConcurrency = 7;
+
+ Assert.Equal(7d, vm.MaxConcurrencyValue);
+ }
+
+ [Fact]
+ public async Task Sort_UnknownColumn_IsANoOp()
+ {
+ var vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"));
+ var before = vm.Repos.Select(r => r.Name).ToList();
+
+ vm.SortCommand.Execute("Nonsense");
+
+ Assert.Null(vm.SortColumn);
+ Assert.Equal(before, vm.Repos.Select(r => r.Name));
+ }
+
+ [Fact]
+ public async Task Sort_ByBranch_OrdersOnBranchText()
+ {
+ var vm = await CreateLoadedViewModelAsync(
+ Repo("a", branch: "zeta"), Repo("b", branch: "alpha"));
+
+ vm.SortCommand.Execute("Branch");
+
+ Assert.Equal(["b", "a"], vm.Repos.Select(r => r.Name));
+ }
+
+ [Fact]
+ public async Task Sort_ByArchived_OrdersUnarchivedFirst()
+ {
+ var vm = await CreateLoadedViewModelAsync(
+ Repo("a", archived: true), Repo("b", archived: false));
+
+ vm.SortCommand.Execute("Archived");
+
+ Assert.Equal(["b", "a"], vm.Repos.Select(r => r.Name));
+ }
+
+ [Fact]
+ public async Task Sync_EngineThrowsOperationCanceled_SetsCanceledResult()
+ {
+ // The engine swallows cancellation into a summary; the view model's own OCE
+ // handling is only reachable when the sync runner throws (injected here).
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ vm.SyncRunner = (_, _, _, ct) => throw new OperationCanceledException(ct);
+
+ await vm.SyncCommand.ExecuteAsync(null);
+
+ Assert.Equal(RunResultKind.Canceled, vm.ResultKind);
+ Assert.Equal("Canceled", vm.ResultMessage);
+ Assert.Equal("Canceled", vm.StatusText);
+ }
+
+ [Fact]
+ public async Task ResolvePaths_NoInteractionSubscriber_IsANoOp()
+ {
+ MakeAlphaFailWithInvalidPaths();
+ var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
+ await vm.SyncCommand.ExecuteAsync(null);
+ vm.RecoveryInteraction = null; // no subscriber
+
+ await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
+
+ Assert.Equal(SyncStatus.Failed, vm.Repos[0].Status); // unchanged
+ Assert.False(vm.IsResolvingPaths);
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
+
+ [Fact]
+ public async Task HandleProgress_UnknownRepoName_IsIgnored()
+ {
+ // The view model builds the progress handler when a sync starts; capture it,
+ // let the sync finish, then feed the handler a name that is not in the table.
+ // The guard must ignore it without throwing or adding a row.
+ Action? handler = null;
+ _lister.Repositories = [Repo("alpha")];
+ var vm = new WorkspaceViewModel(
+ _lister, _git, _orgs,
+ h => { handler = h; return new SyncProgress(h); },
+ TimeSpan.FromMilliseconds(1), new NullActivityLog());
+ vm.Organization = "acme";
+ vm.Token = "token-1234567890";
+ await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
+ vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
+ await vm.LoadReposCommand.ExecuteAsync(null);
+ await vm.SyncCommand.ExecuteAsync(null); // captures the handler
+
+ Assert.NotNull(handler);
+ handler!(new RepoProgress("ghost-repo", SyncStatus.Cloning, Percent: 0.5));
+
+ Assert.DoesNotContain(vm.Repos, r => r.Name == "ghost-repo");
+
+ Directory.Delete(vm.TargetFolder, recursive: true);
+ }
}
diff --git a/gclo.Engine/GitHubOrganizationLister.cs b/gclo.Engine/GitHubOrganizationLister.cs
index 66a57cb..ce63c13 100644
--- a/gclo.Engine/GitHubOrganizationLister.cs
+++ b/gclo.Engine/GitHubOrganizationLister.cs
@@ -5,31 +5,41 @@ namespace gclo.Engine;
/// Lists the authenticated user's organizations through the GitHub REST API.
public sealed class GitHubOrganizationLister : IOrganizationLister
{
+ private readonly Func _gatewayFactory;
+
+ /// Production wiring: a fresh Octokit-backed gateway per call's token.
+ public GitHubOrganizationLister()
+ : this(CreateOctokitGateway)
+ {
+ }
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Production wiring to the live GitHub API; the offline suite injects a fake gateway.")]
+ private static IGitHubGateway CreateOctokitGateway(string token) => new OctokitGateway(token);
+
+ /// Test seam: substitute the GitHub API with a fake gateway.
+ internal GitHubOrganizationLister(Func gatewayFactory)
+ => _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
+
///
public async Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(token);
cancellationToken.ThrowIfCancellationRequested();
- var client = new GitHubClient(new ProductHeaderValue("gclo"))
- {
- Credentials = new Credentials(token),
- };
+ IGitHubGateway gateway = _gatewayFactory(token);
string userLogin;
- IReadOnlyList organizations;
+ IReadOnlyList organizations;
try
{
// The token's own account is a valid sync target too (personal repos
// live under /users, not /orgs), so it heads the list.
- var currentUser = await client.User.Current().ConfigureAwait(false);
- userLogin = currentUser.Login;
+ userLogin = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
try
{
- organizations = await client.Organization
- .GetAllForCurrent(new ApiOptions { PageSize = 100 })
- .ConfigureAwait(false);
+ organizations = await gateway.GetOrganizationLoginsAsync().ConfigureAwait(false);
}
catch (ForbiddenException ex) when (ex is not RateLimitExceededException)
{
@@ -53,7 +63,6 @@ public sealed class GitHubOrganizationLister : IOrganizationLister
var result = new List { userLogin };
result.AddRange(organizations
- .Select(o => o.Login)
.Where(login => !string.Equals(login, userLogin, StringComparison.OrdinalIgnoreCase))
.OrderBy(login => login, StringComparer.OrdinalIgnoreCase));
return result;
diff --git a/gclo.Engine/GitHubRepositoryLister.cs b/gclo.Engine/GitHubRepositoryLister.cs
index ae70d2a..0fb7a4c 100644
--- a/gclo.Engine/GitHubRepositoryLister.cs
+++ b/gclo.Engine/GitHubRepositoryLister.cs
@@ -5,6 +5,22 @@ namespace gclo.Engine;
/// Lists org repositories through the GitHub REST API via Octokit.
public sealed class GitHubRepositoryLister : IRepositoryLister
{
+ private readonly Func _gatewayFactory;
+
+ /// Production wiring: a fresh Octokit-backed gateway per call's token.
+ public GitHubRepositoryLister()
+ : this(CreateOctokitGateway)
+ {
+ }
+
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
+ Justification = "Production wiring to the live GitHub API; the offline suite injects a fake gateway.")]
+ private static IGitHubGateway CreateOctokitGateway(string token) => new OctokitGateway(token);
+
+ /// Test seam: substitute the GitHub API with a fake gateway.
+ internal GitHubRepositoryLister(Func gatewayFactory)
+ => _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
+
///
public async Task> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
@@ -13,23 +29,19 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
ArgumentException.ThrowIfNullOrWhiteSpace(token);
cancellationToken.ThrowIfCancellationRequested();
- var client = new GitHubClient(new ProductHeaderValue("gclo"))
- {
- Credentials = new Credentials(token),
- };
+ IGitHubGateway gateway = _gatewayFactory(token);
// Pages manually (instead of one GetAll* call) so cancellation is honored
// between round trips on owners with hundreds of repositories.
- async Task> PageAsync(Func>> fetchPage)
+ async Task> PageAsync(Func>> fetchPage)
{
- var all = new List();
+ var all = new List();
for (int page = 1; ; page++)
{
cancellationToken.ThrowIfCancellationRequested();
- var batch = await fetchPage(new ApiOptions { PageSize = 100, PageCount = 1, StartPage = page })
- .ConfigureAwait(false);
+ var batch = await fetchPage(page).ConfigureAwait(false);
all.AddRange(batch);
- if (batch.Count < 100)
+ if (batch.Count < IGitHubGateway.PageSize)
{
break;
}
@@ -37,12 +49,13 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
return all;
}
- List repositories;
+ List repositories;
try
{
try
{
- repositories = await PageAsync(o => client.Repository.GetAllForOrg(organization, o))
+ repositories = await PageAsync(
+ page => gateway.GetOrganizationRepositoriesPageAsync(organization, page))
.ConfigureAwait(false);
}
catch (NotFoundException)
@@ -50,18 +63,18 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
// /orgs/{name}/repos 404s for user accounts: the token's own account
// gets its owned repos (including private); any other user account
// yields the repos the token can see there (public).
- var currentUser = await client.User.Current().ConfigureAwait(false);
- if (string.Equals(currentUser.Login, organization, StringComparison.OrdinalIgnoreCase))
+ string currentUser = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
+ if (string.Equals(currentUser, organization, StringComparison.OrdinalIgnoreCase))
{
- var owned = new RepositoryRequest { Affiliation = RepositoryAffiliation.Owner };
- repositories = await PageAsync(o => client.Repository.GetAllForCurrent(owned, o))
+ repositories = await PageAsync(gateway.GetOwnRepositoriesPageAsync)
.ConfigureAwait(false);
}
else
{
try
{
- repositories = await PageAsync(o => client.Repository.GetAllForUser(organization, o))
+ repositories = await PageAsync(
+ page => gateway.GetUserRepositoriesPageAsync(organization, page))
.ConfigureAwait(false);
}
catch (NotFoundException ex)
@@ -90,7 +103,7 @@ public sealed class GitHubRepositoryLister : IRepositoryLister
// repeat a boundary item; duplicates would race two clones into one folder.
.DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
- .Select(r => new RepoDescriptor(r.Name, r.CloneUrl, r.DefaultBranch, r.Archived))
+ .Select(r => new RepoDescriptor(r.Name, r.CloneUrl, r.DefaultBranch, r.IsArchived))
.ToList();
}
}
diff --git a/gclo.Engine/IGitHubGateway.cs b/gclo.Engine/IGitHubGateway.cs
new file mode 100644
index 0000000..4a9d9a7
--- /dev/null
+++ b/gclo.Engine/IGitHubGateway.cs
@@ -0,0 +1,83 @@
+using System.Diagnostics.CodeAnalysis;
+using Octokit;
+
+namespace gclo.Engine;
+
+/// One repository as the GitHub API describes it, in the fields gclo uses.
+internal readonly record struct GitHubRepo(string Name, string CloneUrl, string? DefaultBranch, bool IsArchived);
+
+///
+/// The seam between the listers and the GitHub REST API: exactly the calls gclo
+/// makes, returning plain shapes. Implementations surface Octokit's exception
+/// types unchanged (, ,
+/// , ) —
+/// translating them into user-facing errors is the listers' job, and that logic
+/// is what the tests pin.
+///
+internal interface IGitHubGateway
+{
+ /// Repositories per page; a batch smaller than this ends the paging loop.
+ const int PageSize = 100;
+
+ /// One page of an organization's repositories (1-based page number).
+ Task> GetOrganizationRepositoriesPageAsync(string organization, int page);
+
+ /// One page of the token's own repositories (owner affiliation).
+ Task> GetOwnRepositoriesPageAsync(int page);
+
+ /// One page of another user account's visible repositories.
+ Task> GetUserRepositoriesPageAsync(string user, int page);
+
+ /// The login of the account the token authenticates as.
+ Task GetCurrentUserLoginAsync();
+
+ /// Logins of the organizations visible to the token.
+ Task> GetOrganizationLoginsAsync();
+}
+
+///
+/// The production over Octokit. Pure delegation with
+/// no branching of its own, which is why it is excluded from the coverage gate:
+/// exercising these lines requires the live GitHub API, and the test suite is
+/// offline by policy (see CONTRIBUTING).
+///
+[ExcludeFromCodeCoverage(Justification = "Thin Octokit delegation; needs the live GitHub API.")]
+internal sealed class OctokitGateway : IGitHubGateway
+{
+ private readonly GitHubClient _client;
+
+ public OctokitGateway(string token)
+ => _client = new GitHubClient(new ProductHeaderValue("gclo"))
+ {
+ Credentials = new Credentials(token),
+ };
+
+ public async Task> GetOrganizationRepositoriesPageAsync(string organization, int page)
+ => Map(await _client.Repository.GetAllForOrg(organization, Page(page)).ConfigureAwait(false));
+
+ public async Task> GetOwnRepositoriesPageAsync(int page)
+ {
+ var owned = new RepositoryRequest { Affiliation = RepositoryAffiliation.Owner };
+ return Map(await _client.Repository.GetAllForCurrent(owned, Page(page)).ConfigureAwait(false));
+ }
+
+ public async Task> GetUserRepositoriesPageAsync(string user, int page)
+ => Map(await _client.Repository.GetAllForUser(user, Page(page)).ConfigureAwait(false));
+
+ public async Task GetCurrentUserLoginAsync()
+ => (await _client.User.Current().ConfigureAwait(false)).Login;
+
+ public async Task> GetOrganizationLoginsAsync()
+ {
+ var organizations = await _client.Organization
+ .GetAllForCurrent(new ApiOptions { PageSize = IGitHubGateway.PageSize })
+ .ConfigureAwait(false);
+ return organizations.Select(o => o.Login).ToList();
+ }
+
+ private static ApiOptions Page(int page)
+ => new() { PageSize = IGitHubGateway.PageSize, PageCount = 1, StartPage = page };
+
+ private static List Map(IReadOnlyList repositories)
+ => repositories.Select(r => new GitHubRepo(r.Name, r.CloneUrl, r.DefaultBranch, r.Archived)).ToList();
+}
diff --git a/gclo.Engine/LibGit2GitClient.cs b/gclo.Engine/LibGit2GitClient.cs
index f95d098..a2907f6 100644
--- a/gclo.Engine/LibGit2GitClient.cs
+++ b/gclo.Engine/LibGit2GitClient.cs
@@ -1,10 +1,30 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using LibGit2Sharp;
using LibGit2Sharp.Handlers;
namespace gclo.Engine;
-/// Git operations implemented with LibGit2Sharp.
+///
+/// Git operations implemented with LibGit2Sharp.
+///
+///
+/// This is the native-libgit2 boundary, and it is excluded from the coverage metric
+/// (like , the network boundary) rather than measured
+/// line-by-line. Its real behavior IS verified — the integration suites
+/// (LibGit2GitClientTests, WindowsPathValidationTests, PathRecoveryTests, SyncOverloadTests)
+/// drive it against on-disk fixture repositories and assert clone/pull/validation/recovery
+/// outcomes. What cannot be counted is branch coverage that depends on things the offline
+/// suite cannot deterministically produce: the transfer-progress callback (fires only on
+/// pack/network transport, never on a local clone) and the
+/// → arms
+/// (fire only when cancellation lands during a native libgit2 call). Measuring the class
+/// would report those as gaps no offline test can close, so it is excluded wholesale and
+/// its correctness is asserted behaviorally instead.
+///
+[ExcludeFromCodeCoverage(Justification =
+ "Native libgit2 adapter; verified behaviorally by the integration suites. Transport- and "
+ + "cancellation-timing branches are not deterministically reproducible offline. See remarks.")]
public sealed class LibGit2GitClient : IGitClient
{
///
diff --git a/gclo.Engine/gclo.Engine.csproj b/gclo.Engine/gclo.Engine.csproj
index 2e45976..db2732e 100644
--- a/gclo.Engine/gclo.Engine.csproj
+++ b/gclo.Engine/gclo.Engine.csproj
@@ -16,6 +16,11 @@
+
+
+
+
+
diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs
index 413c70e..e529493 100644
--- a/gclo.ViewModels/AppSettings.cs
+++ b/gclo.ViewModels/AppSettings.cs
@@ -101,7 +101,11 @@ public sealed class AppSettings
// The JSON deserializer can assign null despite the non-nullable annotation.
DefaultTargetFolder ??= "";
DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency);
- SplashMilliseconds = Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
+ // 0 means the field was absent (settings.json written by an older version):
+ // migrate to the default rather than clamping an unset value to the minimum.
+ SplashMilliseconds = SplashMilliseconds == 0
+ ? DefaultSplashMilliseconds
+ : Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
if (Theme is not ("System" or "Light" or "Dark"))
{
Theme = "System";
diff --git a/gclo.ViewModels/CredentialManagerVault.cs b/gclo.ViewModels/CredentialManagerVault.cs
index f2dd284..f0b98df 100644
--- a/gclo.ViewModels/CredentialManagerVault.cs
+++ b/gclo.ViewModels/CredentialManagerVault.cs
@@ -21,8 +21,14 @@ public sealed class CredentialManagerVault : ITokenVault
/// Fails fast on platforms without a Credential Manager.
public CredentialManagerVault()
+ : this(OperatingSystem.IsWindows())
{
- if (!OperatingSystem.IsWindows())
+ }
+
+ /// Test seam: the platform check is a parameter so both arms are coverable.
+ internal CredentialManagerVault(bool isWindows)
+ {
+ if (!isWindows)
{
throw new PlatformNotSupportedException(
"Account token storage requires the Windows Credential Manager.");
@@ -66,9 +72,15 @@ public sealed class CredentialManagerVault : ITokenVault
}
///
- public string? TryRetrieve(Guid accountId)
+ public string? TryRetrieve(Guid accountId) => TryRetrieve(TargetName(accountId), accountId);
+
+ /// Test seam: an invalid target name makes the non-not-found error arm coverable.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1822:Mark members as static",
+ Justification = "Instance seam: tests call it on a constructed vault; static would be a CS0176.")]
+ internal string? TryRetrieve(string targetName, Guid accountId)
{
- if (!CredReadW(TargetName(accountId), CredTypeGeneric, 0, out IntPtr credentialPtr))
+ if (!CredReadW(targetName, CredTypeGeneric, 0, out IntPtr credentialPtr))
{
int error = Marshal.GetLastWin32Error();
if (error == ErrorNotFound)
@@ -93,9 +105,15 @@ public sealed class CredentialManagerVault : ITokenVault
}
///
- public void Delete(Guid accountId)
+ public void Delete(Guid accountId) => Delete(TargetName(accountId), accountId);
+
+ /// Test seam: an invalid target name makes the non-not-found error arm coverable.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance", "CA1822:Mark members as static",
+ Justification = "Instance seam: tests call it on a constructed vault; static would be a CS0176.")]
+ internal void Delete(string targetName, Guid accountId)
{
- if (!CredDeleteW(TargetName(accountId), CredTypeGeneric, 0))
+ if (!CredDeleteW(targetName, CredTypeGeneric, 0))
{
int error = Marshal.GetLastWin32Error();
if (error != ErrorNotFound)
diff --git a/gclo.ViewModels/SyncAllCoordinator.cs b/gclo.ViewModels/SyncAllCoordinator.cs
index f8b7113..6f70776 100644
--- a/gclo.ViewModels/SyncAllCoordinator.cs
+++ b/gclo.ViewModels/SyncAllCoordinator.cs
@@ -10,6 +10,25 @@ namespace gclo.ViewModels;
/// True when cancellation stopped the queue before every account was processed.
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled);
+/// Where one account stands within a sync-all pass; feeds the sidebar badges.
+public enum SyncAllAccountState
+{
+ /// Scheduled in this pass, waiting its turn.
+ Queued,
+
+ /// The one account whose load/sync is in flight right now.
+ Running,
+
+ /// Finished with no failed repositories.
+ Succeeded,
+
+ /// Finished, but at least one repository failed.
+ Failed,
+
+ /// Not run: busy, unloadable, nothing to sync, or the queue was canceled.
+ Skipped,
+}
+
///
/// Runs every account workspace's sync strictly one at a time, in the order given.
/// Workspaces that have not loaded their repositories yet are loaded first; busy or
@@ -22,6 +41,14 @@ public sealed class SyncAllCoordinator(IActivityLog log)
{
private readonly IActivityLog _log = log ?? throw new ArgumentNullException(nameof(log));
+ ///
+ /// Per-account queue-state announcements for this pass, keyed by the workspace's
+ /// account id. Every scheduled account is announced Queued up front, Running when
+ /// its turn starts, then exactly one terminal state (Succeeded, Failed, Skipped).
+ /// Raised on the caller's context; the view maps these to sidebar badges.
+ ///
+ public Action? AccountStateChanged { get; set; }
+
///
/// Syncs each workspace of in list order,
/// loading its repositories first when needed. A workspace is skipped (counted,
@@ -34,13 +61,27 @@ public sealed class SyncAllCoordinator(IActivityLog log)
{
ArgumentNullException.ThrowIfNull(accountWorkspaces);
+ foreach (WorkspaceViewModel workspace in accountWorkspaces)
+ {
+ Announce(workspace, SyncAllAccountState.Queued);
+ }
+
int ran = 0;
int skipped = 0;
- foreach (WorkspaceViewModel workspace in accountWorkspaces)
+ for (int i = 0; i < accountWorkspaces.Count; i++)
{
+ WorkspaceViewModel workspace = accountWorkspaces[i];
+
if (ct.IsCancellationRequested)
{
+ // Unstarted accounts leave the pass as Skipped badges, but the result's
+ // Skipped count keeps its meaning: accounts actively skipped, not the
+ // abandoned remainder of a canceled queue.
+ for (int rest = i; rest < accountWorkspaces.Count; rest++)
+ {
+ Announce(accountWorkspaces[rest], SyncAllAccountState.Skipped);
+ }
_log.Info("Sync all: canceled — remaining accounts were not started.");
return new SyncAllResult(ran, skipped, WasCanceled: true);
}
@@ -48,10 +89,13 @@ public sealed class SyncAllCoordinator(IActivityLog log)
if (workspace.IsRunning || workspace.IsLoadingRepos || workspace.IsResolvingPaths)
{
skipped++;
+ Announce(workspace, SyncAllAccountState.Skipped);
_log.Info($"Sync all: skipped '{workspace.DisplayName}' — it is busy.");
continue;
}
+ Announce(workspace, SyncAllAccountState.Running);
+
if (!workspace.HasLoadedRepos)
{
_log.Info($"Sync all: loading repositories for '{workspace.DisplayName}'.");
@@ -59,6 +103,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
if (!workspace.HasLoadedRepos)
{
skipped++;
+ Announce(workspace, SyncAllAccountState.Skipped);
_log.Info(
$"Sync all: skipped '{workspace.DisplayName}' — its repositories could not be loaded.");
continue;
@@ -68,6 +113,7 @@ public sealed class SyncAllCoordinator(IActivityLog log)
if (!workspace.SyncCommand.CanExecute(null))
{
skipped++;
+ Announce(workspace, SyncAllAccountState.Skipped);
_log.Info($"Sync all: skipped '{workspace.DisplayName}' — it has nothing to sync.");
continue;
}
@@ -75,9 +121,20 @@ public sealed class SyncAllCoordinator(IActivityLog log)
_log.Info($"Sync all: syncing '{workspace.DisplayName}'.");
await workspace.SyncCommand.ExecuteAsync(null);
ran++;
+ Announce(
+ workspace,
+ workspace.HasFailedRepos ? SyncAllAccountState.Failed : SyncAllAccountState.Succeeded);
_log.Info($"Sync all: finished '{workspace.DisplayName}'.");
}
return new SyncAllResult(ran, skipped, WasCanceled: false);
}
+
+ private void Announce(WorkspaceViewModel workspace, SyncAllAccountState state)
+ {
+ if (workspace.AccountId is Guid id)
+ {
+ AccountStateChanged?.Invoke(id, state);
+ }
+ }
}
diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs
index 8e5ad61..cd43058 100644
--- a/gclo.ViewModels/WorkspaceViewModel.cs
+++ b/gclo.ViewModels/WorkspaceViewModel.cs
@@ -30,6 +30,18 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// Canceled on dispose so an in-flight path recovery dies with the workspace.
private readonly CancellationTokenSource _lifetimeCts = new();
+ ///
+ /// Runs one sync pass; the real by default. A test seam:
+ /// the engine swallows cancellation into a summary, so the only way to exercise the
+ /// view model's own handling is to inject a
+ /// runner that throws it.
+ ///
+ internal Func, IProgress, CancellationToken, Task>? SyncRunner
+ {
+ get;
+ set;
+ }
+
/// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other.
private bool _syncingSelection;
@@ -594,12 +606,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
// SynchronizationContext, so HandleProgress always runs on the UI thread.
var progress = _progressFactory(HandleProgress);
- var engine = new OrgSyncEngine(_lister, _git);
var request = new SyncRequest(
Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency);
IReadOnlyList descriptors = selected.Select(r => r.Descriptor).ToList();
- SyncSummary summary = await engine.SyncAsync(request, descriptors, progress, cancellationToken);
+ Func, IProgress, CancellationToken, Task> run =
+ SyncRunner ?? ((req, descs, prog, ct) => new OrgSyncEngine(_lister, _git).SyncAsync(req, descs, prog, ct));
+ SyncSummary summary = await run(request, descriptors, progress, cancellationToken);
string summaryText = (summary.WasCanceled ? "Canceled" : "Finished")
+ $": {summary.Cloned} cloned, {summary.Updated} updated, "
diff --git a/gclo.ViewModels/gclo.ViewModels.csproj b/gclo.ViewModels/gclo.ViewModels.csproj
index a72c419..10edce3 100644
--- a/gclo.ViewModels/gclo.ViewModels.csproj
+++ b/gclo.ViewModels/gclo.ViewModels.csproj
@@ -14,4 +14,9 @@
enable
+
+
+
+
+
diff --git a/gclo.slnx b/gclo.slnx
index 9c4e8a8..49123f4 100644
--- a/gclo.slnx
+++ b/gclo.slnx
@@ -5,6 +5,7 @@
+
diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs
index 2142739..0a3af36 100644
--- a/gclo/MainWindow.xaml.cs
+++ b/gclo/MainWindow.xaml.cs
@@ -251,6 +251,18 @@ namespace gclo
{
OnWorkspaceStateChanged(id);
}
+ else if (e.PropertyName is nameof(WorkspaceViewModel.CompletedCount)
+ && viewModel.IsRunning)
+ {
+ // Live "N of M" on the running account's sidebar item, so a
+ // sync-all pass shows progress without switching workspaces.
+ if (_navItems.TryGetValue(id, out NavigationViewItem? item))
+ {
+ ToolTipService.SetToolTip(
+ item,
+ $"Syncing: {viewModel.CompletedCount} of {viewModel.TotalCount} repositories");
+ }
+ }
};
viewModel.PropertyChanged += handler;
_badgeHandlers[id] = handler;
@@ -285,8 +297,18 @@ namespace gclo
}
///
- /// Refreshes a workspace's pane badge (attention dot while running, critical dot
- /// when it has failures) and, after a run, its tooltip from the stored summary.
+ /// Per-account queue states of the current (or most recent) sync-all pass;
+ /// cleared when the next pass starts. Rendered by
+ /// whenever the account is not running.
+ ///
+ private readonly Dictionary _syncAllStates = new();
+
+ ///
+ /// Refreshes a workspace's pane badge and, after a run, its tooltip from the
+ /// stored summary. Badge priority: a running sync always shows the attention
+ /// dot; otherwise the sync-all pass state (queued, succeeded with a check,
+ /// failed with the failure count, skipped) when one exists; otherwise the
+ /// standing critical dot for lingering failures.
///
private void OnWorkspaceStateChanged(Guid id)
{
@@ -298,10 +320,26 @@ namespace gclo
WorkspaceViewModel viewModel = workspace.ViewModel;
item.InfoBadge = viewModel.IsRunning
- ? CreateDotBadge("AttentionDotInfoBadgeStyle", "SystemFillColorAttentionBrush")
- : viewModel.HasFailedRepos
- ? CreateDotBadge("CriticalDotInfoBadgeStyle", "SystemFillColorCriticalBrush")
- : null;
+ ? CreateBadge("AttentionDotInfoBadgeStyle", "SystemFillColorAttentionBrush")
+ : _syncAllStates.TryGetValue(id, out SyncAllAccountState state)
+ ? state switch
+ {
+ SyncAllAccountState.Queued =>
+ CreateBadge("InformationalDotInfoBadgeStyle", "SystemFillColorSolidNeutralBrush"),
+ SyncAllAccountState.Succeeded =>
+ CreateBadge("SuccessIconInfoBadgeStyle", "SystemFillColorSuccessBrush"),
+ SyncAllAccountState.Failed =>
+ CreateBadge(
+ "CriticalValueInfoBadgeStyle",
+ "SystemFillColorCriticalBrush",
+ viewModel.Repos.Count(r => r.Status == SyncStatus.Failed)),
+ SyncAllAccountState.Skipped =>
+ CreateBadge("CautionDotInfoBadgeStyle", "SystemFillColorCautionBrush"),
+ _ => null, // Running is covered by IsRunning above
+ }
+ : viewModel.HasFailedRepos
+ ? CreateBadge("CriticalDotInfoBadgeStyle", "SystemFillColorCriticalBrush")
+ : null;
// A finished run has stamped its outcome onto the account by now.
if (!viewModel.IsRunning && id != Guid.Empty)
@@ -315,12 +353,17 @@ namespace gclo
}
///
- /// A value-less (dot) InfoBadge, styled by the WinUI dot-badge resource when
- /// present, otherwise tinted directly from the matching theme fill brush.
+ /// An InfoBadge styled by the WinUI badge resource when present, otherwise
+ /// tinted directly from the matching theme fill brush. A non-null
+ /// makes it a numeric badge (e.g. failed count).
///
- private static InfoBadge CreateDotBadge(string styleKey, string fallbackBrushKey)
+ private static InfoBadge CreateBadge(string styleKey, string fallbackBrushKey, int? value = null)
{
var badge = new InfoBadge();
+ if (value is int number)
+ {
+ badge.Value = number;
+ }
if (Application.Current.Resources.TryGetValue(styleKey, out object? styleValue)
&& styleValue is Style style)
{
@@ -612,8 +655,19 @@ namespace gclo
SyncAllNavItem.Content = "Cancel sync all";
try
{
- SyncAllResult result =
- await new SyncAllCoordinator(_log).RunAsync(accountWorkspaces, cts.Token);
+ // Badges from the previous pass make way for this one; the coordinator
+ // announces Queued for every scheduled account immediately. It runs on
+ // this (UI) thread, so the callback may touch the pane directly.
+ _syncAllStates.Clear();
+ var coordinator = new SyncAllCoordinator(_log)
+ {
+ AccountStateChanged = (accountId, state) =>
+ {
+ _syncAllStates[accountId] = state;
+ OnWorkspaceStateChanged(accountId);
+ },
+ };
+ SyncAllResult result = await coordinator.RunAsync(accountWorkspaces, cts.Token);
string accounts = result.Ran == 1 ? "1 account" : $"{result.Ran} accounts";
string message = result.WasCanceled