100% enforced coverage (Engine/ViewModels/CLI) + Sync All sidebar status (beta.13) #36

Merged
KofTwentyTwo merged 5 commits from dev into main 2026-07-05 18:09:55 +00:00
37 changed files with 2378 additions and 106 deletions

No files matched your search

+37 -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,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()
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Coverage contract for the 100% gate: every hand-written line in the three
library assemblies counts. Excluded, with intent:
- GeneratedCodeAttribute: System.Text.Json source-generated serializer
contexts (AccountsJsonContext, AppSettingsJsonContext) — generated code,
regenerated on every build, not ours to test line-by-line.
- ExcludeFromCodeCoverageAttribute: thin, hand-audited adapters over
external systems that cannot run offline (each carries a justification
comment at its declaration).
The WinUI view project (gclo) is not measured here at all: XAML-generated
plumbing needs a live compositor and is exercised by the FlaUI e2e suite. -->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<!-- 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>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
+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>
+33
View File
@@ -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()
{
+238
View File
@@ -0,0 +1,238 @@
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// 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.
/// </summary>
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<string> Errors { get; } = [];
public void Info(string message)
{
}
public void Error(string message, Exception? exception = null) => Errors.Add(message);
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
}
+280
View File
@@ -0,0 +1,280 @@
using System.Net;
using Octokit;
namespace gclo.Engine.Tests;
/// <summary>
/// Pins the GitHub listers' pagination, account-fallback, ordering, dedupe, and
/// exception-translation logic over a fake <see cref="IGitHubGateway"/> — the
/// production gateway is thin Octokit delegation and stays outside the offline suite.
/// </summary>
public sealed class GitHubListerTests
{
private sealed class FakeGateway : IGitHubGateway
{
public Func<string, int, Task<IReadOnlyList<GitHubRepo>>> OrgPages { get; set; } =
(_, _) => Task.FromResult<IReadOnlyList<GitHubRepo>>([]);
public Func<int, Task<IReadOnlyList<GitHubRepo>>> OwnPages { get; set; } =
_ => Task.FromResult<IReadOnlyList<GitHubRepo>>([]);
public Func<string, int, Task<IReadOnlyList<GitHubRepo>>> UserPages { get; set; } =
(_, _) => Task.FromResult<IReadOnlyList<GitHubRepo>>([]);
public Func<Task<string>> CurrentUser { get; set; } = () => Task.FromResult("me");
public Func<Task<IReadOnlyList<string>>> OrgLogins { get; set; } =
() => Task.FromResult<IReadOnlyList<string>>([]);
public Task<IReadOnlyList<GitHubRepo>> GetOrganizationRepositoriesPageAsync(string organization, int page)
=> OrgPages(organization, page);
public Task<IReadOnlyList<GitHubRepo>> GetOwnRepositoriesPageAsync(int page) => OwnPages(page);
public Task<IReadOnlyList<GitHubRepo>> GetUserRepositoriesPageAsync(string user, int page)
=> UserPages(user, page);
public Task<string> GetCurrentUserLoginAsync() => CurrentUser();
public Task<IReadOnlyList<string>> 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)));
/// <summary>Octokit's Response type is internal; its IResponse is not.</summary>
private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null) : IResponse
{
public object? Body => "";
public IReadOnlyDictionary<string, string> Headers { get; } = new Dictionary<string, string>();
public ApiInfo ApiInfo { get; } = new(
new Dictionary<string, Uri>(), [], [], "", 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<IReadOnlyList<GitHubRepo>>(
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<IReadOnlyList<GitHubRepo>>(
[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<IReadOnlyList<GitHubRepo>>(
[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<IReadOnlyList<GitHubRepo>>([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<IReadOnlyList<GitHubRepo>>([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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<IReadOnlyList<GitHubRepo>>(full);
};
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token));
}
[Fact]
public async Task Repos_AlreadyCanceled_ThrowsBeforeAnyCall()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => 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<ArgumentException>(
() => RepoLister().ListOrganizationRepositoriesAsync(organization, token));
// ---------------------------------------------------------------- organizations
[Fact]
public async Task Orgs_OwnLoginFirst_ThenOrgsSorted_OwnLoginNeverDuplicated()
{
_gateway.CurrentUser = () => Task.FromResult("Kof");
_gateway.OrgLogins = () => Task.FromResult<IReadOnlyList<string>>(["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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<OperationCanceledException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890", cts.Token));
Assert.Null(_tokenSeen);
}
[Fact]
public async Task Orgs_BlankToken_Throws()
=> await Assert.ThrowsAsync<ArgumentException>(() => OrgLister().ListOrganizationsAsync(" "));
[Fact]
public void Listers_NullGatewayFactory_Throw()
{
Assert.Throws<ArgumentNullException>(() => new GitHubRepositoryLister(null!));
Assert.Throws<ArgumentNullException>(() => 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());
}
}
@@ -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);
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// The Credential Manager vault's error arms, driven through internal seams:
/// platform rejection, oversized blobs, and invalid target names. The happy
/// paths live in <see cref="VaultTests"/>. The real-store tests guard on
/// <see cref="OperatingSystem.IsWindows"/> (recognized by the platform analyzer);
/// CI runs windows-latest, where they execute.
/// </summary>
[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<PlatformNotSupportedException>(() => 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<Win32Exception>(() => 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<Win32Exception>(() => 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<Win32Exception>(() => vault.Delete(invalidTarget, Guid.NewGuid()));
Assert.Contains("CredDeleteW failed", ex.Message);
}
}
@@ -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]
@@ -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<RepoProgress>? 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);
}
}
+20 -11
View File
@@ -5,31 +5,41 @@ namespace gclo.Engine;
/// <summary>Lists the authenticated user's organizations through the GitHub REST API.</summary>
public sealed class GitHubOrganizationLister : IOrganizationLister
{
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: a fresh Octokit-backed gateway per call's token.</summary>
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);
/// <summary>Test seam: substitute the GitHub API with a fake gateway.</summary>
internal GitHubOrganizationLister(Func<string, IGitHubGateway> gatewayFactory)
=> _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> 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<Organization> organizations;
IReadOnlyList<string> 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<string> { userLogin };
result.AddRange(organizations
.Select(o => o.Login)
.Where(login => !string.Equals(login, userLogin, StringComparison.OrdinalIgnoreCase))
.OrderBy(login => login, StringComparer.OrdinalIgnoreCase));
return result;
+30 -17
View File
@@ -5,6 +5,22 @@ namespace gclo.Engine;
/// <summary>Lists org repositories through the GitHub REST API via Octokit.</summary>
public sealed class GitHubRepositoryLister : IRepositoryLister
{
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: a fresh Octokit-backed gateway per call's token.</summary>
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);
/// <summary>Test seam: substitute the GitHub API with a fake gateway.</summary>
internal GitHubRepositoryLister(Func<string, IGitHubGateway> gatewayFactory)
=> _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory));
/// <inheritdoc/>
public async Task<IReadOnlyList<RepoDescriptor>> 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<List<Repository>> PageAsync(Func<ApiOptions, Task<IReadOnlyList<Repository>>> fetchPage)
async Task<List<GitHubRepo>> PageAsync(Func<int, Task<IReadOnlyList<GitHubRepo>>> fetchPage)
{
var all = new List<Repository>();
var all = new List<GitHubRepo>();
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<Repository> repositories;
List<GitHubRepo> 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();
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Diagnostics.CodeAnalysis;
using Octokit;
namespace gclo.Engine;
/// <summary>One repository as the GitHub API describes it, in the fields gclo uses.</summary>
internal readonly record struct GitHubRepo(string Name, string CloneUrl, string? DefaultBranch, bool IsArchived);
/// <summary>
/// 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 (<see cref="NotFoundException"/>, <see cref="AuthorizationException"/>,
/// <see cref="ForbiddenException"/>, <see cref="RateLimitExceededException"/>) —
/// translating them into user-facing errors is the listers' job, and that logic
/// is what the tests pin.
/// </summary>
internal interface IGitHubGateway
{
/// <summary>Repositories per page; a batch smaller than this ends the paging loop.</summary>
const int PageSize = 100;
/// <summary>One page of an organization's repositories (1-based page number).</summary>
Task<IReadOnlyList<GitHubRepo>> GetOrganizationRepositoriesPageAsync(string organization, int page);
/// <summary>One page of the token's own repositories (owner affiliation).</summary>
Task<IReadOnlyList<GitHubRepo>> GetOwnRepositoriesPageAsync(int page);
/// <summary>One page of another user account's visible repositories.</summary>
Task<IReadOnlyList<GitHubRepo>> GetUserRepositoriesPageAsync(string user, int page);
/// <summary>The login of the account the token authenticates as.</summary>
Task<string> GetCurrentUserLoginAsync();
/// <summary>Logins of the organizations visible to the token.</summary>
Task<IReadOnlyList<string>> GetOrganizationLoginsAsync();
}
/// <summary>
/// The production <see cref="IGitHubGateway"/> 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).
/// </summary>
[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<IReadOnlyList<GitHubRepo>> GetOrganizationRepositoriesPageAsync(string organization, int page)
=> Map(await _client.Repository.GetAllForOrg(organization, Page(page)).ConfigureAwait(false));
public async Task<IReadOnlyList<GitHubRepo>> GetOwnRepositoriesPageAsync(int page)
{
var owned = new RepositoryRequest { Affiliation = RepositoryAffiliation.Owner };
return Map(await _client.Repository.GetAllForCurrent(owned, Page(page)).ConfigureAwait(false));
}
public async Task<IReadOnlyList<GitHubRepo>> GetUserRepositoriesPageAsync(string user, int page)
=> Map(await _client.Repository.GetAllForUser(user, Page(page)).ConfigureAwait(false));
public async Task<string> GetCurrentUserLoginAsync()
=> (await _client.User.Current().ConfigureAwait(false)).Login;
public async Task<IReadOnlyList<string>> 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<GitHubRepo> Map(IReadOnlyList<Repository> repositories)
=> repositories.Select(r => new GitHubRepo(r.Name, r.CloneUrl, r.DefaultBranch, r.Archived)).ToList();
}
+21 -1
View File
@@ -1,10 +1,30 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using LibGit2Sharp;
using LibGit2Sharp.Handlers;
namespace gclo.Engine;
/// <summary>Git operations implemented with LibGit2Sharp.</summary>
/// <summary>
/// Git operations implemented with LibGit2Sharp.
/// </summary>
/// <remarks>
/// This is the native-libgit2 boundary, and it is excluded from the coverage metric
/// (like <see cref="OctokitGateway"/>, 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
/// <see cref="UserCancelledException"/> → <see cref="OperationCanceledException"/> 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.
/// </remarks>
[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
{
/// <summary>
+5
View File
@@ -16,6 +16,11 @@
<None Include="PACKAGE.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<!-- The test suite fakes the internal GitHub gateway seam. -->
<InternalsVisibleTo Include="gclo.Engine.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="Octokit" Version="14.0.0" />
+5 -1
View File
@@ -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";
+23 -5
View File
@@ -21,8 +21,14 @@ public sealed class CredentialManagerVault : ITokenVault
/// <summary>Fails fast on platforms without a Credential Manager.</summary>
public CredentialManagerVault()
: this(OperatingSystem.IsWindows())
{
if (!OperatingSystem.IsWindows())
}
/// <summary>Test seam: the platform check is a parameter so both arms are coverable.</summary>
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
}
/// <inheritdoc/>
public string? TryRetrieve(Guid accountId)
public string? TryRetrieve(Guid accountId) => TryRetrieve(TargetName(accountId), accountId);
/// <summary>Test seam: an invalid target name makes the non-not-found error arm coverable.</summary>
[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
}
/// <inheritdoc/>
public void Delete(Guid accountId)
public void Delete(Guid accountId) => Delete(TargetName(accountId), accountId);
/// <summary>Test seam: an invalid target name makes the non-not-found error arm coverable.</summary>
[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)
+58 -1
View File
@@ -10,6 +10,25 @@ namespace gclo.ViewModels;
/// <param name="WasCanceled">True when cancellation stopped the queue before every account was processed.</param>
public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled);
/// <summary>Where one account stands within a sync-all pass; feeds the sidebar badges.</summary>
public enum SyncAllAccountState
{
/// <summary>Scheduled in this pass, waiting its turn.</summary>
Queued,
/// <summary>The one account whose load/sync is in flight right now.</summary>
Running,
/// <summary>Finished with no failed repositories.</summary>
Succeeded,
/// <summary>Finished, but at least one repository failed.</summary>
Failed,
/// <summary>Not run: busy, unloadable, nothing to sync, or the queue was canceled.</summary>
Skipped,
}
/// <summary>
/// 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));
/// <summary>
/// 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.
/// </summary>
public Action<Guid, SyncAllAccountState>? AccountStateChanged { get; set; }
/// <summary>
/// Syncs each workspace of <paramref name="accountWorkspaces"/> 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);
}
}
}
+15 -2
View File
@@ -30,6 +30,18 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
/// <summary>Canceled on dispose so an in-flight path recovery dies with the workspace.</summary>
private readonly CancellationTokenSource _lifetimeCts = new();
/// <summary>
/// Runs one sync pass; the real <see cref="OrgSyncEngine"/> by default. A test seam:
/// the engine swallows cancellation into a summary, so the only way to exercise the
/// view model's own <see cref="OperationCanceledException"/> handling is to inject a
/// runner that throws it.
/// </summary>
internal Func<SyncRequest, IReadOnlyList<RepoDescriptor>, IProgress<RepoProgress>, CancellationToken, Task<SyncSummary>>? SyncRunner
{
get;
set;
}
/// <summary>Breaks the AllSelected &lt;-&gt; item.IsSelected feedback loop while one side updates the other.</summary>
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<RepoDescriptor> descriptors = selected.Select(r => r.Descriptor).ToList();
SyncSummary summary = await engine.SyncAsync(request, descriptors, progress, cancellationToken);
Func<SyncRequest, IReadOnlyList<RepoDescriptor>, IProgress<RepoProgress>, CancellationToken, Task<SyncSummary>> 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, "
+5
View File
@@ -14,4 +14,9 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<!-- The test suite exercises internal seams (vault error arms). -->
<InternalsVisibleTo Include="gclo.Engine.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" />
+65 -11
View File
@@ -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
}
/// <summary>
/// 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
/// <see cref="OnWorkspaceStateChanged"/> whenever the account is not running.
/// </summary>
private readonly Dictionary<Guid, SyncAllAccountState> _syncAllStates = new();
/// <summary>
/// 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.
/// </summary>
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
}
/// <summary>
/// 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
/// <paramref name="value"/> makes it a numeric badge (e.g. failed count).
/// </summary>
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