100% enforced coverage (Engine/ViewModels/CLI) + Sync All sidebar status (beta.13) #36
No files matched your search
+37
-26
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -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 => "";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 => "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <-> 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, "
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user