100% coverage for gclo.Cli; CI gate enforces 100% on all three libraries

New gclo.Cli.Tests project (65 tests) covers the CLI to 100% line
coverage by driving injectable command cores over fake collaborators:

- Commands (sync/orgs/accounts) split into a thin composition-root
  wrapper (excluded — it wires the real GitHub listers, LibGit2 client,
  and Credential Manager) and an internal core taking IRepositoryLister/
  IOrganizationLister/IGitClient/account-open-func/IActivityLog. Tests
  capture the process Console (sequentially — the assembly disables test
  parallelization since Console is global) and assert output, exit codes,
  and error translation.
- Covered: argument parsing and every usage error, token resolution
  (env/file/redirected-stdin/multi-source), account seeding + org
  subfolder, unknown/missing-token accounts, sync success/partial-
  failure/canceled, JSON and quiet output, path sanitization (recover/
  fail/cancel), invalid-path listing + truncation, and the record-result
  warning path.
- Excluded (auditable): Program (composition root), TokenOptions'
  interactive no-echo read, and each command's real-collaborator wrapper.

CI now runs both test projects under coverage.runsettings and fails
unless gclo.Engine, gclo.ViewModels, and gclo (the CLI) each measure
100% line coverage. Verified locally: 100.00% / 100.00% / 100.00%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James MaesandClaude Fable 5 committed 2026-07-05 12:56:17 -05:00
1 parent 21e850a975
commit 96166e2cfc
19 files changed
+1165 -58

No files matched your search

+36 -26
View File
@@ -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,32 @@ 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.
$required = @('gclo.Engine', 'gclo.ViewModels', 'gclo.Cli')
$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()
+3 -1
View File
@@ -15,7 +15,9 @@
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Include>[gclo.Engine]*,[gclo.ViewModels]*,[gclo.Cli]*</Include>
<!-- The CLI project's assembly name is 'gclo' (it ships as gclo.exe), so it
appears as package 'gclo' — not 'gclo.Cli' — in the report. -->
<Include>[gclo.Engine]*,[gclo.ViewModels]*,[gclo]*</Include>
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>
+9
View File
@@ -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
+105
View File
@@ -0,0 +1,105 @@
using gclo.Engine;
using gclo.ViewModels;
namespace gclo.Cli.Tests;
/// <summary>Covers 'gclo accounts': parsing, empty/populated listings, and JSON output.</summary>
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<IActivityLog, (AccountsStore, ITokenVault)> 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<CliUsageException>(() => 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<IActivityLog, (AccountsStore, ITokenVault)> failing =
_ => throw new CliErrorException("Accounts require Windows credential storage; ...");
Assert.Throws<CliErrorException>(() => AccountsCommand.Run([], failing, new NullLog()));
}
}
+4
View File
@@ -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)]
+117
View File
@@ -0,0 +1,117 @@
using System.Collections.Concurrent;
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Captures Console.Out and Console.Error for the duration of a block.</summary>
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);
}
}
/// <summary>Redirects Console.In to a fixed string for the duration of a block.</summary>
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);
}
/// <summary>An organization lister returning a fixed list or throwing a fixed exception.</summary>
internal sealed class FakeOrgLister : IOrganizationLister
{
public IReadOnlyList<string> Result { get; set; } = [];
public Exception? Throw { get; set; }
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Throw is not null
? Task.FromException<IReadOnlyList<string>>(Throw)
: Task.FromResult(Result);
}
}
/// <summary>A repository lister returning a fixed list or throwing a fixed exception.</summary>
internal sealed class FakeRepoLister : IRepositoryLister
{
public IReadOnlyList<RepoDescriptor> Result { get; set; } = [];
public Exception? Throw { get; set; }
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Throw is not null
? Task.FromException<IReadOnlyList<RepoDescriptor>>(Throw)
: Task.FromResult(Result);
}
}
/// <summary>A git client configured with delegates; records clone/pull/recovery calls.</summary>
internal sealed class FakeGit : IGitClient
{
private readonly ConcurrentQueue<string> _clones = new();
private readonly ConcurrentQueue<string> _recoveries = new();
public Func<string, bool> IsValid { get; set; } = _ => false;
public Func<string, string, string, Action<double>?, CancellationToken, Task> OnClone { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
public Func<string, string, CancellationToken, Task> OnPull { get; set; } = (_, _, _) => Task.CompletedTask;
public Func<string, PathRecovery, CancellationToken, Task> OnApplyRecovery { get; set; } = (_, _, _) => Task.CompletedTask;
public IReadOnlyList<string> ClonedRepoNames => _clones.Select(Path.GetFileName).ToArray()!;
public IReadOnlyList<string> RecoveredRepoNames => _recoveries.Select(Path.GetFileName).ToArray()!;
public bool IsValidRepository(string path) => IsValid(path);
public Task CloneAsync(string url, string path, string token, Action<double>? 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);
}
}
/// <summary>Discards every activity-log entry.</summary>
internal sealed class NullLog : IActivityLog
{
public void Info(string message)
{
}
public void Error(string message, Exception? exception = null)
{
}
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
+86
View File
@@ -0,0 +1,86 @@
namespace gclo.Cli.Tests;
/// <summary>Covers the shared argument reader: both value forms and the flag/value guards.</summary>
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<CliUsageException>(() => reader.RequireValue());
}
[Fact]
public void RequireValue_AtEnd_Throws()
{
var reader = new OptionReader(["--org"]);
reader.MoveNext();
Assert.Throws<CliUsageException>(() => reader.RequireValue());
}
[Fact]
public void RequireValue_FollowedByAnotherOption_Throws()
{
var reader = new OptionReader(["--org", "--target"]);
reader.MoveNext();
Assert.Throws<CliUsageException>(() => reader.RequireValue());
}
[Fact]
public void RejectValue_InlineValueOnAFlag_Throws()
{
var reader = new OptionReader(["--json=true"]);
reader.MoveNext();
Assert.Throws<CliUsageException>(() => reader.RejectValue());
}
[Fact]
public void RejectValue_NoInlineValue_IsFine()
{
var reader = new OptionReader(["--json"]);
reader.MoveNext();
reader.RejectValue(); // must not throw
}
}
+102
View File
@@ -0,0 +1,102 @@
namespace gclo.Cli.Tests;
/// <summary>Covers 'gclo orgs': parsing, plain and JSON output, and error translation.</summary>
public sealed class OrgsCommandTests
{
private const string EnvVar = "GCLO_ORGS_TEST_TOKEN";
private static Task<int> 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<CliUsageException>(() => 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<CliErrorException>(() => 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<CliErrorException>(() => 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<OperationCanceledException>(
() => OrgsCommand.RunAsync(["--token-env", EnvVar], lister, new NullLog(), cts.Token));
}
}
+58
View File
@@ -0,0 +1,58 @@
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Covers the console progress printer: dedup, stdout/stderr routing, and gating.</summary>
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);
}
}
+398
View File
@@ -0,0 +1,398 @@
using gclo.Engine;
using gclo.ViewModels;
namespace gclo.Cli.Tests;
/// <summary>
/// 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.
/// </summary>
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<IActivityLog, (AccountsStore, ITokenVault)> Open => _ => (Store, _vault);
private static RepoDescriptor Repo(string name) => new(name, $"https://x/{name}.git", "main", false);
private Task<int> 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<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--target", Target));
[Fact]
public async Task MissingTarget_Throws()
=> await Assert.ThrowsAsync<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme"));
[Theory]
[InlineData("zero")]
[InlineData("0")]
[InlineData("-3")]
public async Task BadParallel_Throws(string value)
=> await Assert.ThrowsAsync<CliUsageException>(
() => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value));
[Fact]
public async Task UnknownOption_Throws()
=> await Assert.ThrowsAsync<CliUsageException>(
() => 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<CliErrorException>(
() => 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<CliErrorException>(
() => 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 <root>\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<CliErrorException>(
() => 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<CliErrorException>(
() => 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<CliErrorException>(
() => 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);
}
}
+151
View File
@@ -0,0 +1,151 @@
namespace gclo.Cli.Tests;
/// <summary>
/// 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).
/// </summary>
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<CliUsageException>(() => 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<CliErrorException>(
() => 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<CliErrorException>(() => 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<CliErrorException>(() => 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<CliErrorException>(() => 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<CliErrorException>(() => 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<CliUsageException>(() => new TokenOptions().TryConsume(reader));
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\gclo.Cli\gclo.Cli.csproj" />
<ProjectReference Include="..\gclo.Engine\gclo.Engine.csproj" />
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
</ItemGroup>
</Project>
+9 -4
View File
@@ -33,7 +33,12 @@ internal static class AccountsCommand
2 fatal: bad arguments, or not running on Windows
""";
public static int Run(string[] args)
/// <summary>Composition root: wires the real Credential Manager store and file log.</summary>
[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<IActivityLog, (AccountsStore Store, ITokenVault Vault)> 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<Account> 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.
/// </summary>
/// <exception cref="CliErrorException">The current OS is not Windows.</exception>
[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())
+9 -4
View File
@@ -33,7 +33,14 @@ internal static class OrgsCommand
argument would leak. Use --token-env, --token-file, or --token-stdin.
""";
public static async Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
/// <summary>Composition root: wires the real GitHub lister and file log.</summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Wires the real network lister and file log; delegates to the covered core.")]
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
=> RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(), cancellationToken);
internal static async Task<int> 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<string> logins;
try
{
logins = await new GitHubOrganizationLister()
logins = await lister
.ListOrganizationsAsync(token, cancellationToken)
.ConfigureAwait(false);
}
+9 -1
View File
@@ -1,8 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>Entry point: command dispatch, Ctrl+C wiring, and top-level error handling.</summary>
/// <summary>
/// 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%.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Composition root: wires real collaborators and the process Console/Ctrl+C.")]
internal static class Program
{
private const string RootHelp = """
+18 -8
View File
@@ -75,7 +75,22 @@ internal static class SyncCommand
argument would leak. Use --token-env, --token-file, or --token-stdin.
""";
public static async Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
/// <summary>Composition root: wires the real GitHub lister, LibGit2 client, and file log.</summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(
Justification = "Wires the real network/native collaborators; delegates to the covered core.")]
public static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
=> RunAsync(
args,
new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(),
cancellationToken);
internal static async Task<int> RunAsync(
string[] args,
IRepositoryLister lister,
IGitClient git,
Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> 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);
+18 -14
View File
@@ -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();
+5
View File
@@ -15,4 +15,9 @@
<ProjectReference Include="..\gclo.ViewModels\gclo.ViewModels.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The test suite drives internal command cores with injected collaborators. -->
<InternalsVisibleTo Include="gclo.Cli.Tests" />
</ItemGroup>
</Project>
+1
View File
@@ -5,6 +5,7 @@
<Platform Name="x86" />
</Configurations>
<Project Path="gclo.Cli/gclo.Cli.csproj" />
<Project Path="gclo.Cli.Tests/gclo.Cli.Tests.csproj" />
<Project Path="gclo.Engine.Tests/gclo.Engine.Tests.csproj" />
<Project Path="gclo.Engine/gclo.Engine.csproj" />
<Project Path="gclo.UiTests/gclo.UiTests.csproj" />