style: adopt the Kingsrook C# layout and the standard .NET quality gate #70

Merged
KofTwentyTwo merged 2 commits from style/kingsrook-layout into main 2026-10-09 19:39:44 +00:00
165 changed files with 21596 additions and 17591 deletions

No files matched your search

+221 -25
View File
@@ -1,40 +1,142 @@
# gclo code style. Enforced in every build via Directory.Build.props
# (EnforceCodeStyleInBuild + TreatWarningsAsErrors) and by the CI format gate
# (dotnet format --verify-no-changes --severity error).
# KofTwentyTwo .NET code style (standards: K22-CODE-CS): the Kingsrook code style
# (https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) applied to C#, from
# KofTwentyTwo/standards configs/dotnet/.editorconfig. Severities of "warning" fail
# the build because Directory.Build.props sets TreatWarningsAsErrors and
# EnforceCodeStyleInBuild, so local `dotnet build` and CI enforce exactly the same
# rules. The resharper_* keys apply the Kingsrook blank-line rules in Rider and
# ReSharper, which .NET's own formatter cannot express. Repository-specific
# adjustments are at the end, each with its reason.
root = true
[*]
charset = utf-8
end_of_line = crlf
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
# Kingsrook: always 3 spaces, never tabs.
indent_size = 3
tab_width = 3
# MSBuild, config, and workflow files use 2-space indentation.
[*.{csproj,props,targets,slnx,xml,json,yml,yaml}]
# YAML stays at 2: the YAML ecosystem (GitHub Actions, Dependabot) assumes it.
[*.{yml,yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
# Windows-only script formats require CRLF.
[*.{cmd,bat}]
end_of_line = crlf
[*.cs]
# System usings first, as the codebase already does (used by IDE "Sort Usings").
#### Formatting (IDE0055 makes every rule below a build error) ####
dotnet_diagnostic.IDE0055.severity = warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = one_less_than_current
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_space_after_cast = false
# Kingsrook: no space between a control keyword and its parenthesis: if(x), for(...), catch(...).
csharp_space_after_keywords_in_control_flow_statements = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_after_comma = true
csharp_space_before_comma = false
csharp_space_after_semicolon_in_for_statement = true
csharp_preserve_single_line_statements = false
csharp_preserve_single_line_blocks = true
# Braces on every control-flow block; the codebase is already 100% compliant.
#### Kingsrook blank lines and layout (Rider / ReSharper; .NET formatter cannot express) ####
resharper_blank_lines_around_method = 3
resharper_blank_lines_around_property = 3
resharper_blank_lines_around_auto_property = 1
resharper_blank_lines_around_type = 3
resharper_blank_lines_after_using_list = 2
resharper_keep_blank_lines_in_code = 1
resharper_keep_blank_lines_in_declarations = 3
resharper_brace_style = next_line
resharper_indent_size = 3
resharper_space_after_keywords_in_control_flow_statements = false
resharper_max_line_length = 999
resharper_wrap_before_first_method_call = true
resharper_wrap_chained_method_calls = chop_if_long
#### Usings and namespaces ####
# IDE0005 needs GenerateDocumentationFile=true to run during build (set in Directory.Build.props).
dotnet_diagnostic.IDE0005.severity = warning
csharp_using_directive_placement = outside_namespace:warning
csharp_style_namespace_declarations = file_scoped:warning
dotnet_diagnostic.IDE0161.severity = warning
#### var: only when the type is apparent from the right-hand side ####
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_var_elsewhere = false:warning
dotnet_diagnostic.IDE0007.severity = warning
dotnet_diagnostic.IDE0008.severity = warning
#### Braces, modifiers, readonly ####
csharp_prefer_braces = true:warning
dotnet_diagnostic.IDE0011.severity = warning
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
dotnet_diagnostic.IDE0040.severity = warning
dotnet_style_readonly_field = true:warning
dotnet_diagnostic.IDE0044.severity = warning
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
dotnet_diagnostic.IDE0036.severity = warning
# Libraries use file-scoped namespaces; the WinUI shell keeps the template's
# block namespaces, so this stays a suggestion rather than a build error.
csharp_style_namespace_declarations = file_scoped:suggestion
#### this. qualification: never ####
dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_property = false:warning
dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning
dotnet_diagnostic.IDE0003.severity = warning
# CA1716 warns that IActivityLog.Error collides with a VB keyword; this is a C#-only
# codebase and Error is the natural name for a log level.
dotnet_diagnostic.CA1716.severity = none
#### Language keywords over framework type names ####
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
dotnet_diagnostic.IDE0049.severity = warning
# CA1859 asks callers to replace IGitClient/IActivityLog parameters with concrete types
# for micro-performance; those interfaces are the codebase's deliberate test seams.
dotnet_diagnostic.CA1859.severity = none
#### Dead code and redundancy ####
dotnet_diagnostic.IDE0051.severity = warning # unused private member
dotnet_diagnostic.IDE0052.severity = warning # unread private member
dotnet_diagnostic.IDE0059.severity = warning # unnecessary assignment
dotnet_diagnostic.IDE0060.severity = warning # unused parameter
dotnet_diagnostic.IDE0035.severity = warning # unreachable code
dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast
dotnet_diagnostic.IDE0100.severity = warning # unnecessary equality operator
dotnet_diagnostic.IDE0080.severity = warning # unnecessary suppression operator
dotnet_diagnostic.IDE0079.severity = warning # unnecessary suppression
dotnet_code_quality_unused_parameters = all
# Naming: interfaces are I-prefixed; types and members are PascalCase.
#### Modern language features (suggestions: shown in IDE, not enforced) ####
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
#### Naming ####
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_begin_with_i.style = i_prefix_pascal_case
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
@@ -42,20 +144,114 @@ dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.i_prefix_pascal_case.required_prefix = I
dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case
dotnet_naming_rule.type_parameters_begin_with_t.symbols = type_parameters
dotnet_naming_rule.type_parameters_begin_with_t.style = t_prefix_pascal_case
dotnet_naming_rule.type_parameters_begin_with_t.severity = warning
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
dotnet_naming_style.t_prefix_pascal_case.required_prefix = T
dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case
dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members
dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case
dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
dotnet_naming_symbols.types_and_members.applicable_kinds = namespace, class, struct, enum, delegate, property, method, event
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields
dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel_case
dotnet_naming_rule.private_fields_are_underscore_camel.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_rule.private_static_fields_are_s_camel.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_are_s_camel.style = s_camel_case
dotnet_naming_rule.private_static_fields_are_s_camel.severity = warning
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_style.s_camel_case.required_prefix = s_
dotnet_naming_style.s_camel_case.capitalization = camel_case
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
dotnet_naming_rule.constants_are_pascal_case.severity = warning
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
#### Analyzer adjustments (each one justified) ####
# CA1716: flags members named like VB keywords (e.g. ILog.Error). KofTwentyTwo is C#-only.
dotnet_diagnostic.CA1716.severity = none
# MA0004 (ConfigureAwait): app code runs on UI threads where ConfigureAwait(false) is
# wrong by default; libraries opt back in with a [*.cs] override in their own folder.
dotnet_diagnostic.MA0004.severity = none
# MA0049 (type name matches namespace): conflicts with the AppName/AppName.Core layout.
dotnet_diagnostic.MA0049.severity = none
[**/{Generated,obj}/**.cs]
generated_code = true
[{tests,test}/**.cs]
# xUnit's Method_Scenario_Expectation names use underscores by convention.
dotnet_diagnostic.CA1707.severity = none
# Inline arrays keep test data at the assertion; each test runs once.
dotnet_diagnostic.CA1861.severity = none
# Test projects are not documented API.
dotnet_diagnostic.CS1591.severity = none
# MA0048 (one type per file): a test file groups the test classes and fakes of one feature.
dotnet_diagnostic.MA0048.severity = none
#### gclo-specific adjustments (docs/adr/0005: flat layout, test projects beside the libraries) ####
# MSBuild, solution, and JSON files keep the 2-space indentation their tooling writes.
[*.{csproj,props,targets,slnx,xml,json,runsettings}]
indent_size = 2
# XAML is formatted by the Visual Studio XAML editor at 4 spaces; dotnet format does
# not touch it, and reformatting the markup would churn every view for no reader benefit.
[*.xaml]
indent_size = 4
[gclo.Engine/**.cs]
# gclo.Engine is a published library: ConfigureAwait(false) is required there (K22-CODE-CS-13).
dotnet_diagnostic.MA0004.severity = warning
[gclo/**.cs]
# CA1822: x:Bind function targets cannot be static — the XAML compiler generates
# CA1822: x:Bind function targets cannot be static: the XAML compiler generates
# instance calls (this.Fn(...)) for them, which fails with CS0176.
dotnet_diagnostic.CA1822.severity = none
# IDE0051/IDE0052: XAML event handlers and x:Bind targets are referenced only from
# markup; the XAML compiler validates them, and the analyzer (which cannot see the
# markup outside a full build) would otherwise report, and dotnet format would
# DELETE, those handlers.
dotnet_diagnostic.IDE0051.severity = none
dotnet_diagnostic.IDE0052.severity = none
# IDE0060: XAML event handlers must keep the framework's (sender, args) signature even
# when a handler needs neither.
dotnet_diagnostic.IDE0060.severity = none
[gclo.Engine.Tests/**.cs]
# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention.
[{gclo.Engine.Tests,gclo.Cli.Tests,gclo.UiTests}/**.cs]
# The standard's test relaxations, keyed on this repository's test project folders
# (there is no tests/ directory; see docs/adr/0005).
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
dotnet_diagnostic.CS1591.severity = none
dotnet_diagnostic.MA0048.severity = none
[*.cs]
# Namespaces are the product name, `gclo` (and the published package namespace
# `gclo.Engine`, which cannot be renamed in a patch release); the PascalCase rule
# therefore covers types and members here, not namespaces.
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
# CA1859 asks callers to replace IGitClient/IActivityLog parameters with concrete types
# for micro-performance; those interfaces are the codebase's deliberate test seams.
dotnet_diagnostic.CA1859.severity = none
# MA0051 (method length, K22-CODE-16 "flags extremes"): the sync loop, the clone-with-
# recovery path, and the paginated listers are orchestration methods whose steps read
# best in sequence; splitting them for the count alone would scatter the flow. The
# threshold is raised to flag only genuine extremes; new code aims for ~50 lines.
MA0051.maximum_lines_per_method = 160
MA0051.maximum_statements_per_method = 100
MA0051.skip_local_functions = true
+9 -4
View File
@@ -1,4 +1,9 @@
# Text files are stored LF and checked out CRLF on every machine, matching
# .editorconfig (end_of_line = crlf) so the dotnet format gate behaves the same
# at developers' desks and on CI runners regardless of their git config.
* text=auto eol=crlf
# Text is stored and checked out as LF everywhere (K22-CODE-07), matching
# .editorconfig (end_of_line = lf) so the dotnet format gate behaves the same at
# developers' desks and on CI runners regardless of their git config. Windows-only
# script formats that require CRLF are the exception.
* text=auto eol=lf
*.cmd text eol=crlf
*.bat text eol=crlf
*.png binary
*.ico binary
+240 -136
View File
@@ -1,194 +1,298 @@
#Requires -Version 7.4
<#
.SYNOPSIS
Bumps every top-level PackageReference in the solution to its latest stable
version and writes a Markdown report of what changed and what was held back.
Bumps every centrally managed NuGet package to its latest stable version and writes
a Markdown report of what changed and what was held back.
.DESCRIPTION
Replaces Dependabot's NuGet ecosystem for this repository. Dependabot's NuGet
updater mis-evaluates Windows-versioned target frameworks such as
net10.0-windows10.0.19041.0 (dependabot/dependabot-core#13923): it declares every
newer package "not compatible" with the WinUI project and, because it updates a
package consistently across all projects, that blocks the test projects too.
`dotnet list package --outdated` uses the real NuGet/MSBuild evaluation and has no
such problem, so this script drives the weekly update PR from it instead.
Replaces Dependabot's NuGet ecosystem for this repository (docs/adr/0004).
Dependabot's NuGet updater mis-evaluates Windows-versioned target frameworks such
as net10.0-windows10.0.19041.0 (dependabot/dependabot-core#13923) and declares
every newer package "not compatible". `dotnet list package --outdated` uses the
real NuGet/MSBuild evaluation, so this script drives the weekly update PR from it.
Policy, mirroring the Dependabot config it replaces:
Policy, mirroring the Dependabot config it replaces (K22-DEP-20):
- minor and patch bumps are applied;
- major bumps are reported but NOT applied unless -IncludeMajor is given, so
they get a deliberate review;
- prerelease versions are never applied to a package that is on a stable one;
- a version published fewer than -CooldownDays days ago is held back
(K22-DEP-20), so the ecosystem has time to catch a bad release first.
- major bumps are reported but NOT applied unless -IncludeMajor is given;
- prerelease versions are never applied to a package on a stable one;
- a version published fewer than -CooldownDays days ago is held back.
Versions are edited in place in each .csproj (the exact Include/Version pair),
preserving the file's encoding and line endings.
Versions live in Directory.Packages.props (central package management) and are
edited in place there, preserving the file's encoding and line endings.
.PARAMETER Solution
Solution to evaluate. Default: gclo.slnx.
Solution to evaluate. Default: gclo.slnx.
.PARAMETER PackagesProps
The central package file to edit. Default: Directory.Packages.props.
.PARAMETER ReportPath
Where to write the Markdown report. Default: nuget-update-report.md.
Where to write the Markdown report. Default: nuget-update-report.md.
.PARAMETER IncludeMajor
Also apply major-version bumps.
Also apply major-version bumps.
.PARAMETER CooldownDays
Hold back versions published fewer than this many days ago. Default: 7.
When nuget.org cannot be asked for the publish date, the version is held.
Hold back versions published fewer than this many days ago. Default: 7. When
nuget.org cannot be asked for the publish date, the version is held.
.OUTPUTS
Writes `changed=true|false` and `report=<path>` to $env:GITHUB_OUTPUT when set.
Exit code 0 on success (whether or not anything changed), non-zero on error.
Writes `changed=true|false` and `report=<path>` to $env:GITHUB_OUTPUT when set.
Exit code 0 on success (whether or not anything changed), non-zero on error.
#>
[CmdletBinding()]
param(
[string]$Solution = 'gclo.slnx',
[string]$ReportPath = 'nuget-update-report.md',
[switch]$IncludeMajor,
[int]$CooldownDays = 7
[ValidateNotNullOrEmpty()]
[string] $Solution = 'gclo.slnx',
[ValidateNotNullOrEmpty()]
[string] $PackagesProps = 'Directory.Packages.props',
[ValidateNotNullOrEmpty()]
[string] $ReportPath = 'nuget-update-report.md',
[switch] $IncludeMajor,
[ValidateRange(0, 365)]
[int] $CooldownDays = 7
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# [System.IO.File] resolves relative paths against the process working directory,
# which is not necessarily PowerShell's current location; anchor it explicitly.
$ReportPath = [System.IO.Path]::GetFullPath($ReportPath, (Get-Location).Path)
################################################################################
## [System.IO.File] resolves relative paths against the process working ##
## directory, which is not necessarily PowerShell's location; anchor them. ##
################################################################################
$ReportPath = [System.IO.Path]::GetFullPath($ReportPath, $PWD.Path)
$PackagesProps = [System.IO.Path]::GetFullPath($PackagesProps, $PWD.Path)
function Get-NumericVersion([string]$version) {
# NuGet versions may carry a prerelease suffix (1.2.3-beta.1) and may have four
# numeric parts (10.0.28000.2705); [version] handles the latter but not the former.
$core = ($version -split '[-+]', 2)[0]
return [version]$core
<#
.SYNOPSIS
Reads a property of a parsed JSON object, or $null when the object does not have
it; `dotnet list` omits members that would be empty.
#>
function Get-PropertyValue
{
param(
[Parameter(Mandatory)] [object] $Object,
[Parameter(Mandatory)] [string] $Name
)
$property = $Object.PSObject.Properties[$Name]
if($null -eq $property)
{
return $null
}
return $property.Value
}
function Test-Prerelease([string]$version) {
return $version.Contains('-')
<#
.SYNOPSIS
The numeric core of a NuGet version. Versions may carry a prerelease suffix
(1.2.3-beta.1) and may have four numeric parts (10.0.28000.2705); [version]
handles the latter but not the former.
#>
function Get-NumericVersion
{
param([Parameter(Mandatory)] [string] $Version)
$core = ($Version -split '[-+]', 2)[0]
return [version] $core
}
function Get-PublishedDate([string]$id, [string]$version) {
# nuget.org's registration leaf carries the publish timestamp of one version.
$url = "https://api.nuget.org/v3/registration5-gz-semver2/$($id.ToLowerInvariant())/$($version.ToLowerInvariant()).json"
try {
$leaf = Invoke-RestMethod -Uri $url -TimeoutSec 30
return [DateTimeOffset]::Parse($leaf.published, [System.Globalization.CultureInfo]::InvariantCulture)
}
catch {
return $null
}
<#
.SYNOPSIS
True when a NuGet version string carries a prerelease suffix.
#>
function Test-Prerelease
{
param([Parameter(Mandatory)] [string] $Version)
return $Version.Contains('-')
}
Write-Host "Restoring $Solution"
<#
.SYNOPSIS
The publish timestamp of one package version from nuget.org's registration leaf,
or $null when it cannot be read (the caller then holds the version back).
#>
function Get-PublishedDate
{
param(
[Parameter(Mandatory)] [string] $Id,
[Parameter(Mandatory)] [string] $Version
)
$url = "https://api.nuget.org/v3/registration5-gz-semver2/$($Id.ToLowerInvariant())/$($Version.ToLowerInvariant()).json"
try
{
$leaf = Invoke-RestMethod -Uri $url -TimeoutSec 30
return [DateTimeOffset]::Parse($leaf.published, [System.Globalization.CultureInfo]::InvariantCulture)
}
catch
{
return $null
}
}
<#
.SYNOPSIS
Writes a progress line to the information stream (visible in the workflow log).
#>
function Write-Progress-Line
{
param([Parameter(Mandatory)] [string] $Message)
Write-Information -MessageData $Message -InformationAction Continue
}
Write-Progress-Line "Restoring $Solution"
dotnet restore $Solution | Out-Null
if ($LASTEXITCODE -ne 0) { throw "dotnet restore failed with exit code $LASTEXITCODE." }
if($LASTEXITCODE -ne 0)
{
throw "dotnet restore failed with exit code $LASTEXITCODE."
}
Write-Host "Querying outdated packages"
Write-Progress-Line 'Querying outdated packages'
$raw = dotnet list $Solution package --outdated --format json
if ($LASTEXITCODE -ne 0) { throw "dotnet list package --outdated failed with exit code $LASTEXITCODE." }
if($LASTEXITCODE -ne 0)
{
throw "dotnet list package --outdated failed with exit code $LASTEXITCODE."
}
$listing = ($raw -join "`n") | ConvertFrom-Json
# One row per (project, package): the same package can be listed under several
# target frameworks of one project, and we edit the csproj once.
################################################################################
## One row per package: versions are central, so a package listed under ##
## several projects or frameworks is still one edit. ##
################################################################################
$rows = @{}
# Projects with nothing outdated have no 'frameworks' member at all, and a framework
# with nothing outdated has no 'topLevelPackages'; @($null) still yields one $null
# iteration, hence the Where-Object filters.
foreach ($project in @($listing.projects | Where-Object { $_ })) {
foreach ($framework in @($project.frameworks | Where-Object { $_ })) {
foreach ($package in @($framework.topLevelPackages | Where-Object { $_ })) {
$key = "$($project.path)|$($package.id)"
if (-not $rows.ContainsKey($key)) {
$rows[$key] = [pscustomobject]@{
Project = $project.path
Id = $package.id
Requested = $package.requestedVersion
Latest = $package.latestVersion
}
}
}
}
foreach($project in @((Get-PropertyValue $listing 'projects') | Where-Object { $_ }))
{
foreach($framework in @((Get-PropertyValue $project 'frameworks') | Where-Object { $_ }))
{
foreach($package in @((Get-PropertyValue $framework 'topLevelPackages') | Where-Object { $_ }))
{
if(-not $rows.ContainsKey($package.id))
{
$rows[$package.id] = [pscustomobject]@{ Id = $package.id; Requested = $package.requestedVersion; Latest = $package.latestVersion }
}
}
}
}
$applied = New-Object System.Collections.Generic.List[object]
$held = New-Object System.Collections.Generic.List[object]
$applied = [System.Collections.Generic.List[object]]::new()
$held = [System.Collections.Generic.List[object]]::new()
foreach ($row in ($rows.Values | Sort-Object Project, Id)) {
$current = Get-NumericVersion $row.Requested
$latest = Get-NumericVersion $row.Latest
$projectName = [System.IO.Path]::GetFileName($row.Project)
$bytes = [System.IO.File]::ReadAllBytes($PackagesProps)
$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
if($hasBom)
{
$text = $text.Substring(1)
}
$original = $text
$reason = $null
if ((Test-Prerelease $row.Latest) -and -not (Test-Prerelease $row.Requested)) {
$reason = 'latest is a prerelease'
}
elseif ($latest.Major -ne $current.Major -and -not $IncludeMajor) {
$reason = 'major version bump: review manually (or run with -IncludeMajor)'
}
elseif ($latest -le $current) {
$reason = 'not newer than the requested version'
}
elseif ($CooldownDays -gt 0) {
$published = Get-PublishedDate $row.Id $row.Latest
if ($null -eq $published) {
$reason = "publish date unavailable from nuget.org; held for the $CooldownDays-day cooldown"
}
elseif ($published -gt [DateTimeOffset]::UtcNow.AddDays(-$CooldownDays)) {
$age = [int][math]::Floor(([DateTimeOffset]::UtcNow - $published).TotalDays)
$reason = "published $age day(s) ago; waits out the $CooldownDays-day cooldown"
}
}
foreach($row in ($rows.Values | Sort-Object Id))
{
$current = Get-NumericVersion -Version $row.Requested
$latest = Get-NumericVersion -Version $row.Latest
if ($reason) {
$held.Add([pscustomobject]@{ Project = $projectName; Id = $row.Id; From = $row.Requested; To = $row.Latest; Reason = $reason })
continue
}
$reason = $null
if((Test-Prerelease -Version $row.Latest) -and -not (Test-Prerelease -Version $row.Requested))
{
$reason = 'latest is a prerelease'
}
elseif($latest.Major -ne $current.Major -and -not $IncludeMajor)
{
$reason = 'major version bump: review manually (or run with -IncludeMajor)'
}
elseif($latest -le $current)
{
$reason = 'not newer than the requested version'
}
elseif($CooldownDays -gt 0)
{
$published = Get-PublishedDate -Id $row.Id -Version $row.Latest
if($null -eq $published)
{
$reason = "publish date unavailable from nuget.org; held for the $CooldownDays-day cooldown"
}
elseif($published -gt [DateTimeOffset]::UtcNow.AddDays(-$CooldownDays))
{
$age = [int] [math]::Floor(([DateTimeOffset]::UtcNow - $published).TotalDays)
$reason = "published $age day(s) ago; waits out the $CooldownDays-day cooldown"
}
}
# Exact textual edit of the one PackageReference, so nothing else in the file moves.
$bytes = [System.IO.File]::ReadAllBytes($row.Project)
$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
if ($hasBom) { $text = $text.Substring(1) }
if($reason)
{
$held.Add([pscustomobject]@{ Id = $row.Id; From = $row.Requested; To = $row.Latest; Reason = $reason })
continue
}
$pattern = '(<PackageReference\s+Include="' + [regex]::Escape($row.Id) + '"\s+Version=")' + [regex]::Escape($row.Requested) + '(")'
$updated = [regex]::Replace($text, $pattern, ('${1}' + $row.Latest + '${2}'), [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($updated -eq $text) {
$held.Add([pscustomobject]@{ Project = $projectName; Id = $row.Id; From = $row.Requested; To = $row.Latest; Reason = 'PackageReference not found in the project file (central/variable version?)' })
continue
}
################################################################################
## Exact textual edit of the one PackageVersion, so nothing else moves. ##
################################################################################
$pattern = '(<PackageVersion\s+Include="' + [regex]::Escape($row.Id) + '"\s+Version=")' + [regex]::Escape($row.Requested) + '(")'
$updated = [regex]::Replace($text, $pattern, ('${1}' + $row.Latest + '${2}'), [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if($updated -eq $text)
{
$held.Add([pscustomobject]@{ Id = $row.Id; From = $row.Requested; To = $row.Latest; Reason = "PackageVersion not found in $([System.IO.Path]::GetFileName($PackagesProps))" })
continue
}
[System.IO.File]::WriteAllText($row.Project, $updated, (New-Object System.Text.UTF8Encoding($hasBom)))
$applied.Add([pscustomobject]@{ Project = $projectName; Id = $row.Id; From = $row.Requested; To = $row.Latest })
Write-Host " $projectName : $($row.Id) $($row.Requested) -> $($row.Latest)"
$text = $updated
$applied.Add([pscustomobject]@{ Id = $row.Id; From = $row.Requested; To = $row.Latest })
Write-Progress-Line " $($row.Id) $($row.Requested) -> $($row.Latest)"
}
# ---------------------------------------------------------------- report
$lines = New-Object System.Collections.Generic.List[string]
if($text -ne $original)
{
[System.IO.File]::WriteAllText($PackagesProps, $text, [System.Text.UTF8Encoding]::new($hasBom))
}
################################################################################
## Report ##
################################################################################
$lines = [System.Collections.Generic.List[string]]::new()
$lines.Add('## NuGet package updates')
$lines.Add('')
if ($applied.Count -eq 0) {
$lines.Add('No minor or patch updates were available.')
if($applied.Count -eq 0)
{
$lines.Add('No minor or patch updates were available.')
}
else {
$lines.Add('| Project | Package | From | To |')
$lines.Add('| --- | --- | --- | --- |')
foreach ($a in $applied) { $lines.Add("| $($a.Project) | $($a.Id) | $($a.From) | $($a.To) |") }
else
{
$lines.Add('| Package | From | To |')
$lines.Add('| --- | --- | --- |')
foreach($a in $applied)
{
$lines.Add("| $($a.Id) | $($a.From) | $($a.To) |")
}
}
if ($held.Count -gt 0) {
$lines.Add('')
$lines.Add('### Held back (not applied)')
$lines.Add('')
$lines.Add('| Project | Package | Current | Latest | Why |')
$lines.Add('| --- | --- | --- | --- | --- |')
foreach ($h in $held) { $lines.Add("| $($h.Project) | $($h.Id) | $($h.From) | $($h.To) | $($h.Reason) |") }
if($held.Count -gt 0)
{
$lines.Add('')
$lines.Add('### Held back (not applied)')
$lines.Add('')
$lines.Add('| Package | Current | Latest | Why |')
$lines.Add('| --- | --- | --- | --- |')
foreach($h in $held)
{
$lines.Add("| $($h.Id) | $($h.From) | $($h.To) | $($h.Reason) |")
}
}
$lines.Add('')
$lines.Add("_Generated by ``.github/scripts/Update-NuGetPackages.ps1`` from ``dotnet list package --outdated`` on $(Get-Date -Format 'yyyy-MM-dd')._")
[System.IO.File]::WriteAllText($ReportPath, ($lines -join "`n") + "`n", (New-Object System.Text.UTF8Encoding($false)))
[System.IO.File]::WriteAllText($ReportPath, ($lines -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
Write-Host ''
Write-Host (Get-Content $ReportPath -Raw)
Write-Progress-Line ''
Write-Progress-Line (Get-Content $ReportPath -Raw)
$changed = $applied.Count -gt 0
if ($env:GITHUB_OUTPUT) {
Add-Content -Path $env:GITHUB_OUTPUT -Value "changed=$($changed.ToString().ToLowerInvariant())"
Add-Content -Path $env:GITHUB_OUTPUT -Value "report=$ReportPath"
if($env:GITHUB_OUTPUT)
{
Add-Content -Path $env:GITHUB_OUTPUT -Value "changed=$($changed.ToString().ToLowerInvariant())"
Add-Content -Path $env:GITHUB_OUTPUT -Value "report=$ReportPath"
}
+72 -40
View File
@@ -1,80 +1,112 @@
#Requires -Version 7.4
<#
.SYNOPSIS
Points the Scoop manifest (bucket/gclo.json) and the Chocolatey package
(packaging/chocolatey) at a stable release's CLI zip.
Points the Scoop manifest (bucket/gclo.json) and the Chocolatey package
(packaging/chocolatey) at a stable release's CLI zip.
.DESCRIPTION
Both package managers install the same asset, gclo-cli-win-x64.zip, by URL and
SHA-256. This script rewrites the version, URL, checksum, and release-notes
link in all three files from one source of truth: the version and the zip's
hash (computed from -CliZipPath, or taken from -Sha256). The release workflow
runs it on every stable tag before `choco pack` and opens a PR with the result
so the committed manifests always describe the latest stable release.
Both package managers install the same asset, gclo-cli-win-x64.zip, by URL and
SHA-256. This script rewrites the version, URL, checksum, and release-notes link
in all three files from one source of truth: the version and the zip's hash
(computed from -CliZipPath, or taken from -Sha256). The release workflow runs it
on every stable tag before `choco pack` and opens a PR with the result so the
committed manifests always describe the latest stable release.
.PARAMETER Version
The stable semantic version, e.g. 1.2.3 (no leading 'v').
The stable semantic version, e.g. 1.2.3 (no leading 'v').
.PARAMETER CliZipPath
Path to the built gclo-cli-win-x64.zip to hash. Either this or -Sha256.
Path to the built gclo-cli-win-x64.zip to hash. Either this or -Sha256.
.PARAMETER Sha256
The zip's SHA-256 (lowercase hex) when it is already known.
The zip's SHA-256 (lowercase hex) when it is already known.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$Version,
[string]$CliZipPath,
[string]$Sha256
[Parameter(Mandatory)]
[ValidatePattern('^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$')]
[string] $Version,
[string] $CliZipPath,
[string] $Sha256
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($Version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw "Version '$Version' is not a stable semantic version (prerelease builds are not published to package managers)."
<#
.SYNOPSIS
Overwrites a text file keeping its encoding (BOM or not) as it was.
#>
function Write-FileText
{
param(
[Parameter(Mandatory)] [string] $Path,
[Parameter(Mandatory)] [string] $Text
)
$bytes = [System.IO.File]::ReadAllBytes($Path)
$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
[System.IO.File]::WriteAllText($Path, $Text, [System.Text.UTF8Encoding]::new($hasBom))
}
if (-not $Sha256) {
if (-not $CliZipPath) { throw 'Give -CliZipPath or -Sha256.' }
$Sha256 = (Get-FileHash -LiteralPath $CliZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
<#
.SYNOPSIS
Writes a progress line to the information stream (visible in the workflow log).
#>
function Write-Progress-Line
{
param([Parameter(Mandatory)] [string] $Message)
Write-Information -MessageData $Message -InformationAction Continue
}
if ($Sha256 -notmatch '^[0-9a-f]{64}$') {
throw "'$Sha256' is not a lowercase SHA-256."
if(-not $Sha256)
{
if(-not $CliZipPath)
{
throw 'Give -CliZipPath or -Sha256.'
}
$Sha256 = (Get-FileHash -LiteralPath $CliZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
}
if($Sha256 -notmatch '^[0-9a-f]{64}$')
{
throw "'$Sha256' is not a lowercase SHA-256."
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$url = "https://github.com/KofTwentyTwo/gclo/releases/download/v$Version/gclo-cli-win-x64.zip"
function Set-Content-Preserving([string]$path, [string]$text) {
# Keep the file's encoding (no BOM) and line endings as they are.
$bytes = [System.IO.File]::ReadAllBytes($path)
$hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF
[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($hasBom)))
}
# ---- Scoop: a real JSON edit, so the file stays valid whatever its layout.
################################################################################
## Scoop: a real JSON edit, so the file stays valid whatever its layout. ##
################################################################################
$scoopPath = Join-Path $repoRoot 'bucket\gclo.json'
$scoop = Get-Content -LiteralPath $scoopPath -Raw | ConvertFrom-Json
$scoop.version = $Version
$scoop.architecture.'64bit'.url = $url
$scoop.architecture.'64bit'.hash = $Sha256
$scoopJson = ($scoop | ConvertTo-Json -Depth 10) -replace '\\u0026', '&'
# ConvertTo-Json indents with four spaces and escapes '$'-free text; Scoop reads either way.
Set-Content-Preserving $scoopPath ($scoopJson + "`n")
Write-Host "Scoop manifest -> $Version"
Write-FileText -Path $scoopPath -Text ($scoopJson + "`n")
Write-Progress-Line "Scoop manifest -> $Version"
# ---- Chocolatey: nuspec version + release notes link, install script URL + checksum.
################################################################################
## Chocolatey: nuspec version + release notes link, install script URL + hash. ##
################################################################################
$nuspecPath = Join-Path $repoRoot 'packaging\chocolatey\gclo.nuspec'
$nuspec = Get-Content -LiteralPath $nuspecPath -Raw
$nuspec = $nuspec -replace '<version>[^<]+</version>', "<version>$Version</version>"
$nuspec = $nuspec -replace '<releaseNotes>[^<]+</releaseNotes>', "<releaseNotes>https://github.com/KofTwentyTwo/gclo/releases/tag/v$Version</releaseNotes>"
Set-Content-Preserving $nuspecPath $nuspec
Write-FileText -Path $nuspecPath -Text $nuspec
$installPath = Join-Path $repoRoot 'packaging\chocolatey\tools\chocolateyinstall.ps1'
$install = Get-Content -LiteralPath $installPath -Raw
$install = $install -replace "url64bit\s*=\s*'[^']+'", "url64bit = '$url'"
$install = $install -replace "checksum64\s*=\s*'[^']+'", "checksum64 = '$Sha256'"
Set-Content-Preserving $installPath $install
Write-Host "Chocolatey package -> $Version"
$install = $install -replace "url64bit\s*=\s*'[^']+'", "url64bit = '$url'"
$install = $install -replace "checksum64\s*=\s*'[^']+'", "checksum64 = '$Sha256'"
Write-FileText -Path $installPath -Text $install
Write-Progress-Line "Chocolatey package -> $Version"
if ($env:GITHUB_OUTPUT) {
Add-Content -Path $env:GITHUB_OUTPUT -Value "sha256=$Sha256"
if($env:GITHUB_OUTPUT)
{
Add-Content -Path $env:GITHUB_OUTPUT -Value "sha256=$Sha256"
}
+19 -3
View File
@@ -174,7 +174,23 @@ jobs:
- name: Restore
run: dotnet restore gclo.slnx --locked-mode
# Kept at --severity error so this gates real issues (analyzer errors and
# error-severity style rules), not whitespace preferences.
# The Kingsrook layout and every warning-severity style rule (K22-CODE-CS-02):
# the same command CONTRIBUTING.md documents for the desk.
- name: Verify formatting
run: dotnet format gclo.slnx --verify-no-changes --severity error
run: dotnet format gclo.slnx --verify-no-changes --severity warn --no-restore
# PowerShell scripts follow the same layout (K22-CODE-SH-05).
- name: PSScriptAnalyzer
shell: pwsh
run: |
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
# One file per call: PSScriptAnalyzer 1.25.0 intermittently fails with
# "Object reference not set to an instance of an object" when it is
# handed a directory, and never when it is handed a single script.
$settings = (Resolve-Path ./PSScriptAnalyzerSettings.psd1).Path
$scripts = Get-ChildItem -Path .github/scripts, packaging/chocolatey/tools -Recurse -Include *.ps1, *.psm1, *.psd1
$findings = @()
foreach ($script in $scripts) { $findings += @(Invoke-ScriptAnalyzer -Path $script.FullName -Settings $settings) }
foreach ($f in $findings) { Write-Output "::error file=$($f.ScriptPath),line=$($f.Line)::$($f.RuleName): $($f.Message)" }
if ($findings.Count -gt 0) { exit 1 }
Write-Output 'PSScriptAnalyzer: no findings.'
+40 -7
View File
@@ -1,20 +1,53 @@
<Project>
<!-- Local quality gate: every project builds with warnings as errors and the
recommended analyzers + .editorconfig code style enforced, so problems fail
at the desk exactly like they fail in CI. -->
<!-- KofTwentyTwo .NET quality gate (standards: K22-CODE-CS), from
KofTwentyTwo/standards configs/dotnet/Directory.Build.props. Every rule fails
`dotnet build` locally exactly as it fails in CI. -->
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<!-- Warnings are errors; analyzers and .editorconfig style run in every build. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- Required for IDE0005 (unused usings) to run during build. CS1591 (missing XML
doc) stays on for packable projects and is silenced for everything else below. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Reproducible builds with source-linked symbols. -->
<Deterministic>true</Deterministic>
<!-- packages.lock.json per project: pins the restore graph (NuGetAudit sees
exactly what ships) and keys the CI restore cache. CI restores in locked
mode; after changing a PackageReference run
dotnet restore gclo.slnx with the force-evaluate switch and commit the lock files. -->
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- Supply chain: audit direct AND transitive packages; pin the whole graph. -->
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- CI: normalized paths, and restore must match the committed lock files exactly. -->
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(CI)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<RestoreLockedMode>true</RestoreLockedMode>
</PropertyGroup>
<!-- Only shipped libraries must document their public API. -->
<PropertyGroup Condition="'$(IsPackable)' != 'true'">
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Repository-wide analyzers; versions live in Directory.Packages.props. -->
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
</ItemGroup>
<!-- Single version source. Local builds are <prefix>-dev; release builds pass an
explicit -p:Version=<tag-version>, which overrides both parts. The SDK's
built-in SourceLink appends the git commit as "+<sha>" build metadata on
+33
View File
@@ -0,0 +1,33 @@
<Project>
<!-- Central package management (K22-CODE-CS-08). Every package version in the
repository lives here; projects reference packages without a Version attribute.
.github/workflows/nuget-updates.yml bumps this file weekly (docs/adr/0004). -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup Label="Analyzers">
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
</ItemGroup>
<ItemGroup Label="App and libraries">
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="LibGit2Sharp" Version="0.32.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="Velopack" Version="1.2.161" />
<PackageVersion Include="WinUI.TableView" Version="1.5.0" />
</ItemGroup>
<ItemGroup Label="Tests">
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="FlaUI.UIA3" Version="5.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
</Project>
+361 -282
View File
@@ -1,353 +1,432 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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;
private readonly string _dir =
Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N"));
public void Dispose()
{
try
{
Directory.Delete(_dir, recursive: true);
}
catch (IOException)
{
}
}
private readonly InMemoryVault _vault = new();
private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog());
private AccountsStore? _store;
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();
public void Dispose()
{
try
{
Directory.Delete(_dir, recursive: true);
}
catch(IOException)
{
}
}
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()));
private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, 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);
}
private Func<IActivityLog, (AccountsStore, ITokenVault)> Open => _ => (Store, _vault);
[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)
}
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 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);
// The fields a script needs to reason about a run.
Assert.Contains("\"id\":\"" + Store.FindByName("kof")!.Id.ToString("N") + "\"", console.Out);
Assert.Contains("\"createOrgSubfolder\":false", console.Out);
Assert.Contains("\"maxConcurrency\":8", console.Out);
Assert.Contains("\"lastSyncSummary\":null", console.Out);
}
[Fact]
public void Help_PrintsUsage()
{
using var console = new ConsoleCapture();
[Fact]
public void ExplicitListSubcommand_IsTheDefault()
{
Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(["--help"], Open, new NullLog());
Assert.Equal(0, AccountsCommand.Run(["list"], Open, new NullLog()));
Assert.Contains("kof", console.Out);
}
Assert.Equal(0, code);
Assert.Contains("Usage: gclo accounts", console.Out);
}
[Fact]
public void UnknownSubcommand_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["frobnicate"], Open, new NullLog()));
// ---------------------------------------------------------------- add
private const string EnvVar = "GCLO_ACCOUNTS_TEST_TOKEN";
[Fact]
public void UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["--nope"], Open, new NullLog()));
private static IDisposable TokenEnv(string value)
{
Environment.SetEnvironmentVariable(EnvVar, value);
return new EnvScope();
}
private sealed class EnvScope : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
}
[Fact]
public void Add_CreatesTheAccount_AndStoresTheToken()
{
using var _ = TokenEnv("ghp_added");
using var console = new ConsoleCapture();
[Fact]
public void NoAccounts_WritesHintToStderr_AndReturnsZero()
{
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--parallel", "12",
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);
// The fields a script needs to reason about a run.
Assert.Contains("\"id\":\"" + Store.FindByName("kof")!.Id.ToString("N") + "\"", console.Out);
Assert.Contains("\"createOrgSubfolder\":false", console.Out);
Assert.Contains("\"maxConcurrency\":8", console.Out);
Assert.Contains("\"lastSyncSummary\":null", console.Out);
}
[Fact]
public void ExplicitListSubcommand_IsTheDefault()
{
Seed("kof", "KofTwentyTwo", @"R:\repos\kof");
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["list"], Open, new NullLog()));
Assert.Contains("kof", console.Out);
}
[Fact]
public void UnknownSubcommand_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["frobnicate"], Open, new NullLog()));
// ---------------------------------------------------------------- add
private const string EnvVar = "GCLO_ACCOUNTS_TEST_TOKEN";
private static IDisposable TokenEnv(string value)
{
Environment.SetEnvironmentVariable(EnvVar, value);
return new EnvScope();
}
private sealed class EnvScope : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
}
[Fact]
public void Add_CreatesTheAccount_AndStoresTheToken()
{
using IDisposable _ = TokenEnv("ghp_added");
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--parallel", "12",
"--org-subfolder", "--description", "day job", "--token-env", EnvVar],
Open, new NullLog());
Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work' added.", console.Out);
Account saved = Assert.Single(Store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\src", saved.TargetRoot);
Assert.Equal(12, saved.MaxConcurrency);
Assert.True(saved.CreateOrgSubfolder);
Assert.Equal("day job", saved.Description);
Assert.Equal("ghp_added", _vault.TryRetrieve(saved.Id));
}
Assert.Equal(0, code);
Assert.Contains("Account 'work' added.", console.Out);
Account saved = Assert.Single(Store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\src", saved.TargetRoot);
Assert.Equal(12, saved.MaxConcurrency);
Assert.True(saved.CreateOrgSubfolder);
Assert.Equal("day job", saved.Description);
Assert.Equal("ghp_added", _vault.TryRetrieve(saved.Id));
}
[Fact]
public void Add_ReadsTheTokenFromStdin()
{
using var console = new ConsoleCapture();
using var stdin = new StdinRedirect("ghp_piped\n");
int code = AccountsCommand.Run(
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--token-stdin"], Open, new NullLog());
Assert.Equal(0, code);
Assert.Equal("ghp_piped", _vault.TryRetrieve(Store.FindByName("work")!.Id));
}
[Fact]
public void Add_ReadsTheTokenFromStdin()
{
using var console = new ConsoleCapture();
using var stdin = new StdinRedirect("ghp_piped\n");
[Theory]
[InlineData("--org", "acme", "--target", @"C:\src")]
[InlineData("--name", "work", "--target", @"C:\src")]
[InlineData("--name", "work", "--org", "acme")]
public void Add_MissingRequiredOption_IsAUsageError(params string[] options)
{
using var _ = TokenEnv("ghp_x");
Assert.Throws<CliUsageException>(
() => AccountsCommand.Run(["add", .. options, "--token-env", EnvVar], Open, new NullLog()));
}
int code = AccountsCommand.Run(
["add", "--name", "work", "--org", "acme", "--target", @"C:\src", "--token-stdin"], Open, new NullLog());
[Fact]
public void Add_DuplicateName_IsAFatalError()
{
Seed("work", "acme", @"C:\a");
using var _ = TokenEnv("ghp_x");
Assert.Equal(0, code);
Assert.Equal("ghp_piped", _vault.TryRetrieve(Store.FindByName("work")!.Id));
}
var ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["add", "--name", "WORK", "--org", "other", "--target", @"C:\b", "--token-env", EnvVar], Open, new NullLog()));
Assert.Contains("already exists", ex.Message);
Assert.Single(Store.GetAll());
}
[Fact]
public void Add_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
[Theory]
[InlineData("--org", "acme", "--target", @"C:\src")]
[InlineData("--name", "work", "--target", @"C:\src")]
[InlineData("--name", "work", "--org", "acme")]
public void Add_MissingRequiredOption_IsAUsageError(params string[] options)
{
using IDisposable _ = TokenEnv("ghp_x");
Assert.Throws<CliUsageException>(
() => AccountsCommand.Run(["add", .. options, "--token-env", EnvVar], Open, new NullLog()));
}
[Fact]
public void Add_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["add", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts add", console.Out);
}
// ---------------------------------------------------------------- edit
[Fact]
public void Edit_ReplacesOnlyTheGivenSettings_AndLeavesTheTokenAlone()
{
Seed("work", "acme", @"C:\a");
Account before = Store.FindByName("work")!;
using var console = new ConsoleCapture();
[Fact]
public void Add_DuplicateName_IsAFatalError()
{
Seed("work", "acme", @"C:\a");
using IDisposable _ = TokenEnv("ghp_x");
int code = AccountsCommand.Run(
["edit", "--name", "work", "--org", "acme-labs", "--parallel", "3", "--org-subfolder", "--description", "moved"],
Open, new NullLog());
CliErrorException ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["add", "--name", "WORK", "--org", "other", "--target", @"C:\b", "--token-env", EnvVar], Open, new NullLog()));
Assert.Equal(0, code);
Assert.Contains("Account 'work' updated.", console.Out);
Account after = Store.FindByName("work")!;
Assert.Equal(before.Id, after.Id);
Assert.Equal("acme-labs", after.Organization);
Assert.Equal(@"C:\a", after.TargetRoot); // untouched
Assert.Equal(3, after.MaxConcurrency);
Assert.True(after.CreateOrgSubfolder);
Assert.Equal("moved", after.Description);
Assert.Equal("tok-work", _vault.TryRetrieve(after.Id)); // untouched
}
Assert.Contains("already exists", ex.Message);
Assert.Single(Store.GetAll());
}
[Fact]
public void Edit_RenameTargetAndNoOrgSubfolder_AndTokenReplacement()
{
Seed("work", "acme", @"C:\a");
Store.Save(Store.FindByName("work")! with { CreateOrgSubfolder = true }, null);
using var _ = TokenEnv("ghp_new");
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["edit", "--name", "work", "--rename", "work2", "--target", @"D:\b", "--no-org-subfolder", "--token-env", EnvVar],
Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work2' updated (token replaced).", console.Out);
Assert.Null(Store.FindByName("work"));
Account after = Store.FindByName("work2")!;
Assert.Equal(@"D:\b", after.TargetRoot);
Assert.False(after.CreateOrgSubfolder);
Assert.Equal("ghp_new", _vault.TryRetrieve(after.Id));
}
[Fact]
public void Add_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
[Fact]
public void Edit_WithNothingToChange_IsAUsageError()
{
Seed("work", "acme", @"C:\a");
var ex = Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--name", "work"], Open, new NullLog()));
Assert.Contains("Nothing to change", ex.Message);
}
[Fact]
public void Edit_MissingName_IsAUsageError()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--org", "x"], Open, new NullLog()));
[Fact]
public void Edit_UnknownAccount_ListsTheOthers()
{
Seed("work", "acme", @"C:\a");
var ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["edit", "--name", "ghost", "--org", "x"], Open, new NullLog()));
Assert.Contains("No account named 'ghost'", ex.Message);
Assert.Contains("Available accounts: work", ex.Message);
}
[Fact]
public void Add_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["add", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts add", console.Out);
}
[Fact]
public void Edit_RenameOntoAnotherAccount_IsAFatalError()
{
Seed("work", "acme", @"C:\a");
Seed("home", "me", @"C:\h");
var ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["edit", "--name", "home", "--rename", "work"], Open, new NullLog()));
Assert.Contains("already exists", ex.Message);
}
[Fact]
public void Edit_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--name", "work", "--nope"], Open, new NullLog()));
[Fact]
public void Edit_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["edit", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts edit", console.Out);
}
// ---------------------------------------------------------------- edit
// ---------------------------------------------------------------- remove
[Fact]
public void Edit_ReplacesOnlyTheGivenSettings_AndLeavesTheTokenAlone()
{
Seed("work", "acme", @"C:\a");
Account before = Store.FindByName("work")!;
using var console = new ConsoleCapture();
[Fact]
public void Remove_DeletesTheAccountAndItsToken()
{
Seed("work", "acme", @"C:\a");
Guid id = Store.FindByName("work")!.Id;
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(
["edit", "--name", "work", "--org", "acme-labs", "--parallel", "3", "--org-subfolder", "--description", "moved"],
Open, new NullLog());
int code = AccountsCommand.Run(["remove", "--name", "work"], Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work' updated.", console.Out);
Account after = Store.FindByName("work")!;
Assert.Equal(before.Id, after.Id);
Assert.Equal("acme-labs", after.Organization);
Assert.Equal(@"C:\a", after.TargetRoot); // untouched
Assert.Equal(3, after.MaxConcurrency);
Assert.True(after.CreateOrgSubfolder);
Assert.Equal("moved", after.Description);
Assert.Equal("tok-work", _vault.TryRetrieve(after.Id)); // untouched
}
Assert.Equal(0, code);
Assert.Contains("Account 'work' removed.", console.Out);
Assert.Empty(Store.GetAll());
Assert.Null(_vault.TryRetrieve(id));
}
[Fact]
public void Remove_UnknownAccount_WhenNoneExist_SaysSo()
{
var ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["remove", "--name", "ghost"], Open, new NullLog()));
Assert.Contains("No accounts exist yet", ex.Message);
}
[Fact]
public void Remove_MissingName_IsAUsageError()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["remove"], Open, new NullLog()));
[Fact]
public void Edit_RenameTargetAndNoOrgSubfolder_AndTokenReplacement()
{
Seed("work", "acme", @"C:\a");
Store.Save(Store.FindByName("work")! with { CreateOrgSubfolder = true }, null);
using IDisposable _ = TokenEnv("ghp_new");
using var console = new ConsoleCapture();
[Fact]
public void Remove_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["remove", "--nope"], Open, new NullLog()));
int code = AccountsCommand.Run(
["edit", "--name", "work", "--rename", "work2", "--target", @"D:\b", "--no-org-subfolder", "--token-env", EnvVar],
Open, new NullLog());
[Fact]
public void Remove_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["remove", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts remove", console.Out);
}
Assert.Equal(0, code);
Assert.Contains("Account 'work2' updated (token replaced).", console.Out);
Assert.Null(Store.FindByName("work"));
Account after = Store.FindByName("work2")!;
Assert.Equal(@"D:\b", after.TargetRoot);
Assert.False(after.CreateOrgSubfolder);
Assert.Equal("ghp_new", _vault.TryRetrieve(after.Id));
}
[Fact]
public void Mutations_OpenThrows_Propagates()
{
using var _ = TokenEnv("ghp_x");
Func<IActivityLog, (AccountsStore, ITokenVault)> failing =
_ => throw new CliErrorException("Accounts require Windows credential storage; ...");
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
["add", "--name", "w", "--org", "o", "--target", "t", "--token-env", EnvVar], failing, new NullLog()));
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["edit", "--name", "w", "--org", "o"], failing, new NullLog()));
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["remove", "--name", "w"], failing, new NullLog()));
}
[Fact]
public void OpenThrows_Propagates()
{
Func<IActivityLog, (AccountsStore, ITokenVault)> failing =
_ => throw new CliErrorException("Accounts require Windows credential storage; ...");
[Fact]
public void Edit_WithNothingToChange_IsAUsageError()
{
Seed("work", "acme", @"C:\a");
CliUsageException ex = Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--name", "work"], Open, new NullLog()));
Assert.Contains("Nothing to change", ex.Message);
}
Assert.Throws<CliErrorException>(() => AccountsCommand.Run([], failing, new NullLog()));
}
[Fact]
public void Edit_MissingName_IsAUsageError()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--org", "x"], Open, new NullLog()));
[Fact]
public void Edit_UnknownAccount_ListsTheOthers()
{
Seed("work", "acme", @"C:\a");
CliErrorException ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["edit", "--name", "ghost", "--org", "x"], Open, new NullLog()));
Assert.Contains("No account named 'ghost'", ex.Message);
Assert.Contains("Available accounts: work", ex.Message);
}
[Fact]
public void Edit_RenameOntoAnotherAccount_IsAFatalError()
{
Seed("work", "acme", @"C:\a");
Seed("home", "me", @"C:\h");
CliErrorException ex = Assert.Throws<CliErrorException>(
() => AccountsCommand.Run(["edit", "--name", "home", "--rename", "work"], Open, new NullLog()));
Assert.Contains("already exists", ex.Message);
}
[Fact]
public void Edit_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["edit", "--name", "work", "--nope"], Open, new NullLog()));
[Fact]
public void Edit_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["edit", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts edit", console.Out);
}
// ---------------------------------------------------------------- remove
[Fact]
public void Remove_DeletesTheAccountAndItsToken()
{
Seed("work", "acme", @"C:\a");
Guid id = Store.FindByName("work")!.Id;
using var console = new ConsoleCapture();
int code = AccountsCommand.Run(["remove", "--name", "work"], Open, new NullLog());
Assert.Equal(0, code);
Assert.Contains("Account 'work' removed.", console.Out);
Assert.Empty(Store.GetAll());
Assert.Null(_vault.TryRetrieve(id));
}
[Fact]
public void Remove_UnknownAccount_WhenNoneExist_SaysSo()
{
CliErrorException ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["remove", "--name", "ghost"], Open, new NullLog()));
Assert.Contains("No accounts exist yet", ex.Message);
}
[Fact]
public void Remove_MissingName_IsAUsageError()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["remove"], Open, new NullLog()));
[Fact]
public void Remove_UnknownOption_Throws()
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["remove", "--nope"], Open, new NullLog()));
[Fact]
public void Remove_Help_PrintsUsage()
{
using var console = new ConsoleCapture();
Assert.Equal(0, AccountsCommand.Run(["remove", "--help"], Open, new NullLog()));
Assert.Contains("gclo accounts remove", console.Out);
}
[Fact]
public void Mutations_OpenThrows_Propagates()
{
using IDisposable _ = TokenEnv("ghp_x");
Func<IActivityLog, (AccountsStore, ITokenVault)> failing =
_ => throw new CliErrorException("Accounts require Windows credential storage; ...");
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
["add", "--name", "w", "--org", "o", "--target", "t", "--token-env", EnvVar], failing, new NullLog()));
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["edit", "--name", "w", "--org", "o"], failing, new NullLog()));
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["remove", "--name", "w"], failing, new NullLog()));
}
[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()));
}
}
+5
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
// 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.
+131 -68
View File
@@ -1,117 +1,180 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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();
private readonly TextWriter _originalOut = Console.Out;
public ConsoleCapture()
{
Console.SetOut(_out);
Console.SetError(_error);
}
private readonly TextWriter _originalError = Console.Error;
public string Out => _out.ToString();
public string Error => _error.ToString();
private readonly StringWriter _out = new();
public void Dispose()
{
Console.SetOut(_originalOut);
Console.SetError(_originalError);
}
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;
private readonly TextReader _original = Console.In;
public StdinRedirect(string input) => Console.SetIn(new StringReader(input));
public void Dispose() => Console.SetIn(_original);
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 IReadOnlyList<string> Result { 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);
}
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 IReadOnlyList<RepoDescriptor> Result { 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);
}
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();
private readonly ConcurrentQueue<string> _clones = 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;
private readonly ConcurrentQueue<string> _recoveries = new();
public IReadOnlyList<string> ClonedRepoNames => _clones.Select(Path.GetFileName).ToArray()!;
public IReadOnlyList<string> RecoveredRepoNames => _recoveries.Select(Path.GetFileName).ToArray()!;
public Func<string, bool> IsValid { get; set; } = _ => false;
public bool IsValidRepository(string path) => IsValid(path);
public Func<string, string, string, Action<double>?, CancellationToken, Task> OnClone { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
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 Func<string, string, CancellationToken, Task> OnPull { get; set; } = (_, _, _) => Task.CompletedTask;
public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
=> OnPull(path, token, cancellationToken);
public Func<string, PathRecovery, CancellationToken, Task> OnApplyRecovery { get; set; } = (_, _, _) => Task.CompletedTask;
public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken)
{
_recoveries.Enqueue(path);
return OnApplyRecovery(path, recovery, cancellationToken);
}
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 Info(string message)
{
}
public void Error(string message, Exception? exception = null)
{
}
public string LogDirectory => "";
public string CurrentLogFilePath => "";
public void Error(string message, Exception? exception = null)
{
}
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
+85 -63
View File
@@ -1,86 +1,108 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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"]);
[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());
}
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([]);
[Fact]
public void MoveNext_PlainToken_KeepsItWhole()
{
var reader = new OptionReader(["sync"]);
Assert.False(reader.MoveNext());
}
Assert.True(reader.MoveNext());
Assert.Equal("sync", reader.Current);
}
[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();
[Fact]
public void MoveNext_PastEnd_ReturnsFalse()
{
var reader = new OptionReader([]);
Assert.Throws<CliUsageException>(() => reader.RequireValue());
}
Assert.False(reader.MoveNext());
}
[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();
[Fact]
public void RequireValue_TakesTheFollowingArgument()
{
var reader = new OptionReader(["--org", "acme"]);
reader.MoveNext();
Assert.Throws<CliUsageException>(() => reader.RequireValue());
}
Assert.Equal("acme", 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();
[Fact]
public void RequireValue_EmptyInlineValue_Throws()
{
var reader = new OptionReader(["--org="]);
reader.MoveNext();
reader.RejectValue(); // must not throw
}
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
}
}
+118 -89
View File
@@ -1,118 +1,147 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
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 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);
}
private static Task<int> Run(FakeOrgLister lister, params string[] args)
=> OrgsCommand.RunAsync(args, lister, new NullLog(), CancellationToken.None);
[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);
}
private static IDisposable Token(string value)
{
Environment.SetEnvironmentVariable(EnvVar, value);
return new TokenScope();
}
[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);
}
private sealed class TokenScope : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
}
[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 Help_PrintsUsage_AndReturnsZero()
{
using var console = new ConsoleCapture();
[Fact]
public async Task UnknownOption_Throws()
{
await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeOrgLister(), "--nope"));
}
int code = await Run(new FakeOrgLister(), "--help");
[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);
}
}
Assert.Equal(0, code);
Assert.Contains("Usage: gclo orgs", console.Out);
}
[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 ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
using var _ = Token("ghp_x");
[Fact]
public async Task Plain_ListsOneLoginPerLine()
{
using var console = new ConsoleCapture();
using IDisposable _ = Token("ghp_x");
var lister = new FakeOrgLister { Result = ["me", "acme"] };
var rejected = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.Unauthorized, "401") };
var ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(rejected, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
int code = await Run(lister, "--token-env", EnvVar);
var throttled = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "slow down") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(throttled, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
}
Assert.Equal(0, code);
string[] lines = console.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(["me", "acme"], lines);
}
[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));
}
[Fact]
public async Task Json_PrintsAnArray()
{
using var console = new ConsoleCapture();
using IDisposable _ = 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 IDisposable _ = Token("ghp_x");
var lister = new FakeOrgLister { Throw = new InvalidOperationException("bad token (401)") };
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(lister, "--token-env", EnvVar));
Assert.Contains("401", ex.Message);
}
[Fact]
public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
using IDisposable _ = Token("ghp_x");
var rejected = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.Unauthorized, "401") };
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(rejected, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
var throttled = new FakeOrgLister { Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "slow down") };
ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(throttled, "--token-env", EnvVar));
Assert.Equal(ExitCodes.Transient, ex.ExitCode);
}
[Fact]
public async Task Canceled_Propagates()
{
using IDisposable _ = 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));
}
}
+84 -65
View File
@@ -1,9 +1,16 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace gclo.Cli.Tests;
/// <summary>
/// Pins the package-manager manifests the repository ships (#9): the Scoop bucket
/// manifest and the Chocolatey package must stay valid, point at the same CLI
@@ -12,79 +19,91 @@ namespace gclo.Cli.Tests;
/// </summary>
public sealed class PackagingManifestTests
{
private static readonly Regex Sha256 = new("^[0-9a-f]{64}$", RegexOptions.Compiled);
private static readonly Regex s_sha256 = new("^[0-9a-f]{64}$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static string RepoRoot
{
get
{
DirectoryInfo? current = new(AppContext.BaseDirectory);
while (current is not null && !File.Exists(Path.Combine(current.FullName, "gclo.slnx")))
{
current = current.Parent;
}
return current?.FullName ?? throw new FileNotFoundException("gclo.slnx not found above the test directory");
}
}
private static (string Version, string Url, string Hash) ReadScoop()
{
using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepoRoot, "bucket", "gclo.json")));
JsonElement root = doc.RootElement;
JsonElement x64 = root.GetProperty("architecture").GetProperty("64bit");
Assert.Equal("gclo.exe", root.GetProperty("bin").GetString());
Assert.Equal("MIT", root.GetProperty("license").GetString());
Assert.Equal("github", root.GetProperty("checkver").GetString());
string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!;
Assert.Contains("v$version/gclo-cli-win-x64.zip", autoupdateUrl);
Assert.EndsWith("/SHA256SUMS", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString());
return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!);
}
private static (string Version, string Url, string Hash) ReadChocolatey()
{
XDocument nuspec = XDocument.Load(Path.Combine(RepoRoot, "packaging", "chocolatey", "gclo.nuspec"));
XNamespace ns = nuspec.Root!.Name.Namespace;
XElement metadata = nuspec.Root.Element(ns + "metadata")!;
Assert.Equal("gclo", metadata.Element(ns + "id")!.Value);
string version = metadata.Element(ns + "version")!.Value;
Assert.EndsWith($"/releases/tag/v{version}", metadata.Element(ns + "releaseNotes")!.Value);
private static string RepoRoot
{
get
{
DirectoryInfo? current = new(AppContext.BaseDirectory);
while(current is not null && !File.Exists(Path.Combine(current.FullName, "gclo.slnx")))
{
current = current.Parent;
}
return current?.FullName ?? throw new FileNotFoundException("gclo.slnx not found above the test directory");
}
}
string install = File.ReadAllText(Path.Combine(RepoRoot, "packaging", "chocolatey", "tools", "chocolateyinstall.ps1"));
string url = Regex.Match(install, @"url64bit\s*=\s*'([^']+)'").Groups[1].Value;
string hash = Regex.Match(install, @"checksum64\s*=\s*'([^']+)'").Groups[1].Value;
Assert.Contains("Install-ChocolateyZipPackage", install);
return (version, url, hash);
}
[Fact]
public void ScoopManifest_IsValid_AndPointsAtAStableCliRelease()
{
var (version, url, hash) = ReadScoop();
Assert.Matches(@"^\d+\.\d+\.\d+$", version); // package managers get stable releases only
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
Assert.Matches(Sha256, hash);
}
private static (string Version, string Url, string Hash) ReadScoop()
{
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(RepoRoot, "bucket", "gclo.json")));
JsonElement root = doc.RootElement;
JsonElement x64 = root.GetProperty("architecture").GetProperty("64bit");
Assert.Equal("gclo.exe", root.GetProperty("bin").GetString());
Assert.Equal("MIT", root.GetProperty("license").GetString());
Assert.Equal("github", root.GetProperty("checkver").GetString());
string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!;
Assert.Contains("v$version/gclo-cli-win-x64.zip", autoupdateUrl);
Assert.EndsWith("/SHA256SUMS", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString());
return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!);
}
[Fact]
public void ChocolateyPackage_IsValid_AndPointsAtAStableCliRelease()
{
var (version, url, hash) = ReadChocolatey();
Assert.Matches(@"^\d+\.\d+\.\d+$", version);
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
Assert.Matches(Sha256, hash);
}
[Fact]
public void ScoopAndChocolatey_DescribeTheSameRelease()
{
var scoop = ReadScoop();
var choco = ReadChocolatey();
private static (string Version, string Url, string Hash) ReadChocolatey()
{
var nuspec = XDocument.Load(Path.Combine(RepoRoot, "packaging", "chocolatey", "gclo.nuspec"));
XNamespace ns = nuspec.Root!.Name.Namespace;
XElement metadata = nuspec.Root.Element(ns + "metadata")!;
Assert.Equal("gclo", metadata.Element(ns + "id")!.Value);
string version = metadata.Element(ns + "version")!.Value;
Assert.EndsWith($"/releases/tag/v{version}", metadata.Element(ns + "releaseNotes")!.Value);
Assert.Equal(scoop.Version, choco.Version);
Assert.Equal(scoop.Url, choco.Url);
Assert.Equal(scoop.Hash, choco.Hash);
}
string install = File.ReadAllText(Path.Combine(RepoRoot, "packaging", "chocolatey", "tools", "chocolateyinstall.ps1"));
string url = Regex.Match(install, @"url64bit\s*=\s*'(?<value>[^']+)'", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)).Groups["value"].Value;
string hash = Regex.Match(install, @"checksum64\s*=\s*'(?<value>[^']+)'", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)).Groups["value"].Value;
Assert.Contains("Install-ChocolateyZipPackage", install);
return (version, url, hash);
}
[Fact]
public void ScoopManifest_IsValid_AndPointsAtAStableCliRelease()
{
(string? version, string? url, string? hash) = ReadScoop();
Assert.Matches(@"^\d+\.\d+\.\d+$", version); // package managers get stable releases only
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
Assert.Matches(s_sha256, hash);
}
[Fact]
public void ChocolateyPackage_IsValid_AndPointsAtAStableCliRelease()
{
(string? version, string? url, string? hash) = ReadChocolatey();
Assert.Matches(@"^\d+\.\d+\.\d+$", version);
Assert.Equal($"https://github.com/KofTwentyTwo/gclo/releases/download/v{version}/gclo-cli-win-x64.zip", url);
Assert.Matches(s_sha256, hash);
}
[Fact]
public void ScoopAndChocolatey_DescribeTheSameRelease()
{
(string Version, string Url, string Hash) scoop = ReadScoop();
(string Version, string Url, string Hash) choco = ReadChocolatey();
Assert.Equal(scoop.Version, choco.Version);
Assert.Equal(scoop.Url, choco.Url);
Assert.Equal(scoop.Hash, choco.Hash);
}
}
+91 -70
View File
@@ -1,100 +1,121 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>
/// Covers <see cref="ProgressPrinter"/>: transition deduplication, which stream each
/// output mode writes to, and the failure/sanitized records the summary is built from.
/// </summary>
public sealed class ProgressPrinterTests
{
private static readonly InvalidPathInfo BadPath = new("bad:name.txt", "invalid on Windows", "bad_name.txt");
private static readonly InvalidPathInfo s_badPath = new("bad:name.txt", "invalid on Windows", "bad_name.txt");
[Fact]
public void Text_PrintsTransitionsOnStdout_FailuresOnStderr_AndDedupesRepeats()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Text);
printer.Report(new RepoProgress("a", SyncStatus.Queued));
printer.Report(new RepoProgress("a", SyncStatus.Cloning, Percent: 0.1));
printer.Report(new RepoProgress("a", SyncStatus.Cloning, Percent: 0.5)); // same status: suppressed
printer.Report(new RepoProgress("a", SyncStatus.Done));
printer.Report(new RepoProgress("b", SyncStatus.Failed, "boom"));
Assert.Equal(["a Queued", "a Cloning", "a Done"], Lines(console.Out));
Assert.Equal(["b Failed boom"], Lines(console.Error));
Assert.Equal("boom", Assert.Single(printer.Failures).Error);
Assert.Null(printer.Failures[0].InvalidPaths);
}
[Fact]
public void Text_PrintsTransitionsOnStdout_FailuresOnStderr_AndDedupesRepeats()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Text);
[Fact]
public void Quiet_PrintsOnlyFailures()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Quiet);
printer.Report(new RepoProgress("a", SyncStatus.Queued));
printer.Report(new RepoProgress("a", SyncStatus.Cloning, Percent: 0.1));
printer.Report(new RepoProgress("a", SyncStatus.Cloning, Percent: 0.5)); // same status: suppressed
printer.Report(new RepoProgress("a", SyncStatus.Done));
printer.Report(new RepoProgress("b", SyncStatus.Failed, "boom"));
printer.Report(new RepoProgress("a", SyncStatus.Cloning));
printer.Report(new RepoProgress("a", SyncStatus.Done));
printer.Report(new RepoProgress("b", SyncStatus.Failed, null));
Assert.Equal(["a Queued", "a Cloning", "a Done"], Lines(console.Out));
Assert.Equal(["b Failed boom"], Lines(console.Error));
Assert.Equal("boom", Assert.Single(printer.Failures).Error);
Assert.Null(printer.Failures[0].InvalidPaths);
}
Assert.Equal("", console.Out);
Assert.Equal(["b Failed unknown error"], Lines(console.Error));
}
[Fact]
public void Json_PrintsNothing_ButRecordsFailuresWithTheirInvalidPaths()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Json);
printer.Report(new RepoProgress("a", SyncStatus.Cloning));
printer.Report(new RepoProgress("b", SyncStatus.Failed, "paths", InvalidPaths: [BadPath]));
[Fact]
public void Quiet_PrintsOnlyFailures()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Quiet);
Assert.Equal("", console.Out);
Assert.Equal("", console.Error);
SyncFailure failure = Assert.Single(printer.Failures);
SyncInvalidPath path = Assert.Single(failure.InvalidPaths!);
Assert.Equal(("bad:name.txt", "invalid on Windows", "bad_name.txt"), (path.Path, path.Reason, path.SuggestedName));
}
printer.Report(new RepoProgress("a", SyncStatus.Cloning));
printer.Report(new RepoProgress("a", SyncStatus.Done));
printer.Report(new RepoProgress("b", SyncStatus.Failed, null));
[Fact]
public void JsonLines_PrintsOneObjectPerTransition_IncludingFailures_OnStdout()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.JsonLines);
Assert.Equal("", console.Out);
Assert.Equal(["b Failed unknown error"], Lines(console.Error));
}
printer.Report(new RepoProgress("a", SyncStatus.Pulling));
printer.Report(new RepoProgress("a", SyncStatus.Pulling)); // suppressed
printer.Report(new RepoProgress("b", SyncStatus.Failed, "boom"));
Assert.Equal(
[
"{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Pulling\",\"error\":null}",
[Fact]
public void Json_PrintsNothing_ButRecordsFailuresWithTheirInvalidPaths()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.Json);
printer.Report(new RepoProgress("a", SyncStatus.Cloning));
printer.Report(new RepoProgress("b", SyncStatus.Failed, "paths", InvalidPaths: [s_badPath]));
Assert.Equal("", console.Out);
Assert.Equal("", console.Error);
SyncFailure failure = Assert.Single(printer.Failures);
SyncInvalidPath path = Assert.Single(failure.InvalidPaths!);
Assert.Equal(("bad:name.txt", "invalid on Windows", "bad_name.txt"), (path.Path, path.Reason, path.SuggestedName));
}
[Fact]
public void JsonLines_PrintsOneObjectPerTransition_IncludingFailures_OnStdout()
{
using var console = new ConsoleCapture();
var printer = new ProgressPrinter(OutputMode.JsonLines);
printer.Report(new RepoProgress("a", SyncStatus.Pulling));
printer.Report(new RepoProgress("a", SyncStatus.Pulling)); // suppressed
printer.Report(new RepoProgress("b", SyncStatus.Failed, "boom"));
Assert.Equal(
[
"{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Pulling\",\"error\":null}",
"{\"type\":\"progress\",\"repo\":\"b\",\"status\":\"Failed\",\"error\":\"boom\"}",
],
Lines(console.Out));
Assert.Equal("", console.Error);
}
Lines(console.Out));
Assert.Equal("", console.Error);
}
[Fact]
public void RecordSanitized_IsExposedForTheSummary()
{
var printer = new ProgressPrinter(OutputMode.Json);
printer.RecordSanitized(new SyncSanitized("a", 2, 1, ["dup"]));
SyncSanitized record = Assert.Single(printer.Sanitized);
Assert.Equal((2, 1), (record.Renamed, record.Skipped));
}
[Fact]
public void RecordSanitized_IsExposedForTheSummary()
{
var printer = new ProgressPrinter(OutputMode.Json);
[Fact]
public void MapInvalidPaths_EmptyOrNull_IsNull()
{
Assert.Null(ProgressPrinter.MapInvalidPaths(null));
Assert.Null(ProgressPrinter.MapInvalidPaths([]));
}
printer.RecordSanitized(new SyncSanitized("a", 2, 1, ["dup"]));
private static string[] Lines(string text)
=> text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray();
SyncSanitized record = Assert.Single(printer.Sanitized);
Assert.Equal((2, 1), (record.Renamed, record.Skipped));
}
[Fact]
public void MapInvalidPaths_EmptyOrNull_IsNull()
{
Assert.Null(ProgressPrinter.MapInvalidPaths(null));
Assert.Null(ProgressPrinter.MapInvalidPaths([]));
}
private static string[] Lines(string text)
=> text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray();
}
+75 -58
View File
@@ -1,76 +1,93 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Pins the '--include' / '--exclude' / '--skip-archived' selection rules.</summary>
public sealed class RepoFilterSpecTests
{
private static RepoDescriptor Repo(string name, bool archived = false)
=> new(name, $"https://x/{name}.git", "main", archived);
private static RepoDescriptor Repo(string name, bool archived = false)
=> new(name, $"https://x/{name}.git", "main", archived);
[Fact]
public void Empty_SelectsEverything()
{
var spec = new RepoFilterSpec();
Assert.True(spec.IsEmpty);
Assert.True(spec.Matches(Repo("anything")));
Assert.True(spec.Matches(Repo("archived", archived: true)));
Assert.Equal("none", spec.ToString());
}
[Theory]
[InlineData("platform-*", "platform-api", true)]
[InlineData("platform-*", "PLATFORM-WEB", true)] // case-insensitive
[InlineData("platform-*", "docs", false)]
[InlineData("*-api", "platform-api", true)]
[InlineData("api", "platform-api", false)] // whole-name match, not substring
[InlineData("repo-?", "repo-1", true)]
[InlineData("repo-?", "repo-10", false)]
[InlineData("a.b", "axb", false)] // '.' is literal
public void Include_MatchesTheWholeName_WithGlobWildcards(string glob, string name, bool expected)
{
var spec = new RepoFilterSpec();
spec.Include(glob);
[Fact]
public void Empty_SelectsEverything()
{
var spec = new RepoFilterSpec();
Assert.Equal(expected, spec.Matches(Repo(name)));
}
Assert.True(spec.IsEmpty);
Assert.True(spec.Matches(Repo("anything")));
Assert.True(spec.Matches(Repo("archived", archived: true)));
Assert.Equal("none", spec.ToString());
}
[Fact]
public void MultipleIncludes_AreOrEd_AndExcludesWinOverIncludes()
{
var spec = new RepoFilterSpec();
spec.Include("platform-*");
spec.Include("docs");
spec.Exclude("*-legacy");
Assert.True(spec.Matches(Repo("platform-api")));
Assert.True(spec.Matches(Repo("docs")));
Assert.False(spec.Matches(Repo("platform-legacy")));
Assert.False(spec.Matches(Repo("other")));
Assert.Equal("include=2, exclude=1", spec.ToString());
}
[Fact]
public void SkipArchived_DropsArchivedRepositories()
{
var spec = new RepoFilterSpec { SkipArchived = true };
[Theory]
[InlineData("platform-*", "platform-api", true)]
[InlineData("platform-*", "PLATFORM-WEB", true)] // case-insensitive
[InlineData("platform-*", "docs", false)]
[InlineData("*-api", "platform-api", true)]
[InlineData("api", "platform-api", false)] // whole-name match, not substring
[InlineData("repo-?", "repo-1", true)]
[InlineData("repo-?", "repo-10", false)]
[InlineData("a.b", "axb", false)] // '.' is literal
public void Include_MatchesTheWholeName_WithGlobWildcards(string glob, string name, bool expected)
{
var spec = new RepoFilterSpec();
spec.Include(glob);
Assert.False(spec.IsEmpty);
Assert.True(spec.Matches(Repo("live")));
Assert.False(spec.Matches(Repo("old", archived: true)));
Assert.Equal("skipArchived", spec.ToString());
}
Assert.Equal(expected, spec.Matches(Repo(name)));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void BlankPattern_IsAUsageError(string glob)
{
var spec = new RepoFilterSpec();
var ex = Assert.Throws<CliUsageException>(() => spec.Include(glob));
Assert.Contains("--include expects", ex.Message);
Assert.Throws<CliUsageException>(() => spec.Exclude(glob));
}
[Fact]
public void MultipleIncludes_AreOrEd_AndExcludesWinOverIncludes()
{
var spec = new RepoFilterSpec();
spec.Include("platform-*");
spec.Include("docs");
spec.Exclude("*-legacy");
Assert.True(spec.Matches(Repo("platform-api")));
Assert.True(spec.Matches(Repo("docs")));
Assert.False(spec.Matches(Repo("platform-legacy")));
Assert.False(spec.Matches(Repo("other")));
Assert.Equal("include=2, exclude=1", spec.ToString());
}
[Fact]
public void SkipArchived_DropsArchivedRepositories()
{
var spec = new RepoFilterSpec { SkipArchived = true };
Assert.False(spec.IsEmpty);
Assert.True(spec.Matches(Repo("live")));
Assert.False(spec.Matches(Repo("old", archived: true)));
Assert.Equal("skipArchived", spec.ToString());
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void BlankPattern_IsAUsageError(string glob)
{
var spec = new RepoFilterSpec();
CliUsageException ex = Assert.Throws<CliUsageException>(() => spec.Include(glob));
Assert.Contains("--include expects", ex.Message);
Assert.Throws<CliUsageException>(() => spec.Exclude(glob));
}
}
+136 -103
View File
@@ -1,136 +1,169 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli.Tests;
/// <summary>Covers 'gclo repos': parsing, filters, plain and JSON output, and error translation.</summary>
public sealed class ReposCommandTests
{
private const string EnvVar = "GCLO_REPOS_TEST_TOKEN";
private const string EnvVar = "GCLO_REPOS_TEST_TOKEN";
private static Task<int> Run(FakeRepoLister lister, params string[] args)
=> ReposCommand.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);
}
private static Task<int> Run(FakeRepoLister lister, params string[] args)
=> ReposCommand.RunAsync(args, lister, new NullLog(), CancellationToken.None);
private static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
=> new(name, $"https://x/{name}.git", branch, archived);
[Fact]
public async Task Help_PrintsUsage_AndReturnsZero()
{
using var console = new ConsoleCapture();
int code = await Run(new FakeRepoLister(), "--help");
private static IDisposable Token(string value)
{
Environment.SetEnvironmentVariable(EnvVar, value);
return new TokenScope();
}
Assert.Equal(0, code);
Assert.Contains("Usage: gclo repos", console.Out);
}
[Fact]
public async Task MissingOrg_IsAUsageError()
=> await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeRepoLister()));
[Fact]
public async Task UnknownOption_Throws()
=> await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeRepoLister(), "--org", "acme", "--nope"));
private sealed class TokenScope : IDisposable
{
public void Dispose() => Environment.SetEnvironmentVariable(EnvVar, null);
}
[Fact]
public async Task Plain_PrintsAlignedColumns_WithBranchAndArchivedMarker()
{
using var console = new ConsoleCapture();
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("api"), Repo("empty-one", null), Repo("old", "master", archived: true)] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd()).ToArray();
// Name and branch columns are padded to the widest value; 'archived' trails only archived rows.
Assert.Equal(["api main", "empty-one -", "old master archived"], lines);
}
private static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
=> new(name, $"https://x/{name}.git", branch, archived);
[Fact]
public async Task Filters_NarrowTheListing_AndAnEmptyResultSaysWhy()
{
using var console = new ConsoleCapture();
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("platform-api"), Repo("platform-old", archived: true), Repo("docs")] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--include", "platform-*", "--skip-archived");
Assert.Equal(0, code);
Assert.Contains("platform-api", console.Out);
Assert.DoesNotContain("platform-old", console.Out);
Assert.DoesNotContain("docs", console.Out);
code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--include", "nothing-*", "--exclude", "x");
Assert.Equal(0, code);
Assert.Contains("No repositories of 'acme' match the filters (3 listed).", console.Error);
[Fact]
public async Task Help_PrintsUsage_AndReturnsZero()
{
using var console = new ConsoleCapture();
code = await Run(new FakeRepoLister(), "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
Assert.Contains("No repositories visible in 'acme'.", console.Error);
}
int code = await Run(new FakeRepoLister(), "--help");
[Fact]
public async Task Json_PrintsAnArrayOfSummaries()
{
using var console = new ConsoleCapture();
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("api"), Repo("old", null, archived: true)] };
Assert.Equal(0, code);
Assert.Contains("Usage: gclo repos", console.Out);
}
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--json");
Assert.Equal(0, code);
Assert.Equal(
"[{\"name\":\"api\",\"defaultBranch\":\"main\",\"archived\":false,\"cloneUrl\":\"https://x/api.git\"},"
+ "{\"name\":\"old\",\"defaultBranch\":null,\"archived\":true,\"cloneUrl\":\"https://x/old.git\"}]",
console.Out.Trim());
}
[Fact]
public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
using var _ = Token("ghp_x");
var lister = new FakeRepoLister { Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "no access") };
[Fact]
public async Task MissingOrg_IsAUsageError()
=> await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeRepoLister()));
var ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(lister, "--org", "acme", "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
Assert.Contains("no access", ex.Message);
}
[Fact]
public async Task MissingToken_Throws()
{
string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
try
{
await Assert.ThrowsAsync<CliErrorException>(() => Run(new FakeRepoLister(), "--org", "acme"));
}
finally
{
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
}
}
[Fact]
public async Task UnknownOption_Throws()
=> await Assert.ThrowsAsync<CliUsageException>(() => Run(new FakeRepoLister(), "--org", "acme", "--nope"));
[Fact]
public async Task Canceled_Propagates()
{
using var _ = Token("ghp_x");
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => ReposCommand.RunAsync(["--org", "acme", "--token-env", EnvVar], new FakeRepoLister(), new NullLog(), cts.Token));
}
[Fact]
public async Task Plain_PrintsAlignedColumns_WithBranchAndArchivedMarker()
{
using var console = new ConsoleCapture();
using IDisposable _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("api"), Repo("empty-one", null), Repo("old", "master", archived: true)] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd()).ToArray();
// Name and branch columns are padded to the widest value; 'archived' trails only archived rows.
Assert.Equal(["api main", "empty-one -", "old master archived"], lines);
}
[Fact]
public async Task Filters_NarrowTheListing_AndAnEmptyResultSaysWhy()
{
using var console = new ConsoleCapture();
using IDisposable _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("platform-api"), Repo("platform-old", archived: true), Repo("docs")] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--include", "platform-*", "--skip-archived");
Assert.Equal(0, code);
Assert.Contains("platform-api", console.Out);
Assert.DoesNotContain("platform-old", console.Out);
Assert.DoesNotContain("docs", console.Out);
code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--include", "nothing-*", "--exclude", "x");
Assert.Equal(0, code);
Assert.Contains("No repositories of 'acme' match the filters (3 listed).", console.Error);
code = await Run(new FakeRepoLister(), "--org", "acme", "--token-env", EnvVar);
Assert.Equal(0, code);
Assert.Contains("No repositories visible in 'acme'.", console.Error);
}
[Fact]
public async Task Json_PrintsAnArrayOfSummaries()
{
using var console = new ConsoleCapture();
using IDisposable _ = Token("ghp_x");
var lister = new FakeRepoLister { Result = [Repo("api"), Repo("old", null, archived: true)] };
int code = await Run(lister, "--org", "acme", "--token-env", EnvVar, "--json");
Assert.Equal(0, code);
Assert.Equal(
"[{\"name\":\"api\",\"defaultBranch\":\"main\",\"archived\":false,\"cloneUrl\":\"https://x/api.git\"},"
+ "{\"name\":\"old\",\"defaultBranch\":null,\"archived\":true,\"cloneUrl\":\"https://x/old.git\"}]",
console.Out.Trim());
}
[Fact]
public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy()
{
using IDisposable _ = Token("ghp_x");
var lister = new FakeRepoLister { Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "no access") };
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(() => Run(lister, "--org", "acme", "--token-env", EnvVar));
Assert.Equal(ExitCodes.Auth, ex.ExitCode);
Assert.Contains("no access", ex.Message);
}
[Fact]
public async Task MissingToken_Throws()
{
string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
try
{
await Assert.ThrowsAsync<CliErrorException>(() => Run(new FakeRepoLister(), "--org", "acme"));
}
finally
{
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original);
}
}
[Fact]
public async Task Canceled_Propagates()
{
using IDisposable _ = Token("ghp_x");
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => ReposCommand.RunAsync(["--org", "acme", "--token-env", EnvVar], new FakeRepoLister(), new NullLog(), cts.Token));
}
}
File diff suppressed because it is too large. Load diff
+153 -123
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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
@@ -7,145 +13,169 @@ namespace gclo.Cli.Tests;
/// </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;
}
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 TryConsume_NonTokenOption_ReturnsFalse()
{
var reader = new OptionReader(["--json"]);
reader.MoveNext();
[Fact]
public void Resolve_MoreThanOneSource_Throws()
{
var options = Parse("--token-env", "A", "--token-file", "b.txt");
Assert.False(new TokenOptions().TryConsume(reader));
}
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 HasExplicitSource_TrueOnlyAfterATokenOption()
{
Assert.False(new TokenOptions().HasExplicitSource);
Assert.True(Parse("--token-env", "X").HasExplicitSource);
Assert.True(Parse("--token-stdin").HasExplicitSource);
}
[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_MoreThanOneSource_Throws()
{
TokenOptions options = Parse("--token-env", "A", "--token-file", "b.txt");
[Fact]
public void Resolve_File_Unreadable_Throws()
{
string missing = Path.Combine(Path.GetTempPath(), "gclo-no-such-" + Guid.NewGuid().ToString("N") + ".txt");
Assert.Throws<CliUsageException>(() => options.Resolve());
}
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_ExplicitEnvVar_ReturnsTrimmedValue()
{
const string VariableName = "GCLO_TEST_TOKEN_ENV";
Environment.SetEnvironmentVariable(VariableName, " ghp_from_env ");
try
{
Assert.Equal("ghp_from_env", Parse("--token-env", VariableName).Resolve());
}
finally
{
Environment.SetEnvironmentVariable(VariableName, null);
}
}
[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();
[Fact]
public void Resolve_ExplicitEnvVar_Missing_Throws()
{
CliErrorException ex = Assert.Throws<CliErrorException>(
() => Parse("--token-env", "GCLO_DEFINITELY_UNSET_VAR").Resolve());
Assert.Contains("not set", ex.Message);
}
Assert.Throws<CliUsageException>(() => new TokenOptions().TryConsume(reader));
}
[Fact]
public void Resolve_DefaultEnvVar_WhenUnset_ThrowsWithGuidance()
{
string? original = Environment.GetEnvironmentVariable(TokenOptions.DefaultVariable);
Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, null);
try
{
CliErrorException 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");
CliErrorException 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");
CliErrorException 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("");
CliErrorException 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));
}
}
+4 -4
View File
@@ -8,10 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
+28 -19
View File
@@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"dependencies": {
"net10.0": {
"coverlet.collector": {
@@ -8,6 +8,12 @@
"resolved": "10.1.0",
"contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
@@ -35,19 +41,6 @@
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"CommunityToolkit.Mvvm": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
@@ -71,11 +64,6 @@
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
@@ -136,6 +124,27 @@
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "CentralTransitive",
"requested": "[0.32.0, )",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"Octokit": {
"type": "CentralTransitive",
"requested": "[14.0.0, )",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// One saved account in the JSON listing of 'gclo accounts --json'.
/// <see cref="LastSync"/> is the UTC time of the last completed sync, or null
/// when the account has never synced; <see cref="Id"/> is the key of its
/// Credential Manager entry ('gclo:account:&lt;id&gt;').
/// </summary>
internal sealed record AccountSummary(
string Id,
string Name,
string Description,
string Organization,
string TargetRoot,
bool CreateOrgSubfolder,
int MaxConcurrency,
DateTimeOffset? LastSync,
string? LastSyncSummary);
+368 -341
View File
@@ -1,10 +1,17 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text.Json;
using gclo.Engine;
using gclo.ViewModels;
namespace gclo.Cli;
/// <summary>
/// 'gclo accounts': list, add, edit, and remove the saved accounts — the same
/// profiles the desktop app's account wizard manages, so a headless mirror server
@@ -12,7 +19,7 @@ namespace gclo.Cli;
/// </summary>
internal static class AccountsCommand
{
private const string HelpText = """
private const string HelpText = """
Usage: gclo accounts [list] [--json]
gclo accounts add --name <name> --org <name> --target <folder> [options] <token option>
gclo accounts edit --name <name> [options] [token option]
@@ -64,374 +71,394 @@ internal static class AccountsCommand
or not running on Windows
""";
/// <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)
{
// The first argument names the subcommand unless it is an option, in which
// case it is 'list' (the original, option-only form of the command).
string subcommand = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal) ? args[0] : "list";
string[] rest = subcommand == "list" && (args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal))
? args
: args[1..];
return subcommand switch
{
"list" => List(rest, open, log),
"add" => Add(rest, open, log),
"edit" => Edit(rest, open, log),
"remove" => Remove(rest, open, log),
_ => throw new CliUsageException($"Unknown subcommand '{subcommand}' for 'gclo accounts' (use list, add, edit, or remove)."),
};
}
/// <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());
// ---------------------------------------------------------------- list
private static int List(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
bool json = false;
var reader = new OptionReader(args);
while (reader.MoveNext())
{
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts'.");
}
}
internal static int Run(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
// The first argument names the subcommand unless it is an option, in which
// case it is 'list' (the original, option-only form of the command).
string subcommand = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal) ? args[0] : "list";
string[] rest = string.Equals(subcommand, "list", StringComparison.Ordinal) && (args.Length == 0 || args[0].StartsWith("--", StringComparison.Ordinal))
? args
: args[1..];
log.Info($"accounts started: json={json}");
return subcommand switch
{
"list" => List(rest, open, log),
"add" => Add(rest, open, log),
"edit" => Edit(rest, open, log),
"remove" => Remove(rest, open, log),
_ => throw new CliUsageException($"Unknown subcommand '{subcommand}' for 'gclo accounts' (use list, add, edit, or remove)."),
};
}
try
{
(AccountsStore store, _) = open(log);
IReadOnlyList<Account> accounts = store.GetAll();
log.Info($"accounts finished: {accounts.Count} account(s) listed.");
if (json)
{
IReadOnlyList<AccountSummary> summaries = accounts
.Select(a => new AccountSummary(
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
return ExitCodes.Success;
}
if (accounts.Count == 0)
{
// Diagnostics go to stderr so redirected stdout stays clean (and empty).
Console.Error.WriteLine("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app.");
return ExitCodes.Success;
}
// ---------------------------------------------------------------- list
int nameWidth = accounts.Max(a => a.Name.Length);
int orgWidth = accounts.Max(a => a.Organization.Length);
int targetWidth = accounts.Max(a => a.TargetRoot.Length);
foreach (Account account in accounts)
{
Console.Out.WriteLine(
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
}
private static int List(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
bool json = false;
var reader = new OptionReader(args);
while(reader.MoveNext())
{
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts'.");
}
}
log.Info($"accounts started: json={json}");
try
{
(AccountsStore store, _) = open(log);
IReadOnlyList<Account> accounts = store.GetAll();
log.Info($"accounts finished: {accounts.Count} account(s) listed.");
if(json)
{
IReadOnlyList<AccountSummary> summaries = accounts
.Select(a => new AccountSummary(
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
return ExitCodes.Success;
}
catch (Exception ex)
{
// Fatal path: unusable accounts store or non-Windows platform.
// Program prints the message; the log keeps it.
log.Error($"accounts failed: {ex.Message}", ex);
throw;
}
}
}
// ---------------------------------------------------------------- add
private static int Add(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
string? org = null;
string? target = null;
string description = "";
int parallel = AppSettings.DefaultConcurrency;
bool orgSubfolder = false;
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
{
continue;
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
case "--org":
org = reader.RequireValue();
break;
case "--target":
target = reader.RequireValue();
break;
case "--description":
description = reader.RequireValue();
break;
case "--parallel":
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--org-subfolder":
reader.RejectValue();
orgSubfolder = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts add'.");
}
}
if (string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
if (string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
if (string.IsNullOrWhiteSpace(target))
{
throw new CliUsageException("--target is required.");
}
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'");
try
{
string token = tokenOptions.Resolve();
(AccountsStore store, _) = open(log);
var account = new Account
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description.Trim(),
Organization = org.Trim(),
TargetRoot = target.Trim(),
CreateOrgSubfolder = orgSubfolder,
MaxConcurrency = parallel,
};
SaveOrFail(store, account, token);
Console.Out.WriteLine($"Account '{account.Name}' added.");
if(accounts.Count == 0)
{
// Diagnostics go to stderr so redirected stdout stays clean (and empty).
Console.Error.WriteLine("No accounts yet. Create one with 'gclo accounts add' or in the gclo desktop app.");
return ExitCodes.Success;
}
catch (Exception ex)
{
log.Error($"accounts add failed: {ex.Message}", ex);
throw;
}
}
}
// ---------------------------------------------------------------- edit
int nameWidth = accounts.Max(a => a.Name.Length);
int orgWidth = accounts.Max(a => a.Organization.Length);
int targetWidth = accounts.Max(a => a.TargetRoot.Length);
foreach(Account account in accounts)
{
Console.Out.WriteLine(
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
}
return ExitCodes.Success;
}
catch(Exception ex)
{
// Fatal path: unusable accounts store or non-Windows platform.
// Program prints the message; the log keeps it.
log.Error($"accounts failed: {ex.Message}", ex);
throw;
}
}
private static int Edit(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
string? rename = null;
string? org = null;
string? target = null;
string? description = null;
int? parallel = null;
bool? orgSubfolder = null;
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
{
continue;
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
case "--rename":
rename = reader.RequireValue();
break;
case "--org":
org = reader.RequireValue();
break;
case "--target":
target = reader.RequireValue();
break;
case "--description":
description = reader.RequireValue();
break;
case "--parallel":
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--org-subfolder":
reader.RejectValue();
orgSubfolder = true;
break;
case "--no-org-subfolder":
reader.RejectValue();
orgSubfolder = false;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts edit'.");
}
}
if (string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
if (rename is null && org is null && target is null && description is null && parallel is null
&& orgSubfolder is null && !tokenOptions.HasExplicitSource)
{
throw new CliUsageException("Nothing to change: give at least one of --rename, --org, --target, --parallel, "
+ "--description, --org-subfolder/--no-org-subfolder, or a token option.");
}
// ---------------------------------------------------------------- add
log.Info($"accounts edit started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null;
private static int Add(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
string? org = null;
string? target = null;
string description = "";
int parallel = AppSettings.DefaultConcurrency;
bool orgSubfolder = false;
var tokenOptions = new TokenOptions();
Account updated = existing with
{
Name = rename?.Trim() ?? existing.Name,
Organization = org?.Trim() ?? existing.Organization,
TargetRoot = target?.Trim() ?? existing.TargetRoot,
Description = description?.Trim() ?? existing.Description,
MaxConcurrency = parallel ?? existing.MaxConcurrency,
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
};
SaveOrFail(store, updated, token);
Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced)."));
return ExitCodes.Success;
}
catch (Exception ex)
{
log.Error($"accounts edit failed: {ex.Message}", ex);
throw;
}
}
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
{
continue;
}
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
case "--org":
org = reader.RequireValue();
break;
case "--target":
target = reader.RequireValue();
break;
case "--description":
description = reader.RequireValue();
break;
case "--parallel":
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--org-subfolder":
reader.RejectValue();
orgSubfolder = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts add'.");
}
}
// ---------------------------------------------------------------- remove
if(string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
if(string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
if(string.IsNullOrWhiteSpace(target))
{
throw new CliUsageException("--target is required.");
}
private static int Remove(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'");
try
{
string token = tokenOptions.Resolve();
(AccountsStore store, _) = open(log);
var account = new Account
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Description = description.Trim(),
Organization = org.Trim(),
TargetRoot = target.Trim(),
CreateOrgSubfolder = orgSubfolder,
MaxConcurrency = parallel,
};
SaveOrFail(store, account, token);
Console.Out.WriteLine($"Account '{account.Name}' added.");
return ExitCodes.Success;
}
catch(Exception ex)
{
log.Error($"accounts add failed: {ex.Message}", ex);
throw;
}
}
var reader = new OptionReader(args);
while (reader.MoveNext())
{
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts remove'.");
}
}
if (string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
log.Info($"accounts remove started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
store.Delete(existing.Id); // the store logs the deletion
Console.Out.WriteLine($"Account '{existing.Name}' removed.");
return ExitCodes.Success;
}
catch (Exception ex)
{
log.Error($"accounts remove failed: {ex.Message}", ex);
throw;
}
}
// ---------------------------------------------------------------- edit
// ---------------------------------------------------------------- shared
private static int Edit(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
string? rename = null;
string? org = null;
string? target = null;
string? description = null;
int? parallel = null;
bool? orgSubfolder = null;
var tokenOptions = new TokenOptions();
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
private static void SaveOrFail(AccountsStore store, Account account, string? token)
{
try
{
store.Save(account, token);
}
catch (ArgumentException ex)
{
throw new CliErrorException(ex.Message, ex);
}
}
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
{
continue;
}
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
case "--rename":
rename = reader.RequireValue();
break;
case "--org":
org = reader.RequireValue();
break;
case "--target":
target = reader.RequireValue();
break;
case "--description":
description = reader.RequireValue();
break;
case "--parallel":
parallel = OptionReader.ParseParallel(reader.RequireValue());
break;
case "--org-subfolder":
reader.RejectValue();
orgSubfolder = true;
break;
case "--no-org-subfolder":
reader.RejectValue();
orgSubfolder = false;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts edit'.");
}
}
private static CliErrorException UnknownAccount(string name, AccountsStore store)
{
IReadOnlyList<Account> all = store.GetAll();
string available = all.Count == 0
? "No accounts exist yet."
: "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + ".";
return new CliErrorException($"No account named '{name}'. {available}");
}
if(string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
if(rename is null && org is null && target is null && description is null && parallel is null
&& orgSubfolder is null && !tokenOptions.HasExplicitSource)
{
throw new CliUsageException("Nothing to change: give at least one of --rename, --org, --target, --parallel, "
+ "--description, --org-subfolder/--no-org-subfolder, or a token option.");
}
/// <summary>
/// Opens the accounts store backed by Windows Credential Manager. The vault is
/// returned alongside the store because token retrieval for 'gclo sync --account'
/// goes through the vault directly (the store only handles metadata).
/// Constructed lazily — only the account code paths call this — so plain
/// '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())
{
throw new CliErrorException(
"Accounts require Windows credential storage; "
+ "'gclo accounts' and 'gclo sync --account' only work on Windows.");
}
log.Info($"accounts edit started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null;
ITokenVault vault = new CredentialManagerVault();
return (new AccountsStore(vault, log: log), vault);
}
Account updated = existing with
{
Name = rename?.Trim() ?? existing.Name,
Organization = org?.Trim() ?? existing.Organization,
TargetRoot = target?.Trim() ?? existing.TargetRoot,
Description = description?.Trim() ?? existing.Description,
MaxConcurrency = parallel ?? existing.MaxConcurrency,
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
};
SaveOrFail(store, updated, token);
Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced)."));
return ExitCodes.Success;
}
catch(Exception ex)
{
log.Error($"accounts edit failed: {ex.Message}", ex);
throw;
}
}
/// <summary>'never', or the local time of the last completed sync at minute precision.</summary>
private static string FormatLastSync(DateTimeOffset? lastSyncUtc)
=> lastSyncUtc is null
? "never"
: lastSyncUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
// ---------------------------------------------------------------- remove
private static int Remove(string[] args, Func<IActivityLog, (AccountsStore Store, ITokenVault Vault)> open, IActivityLog log)
{
string? name = null;
var reader = new OptionReader(args);
while(reader.MoveNext())
{
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--name":
name = reader.RequireValue();
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo accounts remove'.");
}
}
if(string.IsNullOrWhiteSpace(name))
{
throw new CliUsageException("--name is required.");
}
log.Info($"accounts remove started: name='{name.Trim()}'");
try
{
(AccountsStore store, _) = open(log);
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
store.Delete(existing.Id); // the store logs the deletion
Console.Out.WriteLine($"Account '{existing.Name}' removed.");
return ExitCodes.Success;
}
catch(Exception ex)
{
log.Error($"accounts remove failed: {ex.Message}", ex);
throw;
}
}
// ---------------------------------------------------------------- shared
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
private static void SaveOrFail(AccountsStore store, Account account, string? token)
{
try
{
store.Save(account, token);
}
catch(ArgumentException ex)
{
throw new CliErrorException(ex.Message, ex);
}
}
private static CliErrorException UnknownAccount(string name, AccountsStore store)
{
IReadOnlyList<Account> all = store.GetAll();
string available = all.Count == 0
? "No accounts exist yet."
: "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + ".";
return new CliErrorException($"No account named '{name}'. {available}");
}
/// <summary>
/// Opens the accounts store backed by Windows Credential Manager. The vault is
/// returned alongside the store because token retrieval for 'gclo sync --account'
/// goes through the vault directly (the store only handles metadata).
/// Constructed lazily — only the account code paths call this — so plain
/// '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())
{
throw new CliErrorException(
"Accounts require Windows credential storage; "
+ "'gclo accounts' and 'gclo sync --account' only work on Windows.");
}
ITokenVault vault = new CredentialManagerVault();
return (new AccountsStore(vault, log: log), vault);
}
/// <summary>'never', or the local time of the last completed sync at minute precision.</summary>
private static string FormatLastSync(DateTimeOffset? lastSyncUtc)
=> lastSyncUtc is null
? "never"
: lastSyncUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// A fatal runtime error (missing token, rejected token, organization not found,
/// unusable target folder). The message is printed to stderr and the process
/// exits with <see cref="ExitCode"/> — 2 unless the failure is an auth (3) or
/// transient (4) refusal from GitHub.
/// </summary>
internal sealed class CliErrorException : Exception
{
public CliErrorException(string message, Exception? inner = null, int exitCode = ExitCodes.Fatal)
: base(message, inner)
{
ExitCode = exitCode;
}
/// <summary>The process exit code this error maps to.</summary>
public int ExitCode { get; }
/// <summary>
/// Wraps an engine failure: the listers translate GitHub refusals into
/// <see cref="GitHubAccessException"/>, whose kind picks the exit code; any other
/// <see cref="InvalidOperationException"/> stays fatal (2).
/// </summary>
public static CliErrorException FromEngine(InvalidOperationException ex)
=> new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
}
-75
View File
@@ -1,75 +0,0 @@
using System.Text.Json.Serialization;
namespace gclo.Cli;
/// <summary>One Windows-invalid path behind a failure, as the validator reported it.</summary>
internal sealed record SyncInvalidPath(string Path, string Reason, string? SuggestedName);
/// <summary>
/// One failed repository in the JSON summary. <see cref="InvalidPaths"/> is present
/// (non-null) only when the failure was Windows path validation — the full list, not
/// the three-path excerpt the message carries.
/// </summary>
internal sealed record SyncFailure(string Repo, string Error, IReadOnlyList<SyncInvalidPath>? InvalidPaths = null);
/// <summary>
/// One repository that '--sanitize-paths' checked out with renames and skips: it
/// counts as cloned, but its working tree is incomplete by <see cref="Skipped"/>
/// paths, which a mirroring pipeline needs to know.
/// </summary>
internal sealed record SyncSanitized(string Repo, int Renamed, int Skipped, IReadOnlyList<string> SkippedPaths);
/// <summary>
/// The single-line JSON document printed by 'gclo sync --json', and the last line of
/// 'gclo sync --json-lines'. <see cref="Type"/> is always "summary" so a line reader
/// can tell it from progress lines.
/// </summary>
internal sealed record SyncJsonResult(
string Type,
int Total,
int Cloned,
int Updated,
int Failed,
int Canceled,
bool WasCanceled,
IReadOnlyList<SyncFailure> Failures,
IReadOnlyList<SyncSanitized> Sanitized);
/// <summary>One status transition in 'gclo sync --json-lines'; <see cref="Type"/> is always "progress".</summary>
internal sealed record SyncProgressLine(string Type, string Repo, string Status, string? Error);
/// <summary>One repository of 'gclo sync --dry-run --json': what the run would do with it.</summary>
internal sealed record DryRunEntry(string Repo, string Action);
/// <summary>One repository in the JSON listing of 'gclo repos --json'.</summary>
internal sealed record RepoSummary(string Name, string? DefaultBranch, bool Archived, string CloneUrl);
/// <summary>
/// One saved account in the JSON listing of 'gclo accounts --json'.
/// <see cref="LastSync"/> is the UTC time of the last completed sync, or null
/// when the account has never synced; <see cref="Id"/> is the key of its
/// Credential Manager entry ('gclo:account:&lt;id&gt;').
/// </summary>
internal sealed record AccountSummary(
string Id,
string Name,
string Description,
string Organization,
string TargetRoot,
bool CreateOrgSubfolder,
int MaxConcurrency,
DateTimeOffset? LastSync,
string? LastSyncSummary);
/// <summary>
/// Source-generated System.Text.Json serialization: no runtime reflection, so the
/// project stays free of trim/AOT warnings.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SyncJsonResult))]
[JsonSerializable(typeof(SyncProgressLine))]
[JsonSerializable(typeof(IReadOnlyList<DryRunEntry>))]
[JsonSerializable(typeof(IReadOnlyList<string>))]
[JsonSerializable(typeof(IReadOnlyList<RepoSummary>))]
[JsonSerializable(typeof(IReadOnlyList<AccountSummary>))]
internal sealed partial class CliJsonContext : JsonSerializerContext;
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace gclo.Cli;
/// <summary>
/// Source-generated System.Text.Json serialization: no runtime reflection, so the
/// project stays free of trim/AOT warnings.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SyncJsonResult))]
[JsonSerializable(typeof(SyncProgressLine))]
[JsonSerializable(typeof(IReadOnlyList<DryRunEntry>))]
[JsonSerializable(typeof(IReadOnlyList<string>))]
[JsonSerializable(typeof(IReadOnlyList<RepoSummary>))]
[JsonSerializable(typeof(IReadOnlyList<AccountSummary>))]
internal sealed partial class CliJsonContext : JsonSerializerContext;
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// A command-line usage error. The message is printed to stderr followed by a
/// help hint, and the process exits with code 2.
/// </summary>
internal sealed class CliUsageException : Exception
{
public CliUsageException(string message) : base(message)
{
}
}
-163
View File
@@ -1,163 +0,0 @@
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// The process exit codes. 0-2 are the original contract; 3 and 4 split what used
/// to be 2 so a scheduler can tell "fix the invocation or token" from "transient,
/// retry later" without parsing stderr; 70 (BSD EX_SOFTWARE) marks a bug in gclo
/// itself rather than a caller mistake.
/// </summary>
internal static class ExitCodes
{
/// <summary>Every repository synced (or the listing completed).</summary>
public const int Success = 0;
/// <summary>The run completed, but some repositories failed or it was canceled.</summary>
public const int Partial = 1;
/// <summary>Fatal: bad arguments, missing token, unknown organization or account, unusable target.</summary>
public const int Fatal = 2;
/// <summary>GitHub rejected the token or refused access (401/403): fix the token; never retry as is.</summary>
public const int Auth = 3;
/// <summary>A rate limit or temporary throttle: retry later.</summary>
public const int Transient = 4;
/// <summary>An unexpected exception inside gclo; details are in the activity log.</summary>
public const int Unexpected = 70;
/// <summary>The exit code for an engine access refusal of the given kind.</summary>
public static int ForAccess(GitHubAccessKind kind) => kind switch
{
GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth,
GitHubAccessKind.RateLimited => Transient,
_ => Fatal,
};
}
/// <summary>
/// A command-line usage error. The message is printed to stderr followed by a
/// help hint, and the process exits with code 2.
/// </summary>
internal sealed class CliUsageException : Exception
{
public CliUsageException(string message) : base(message)
{
}
}
/// <summary>
/// A fatal runtime error (missing token, rejected token, organization not found,
/// unusable target folder). The message is printed to stderr and the process
/// exits with <see cref="ExitCode"/> — 2 unless the failure is an auth (3) or
/// transient (4) refusal from GitHub.
/// </summary>
internal sealed class CliErrorException : Exception
{
public CliErrorException(string message, Exception? inner = null, int exitCode = ExitCodes.Fatal)
: base(message, inner)
{
ExitCode = exitCode;
}
/// <summary>The process exit code this error maps to.</summary>
public int ExitCode { get; }
/// <summary>
/// Wraps an engine failure: the listers translate GitHub refusals into
/// <see cref="GitHubAccessException"/>, whose kind picks the exit code; any other
/// <see cref="InvalidOperationException"/> stays fatal (2).
/// </summary>
public static CliErrorException FromEngine(InvalidOperationException ex)
=> new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal);
}
/// <summary>
/// Sequential reader over a command's arguments supporting both
/// '--name value' and '--name=value' forms.
/// </summary>
internal sealed class OptionReader
{
private readonly string[] _args;
private int _index;
private string? _inlineValue;
public OptionReader(string[] args) => _args = args;
/// <summary>The current token, without any inline '=value' part.</summary>
public string Current { get; private set; } = "";
public bool MoveNext()
{
_inlineValue = null;
if (_index >= _args.Length)
{
return false;
}
string token = _args[_index++];
if (token.StartsWith("--", StringComparison.Ordinal))
{
int equals = token.IndexOf('=');
if (equals >= 0)
{
_inlineValue = token[(equals + 1)..];
token = token[..equals];
}
}
Current = token;
return true;
}
/// <summary>
/// The value for <see cref="Current"/>: its inline '=value' part or the next
/// argument. A following token that itself starts with '--' is treated as a
/// missing value (use '--name=--literal' to pass such a value literally).
/// </summary>
public string RequireValue()
{
if (_inlineValue is not null)
{
if (_inlineValue.Length == 0)
{
throw new CliUsageException($"Option '{Current}' requires a value.");
}
return _inlineValue;
}
if (_index >= _args.Length || _args[_index].StartsWith("--", StringComparison.Ordinal))
{
throw new CliUsageException($"Option '{Current}' requires a value.");
}
return _args[_index++];
}
/// <summary>Rejects '--name=value' written for a flag that takes no value.</summary>
public void RejectValue()
{
if (_inlineValue is not null)
{
throw new CliUsageException($"Option '{Current}' does not take a value.");
}
}
/// <summary>
/// Parses a '--parallel' value: an integer within the engine's supported range,
/// the same 1..64 the desktop app enforces (an unbounded value would open
/// thousands of connections and trip GitHub's secondary rate limit).
/// </summary>
public static int ParseParallel(string value)
{
if (!int.TryParse(value, out int parsed)
|| parsed < gclo.ViewModels.AppSettings.MinConcurrency
|| parsed > gclo.ViewModels.AppSettings.MaxConcurrency)
{
throw new CliUsageException(
$"--parallel expects an integer between {gclo.ViewModels.AppSettings.MinConcurrency} and "
+ $"{gclo.ViewModels.AppSettings.MaxConcurrency}, got '{value}'.");
}
return parsed;
}
}
+10
View File
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>One repository of 'gclo sync --dry-run --json': what the run would do with it.</summary>
internal sealed record DryRunEntry(string Repo, string Action);
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// The process exit codes. 0-2 are the original contract; 3 and 4 split what used
/// to be 2 so a scheduler can tell "fix the invocation or token" from "transient,
/// retry later" without parsing stderr; 70 (BSD EX_SOFTWARE) marks a bug in gclo
/// itself rather than a caller mistake.
/// </summary>
internal static class ExitCodes
{
/// <summary>Every repository synced (or the listing completed).</summary>
public const int Success = 0;
/// <summary>The run completed, but some repositories failed or it was canceled.</summary>
public const int Partial = 1;
/// <summary>Fatal: bad arguments, missing token, unknown organization or account, unusable target.</summary>
public const int Fatal = 2;
/// <summary>GitHub rejected the token or refused access (401/403): fix the token; never retry as is.</summary>
public const int Auth = 3;
/// <summary>A rate limit or temporary throttle: retry later.</summary>
public const int Transient = 4;
/// <summary>An unexpected exception inside gclo; details are in the activity log.</summary>
public const int Unexpected = 70;
/// <summary>The exit code for an engine access refusal of the given kind.</summary>
public static int ForAccess(GitHubAccessKind kind) => kind switch
{
GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth,
GitHubAccessKind.RateLimited => Transient,
_ => Fatal,
};
}
+109
View File
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// Sequential reader over a command's arguments supporting both
/// '--name value' and '--name=value' forms.
/// </summary>
internal sealed class OptionReader
{
private readonly string[] _args;
private int _index;
private string? _inlineValue;
public OptionReader(string[] args) => _args = args;
/// <summary>The current token, without any inline '=value' part.</summary>
public string Current { get; private set; } = "";
public bool MoveNext()
{
_inlineValue = null;
if(_index >= _args.Length)
{
return false;
}
string token = _args[_index++];
if(token.StartsWith("--", StringComparison.Ordinal))
{
int equals = token.IndexOf('=');
if(equals >= 0)
{
_inlineValue = token[(equals + 1)..];
token = token[..equals];
}
}
Current = token;
return true;
}
/// <summary>
/// The value for <see cref="Current"/>: its inline '=value' part or the next
/// argument. A following token that itself starts with '--' is treated as a
/// missing value (use '--name=--literal' to pass such a value literally).
/// </summary>
public string RequireValue()
{
if(_inlineValue is not null)
{
if(_inlineValue.Length == 0)
{
throw new CliUsageException($"Option '{Current}' requires a value.");
}
return _inlineValue;
}
if(_index >= _args.Length || _args[_index].StartsWith("--", StringComparison.Ordinal))
{
throw new CliUsageException($"Option '{Current}' requires a value.");
}
return _args[_index++];
}
/// <summary>Rejects '--name=value' written for a flag that takes no value.</summary>
public void RejectValue()
{
if(_inlineValue is not null)
{
throw new CliUsageException($"Option '{Current}' does not take a value.");
}
}
/// <summary>
/// Parses a '--parallel' value: an integer within the engine's supported range,
/// the same 1..64 the desktop app enforces (an unbounded value would open
/// thousands of connections and trip GitHub's secondary rate limit).
/// </summary>
public static int ParseParallel(string value)
{
if(!int.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out int parsed)
|| parsed < gclo.ViewModels.AppSettings.MinConcurrency
|| parsed > gclo.ViewModels.AppSettings.MaxConcurrency)
{
throw new CliUsageException(
$"--parallel expects an integer between {gclo.ViewModels.AppSettings.MinConcurrency} and "
+ $"{gclo.ViewModels.AppSettings.MaxConcurrency}, got '{value}'.");
}
return parsed;
}
}
+89 -78
View File
@@ -1,12 +1,19 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>'gclo orgs': list the account and organization logins a token can see.</summary>
internal static class OrgsCommand
{
private const string HelpText = """
private const string HelpText = """
Usage: gclo orgs [options]
Lists the logins the token can sync, one per line: the token's own account
@@ -35,85 +42,89 @@ internal static class OrgsCommand
argument would leak. Use --token-env, --token-file, or --token-stdin.
""";
/// <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();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
/// <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();
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
{
continue;
}
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo orgs'.");
}
}
log.Info($"orgs started: json={json}");
try
{
string token = tokenOptions.Resolve();
IReadOnlyList<string> logins;
try
{
logins = await lister
.ListOrganizationsAsync(token, cancellationToken)
.ConfigureAwait(false);
}
catch(InvalidOperationException ex)
{
// The lister translates auth failures and rate limiting; the kind
// picks the exit code (2/3/4).
throw CliErrorException.FromEngine(ex);
}
log.Info($"orgs finished: {logins.Count} login(s) listed.");
if(json)
{
Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString));
}
else
{
foreach(string login in logins)
{
continue;
Console.Out.WriteLine(login);
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo orgs'.");
}
}
log.Info($"orgs started: json={json}");
try
{
string token = tokenOptions.Resolve();
IReadOnlyList<string> logins;
try
{
logins = await lister
.ListOrganizationsAsync(token, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
// The lister translates auth failures and rate limiting; the kind
// picks the exit code (2/3/4).
throw CliErrorException.FromEngine(ex);
}
log.Info($"orgs finished: {logins.Count} login(s) listed.");
if (json)
{
Console.Out.WriteLine(JsonSerializer.Serialize(logins, CliJsonContext.Default.IReadOnlyListString));
}
else
{
foreach (string login in logins)
{
Console.Out.WriteLine(login);
}
}
return ExitCodes.Success;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
log.Info("orgs canceled.");
throw;
}
catch (Exception ex)
{
// Fatal path: missing or rejected token. Program prints the message;
// the log keeps it.
log.Error($"orgs failed: {ex.Message}", ex);
throw;
}
}
}
return ExitCodes.Success;
}
catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested)
{
log.Info("orgs canceled.");
throw;
}
catch(Exception ex)
{
// Fatal path: missing or rejected token. Program prints the message;
// the log keeps it.
log.Error($"orgs failed: {ex.Message}", ex);
throw;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>How 'gclo sync' reports progress and the result.</summary>
internal enum OutputMode
{
/// <summary>Transitions on stdout, failures on stderr, a summary line at the end.</summary>
Text,
/// <summary>Failures on stderr and the summary line only.</summary>
Quiet,
/// <summary>Nothing until the end, then one JSON document.</summary>
Json,
/// <summary>One JSON object per transition on stdout (NDJSON), then the summary object.</summary>
JsonLines,
}
+125 -110
View File
@@ -1,8 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using gclo.Engine;
namespace gclo.Cli;
/// <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
@@ -13,7 +20,7 @@ namespace gclo.Cli;
[ExcludeFromCodeCoverage(Justification = "Composition root: wires real collaborators and the process Console/Ctrl+C.")]
internal static class Program
{
private const string RootHelp = """
private const string RootHelp = """
gclo (Git Clone Large Organizations) - clone or update every repository of a GitHub organization or user account.
Usage:
@@ -57,118 +64,126 @@ internal static class Program
70 unexpected error (details in the activity log).
""";
public static async Task<int> Main(string[] args)
{
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
if (!cts.IsCancellationRequested)
public static async Task<int> Main(string[] args)
{
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
if(!cts.IsCancellationRequested)
{
// Finish gracefully: in-flight git operations stop, remaining
// repositories are marked Canceled, and the summary still prints.
e.Cancel = true;
cts.Cancel();
Console.Error.WriteLine("Canceling... (press Ctrl+C again to abort immediately)");
}
// Second Ctrl+C: e.Cancel stays false and the process terminates.
};
try
{
return await RunAsync(args, cts.Token).ConfigureAwait(false);
}
catch(CliUsageException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine("Run 'gclo --help' for usage.");
return ExitCodes.Fatal;
}
catch(CliErrorException ex)
{
Console.Error.WriteLine(ex.Message);
return ex.ExitCode;
}
catch(OperationCanceledException) when(cts.IsCancellationRequested)
{
Console.Error.WriteLine("Canceled.");
return ExitCodes.Partial;
}
catch(Exception ex)
{
return ReportUnexpected(ex);
}
}
/// <summary>
/// A bug in gclo, not a caller mistake: say so, with the exception type, and point
/// at the activity log where the full exception (with its stack) is recorded, so
/// a bug report has something to go on. Exit 70 keeps it apart from exit 2.
/// </summary>
private static int ReportUnexpected(Exception ex)
{
string details = "";
try
{
var log = new FileActivityLog();
log.Error($"unexpected error ({ex.GetType().FullName}): {ex.Message}", ex);
details = log.CurrentLogFilePath;
}
catch
{
// The log must never make a bad situation worse.
}
Console.Error.WriteLine($"Unexpected error ({ex.GetType().Name}): {ex.Message}");
if(details.Length > 0)
{
Console.Error.WriteLine($"Details: {details}");
}
Console.Error.WriteLine("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues.");
return ExitCodes.Unexpected;
}
private static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
{
if(args.Length == 0)
{
throw new CliUsageException("No command given.");
}
string command = args[0];
string[] rest = args[1..];
switch(command)
{
case "sync":
return SyncCommand.RunAsync(rest, cancellationToken);
case "repos":
return ReposCommand.RunAsync(rest, cancellationToken);
case "orgs":
return OrgsCommand.RunAsync(rest, cancellationToken);
case "accounts":
return Task.FromResult(AccountsCommand.Run(rest));
case "--help" or "-h":
Console.Out.WriteLine(RootHelp);
return Task.FromResult(ExitCodes.Success);
case "help":
if(rest.Length == 0)
{
// Finish gracefully: in-flight git operations stop, remaining
// repositories are marked Canceled, and the summary still prints.
e.Cancel = true;
cts.Cancel();
Console.Error.WriteLine("Canceling... (press Ctrl+C again to abort immediately)");
Console.Out.WriteLine(RootHelp);
return Task.FromResult(ExitCodes.Success);
}
// Second Ctrl+C: e.Cancel stays false and the process terminates.
};
// 'gclo help sync' means 'gclo sync --help'; an unknown name is a usage error.
return rest[0] is "sync" or "repos" or "orgs" or "accounts"
? RunAsync([rest[0], "--help"], cancellationToken)
: throw new CliUsageException($"Unknown command '{rest[0]}'.");
case "--version":
Console.Out.WriteLine(Version());
return Task.FromResult(ExitCodes.Success);
default:
throw new CliUsageException($"Unknown command '{command}'.");
}
}
try
{
return await RunAsync(args, cts.Token).ConfigureAwait(false);
}
catch (CliUsageException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine("Run 'gclo --help' for usage.");
return ExitCodes.Fatal;
}
catch (CliErrorException ex)
{
Console.Error.WriteLine(ex.Message);
return ex.ExitCode;
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
Console.Error.WriteLine("Canceled.");
return ExitCodes.Partial;
}
catch (Exception ex)
{
return ReportUnexpected(ex);
}
}
/// <summary>
/// A bug in gclo, not a caller mistake: say so, with the exception type, and point
/// at the activity log where the full exception (with its stack) is recorded, so
/// a bug report has something to go on. Exit 70 keeps it apart from exit 2.
/// </summary>
private static int ReportUnexpected(Exception ex)
{
string details = "";
try
{
var log = new FileActivityLog();
log.Error($"unexpected error ({ex.GetType().FullName}): {ex.Message}", ex);
details = log.CurrentLogFilePath;
}
catch
{
// The log must never make a bad situation worse.
}
Console.Error.WriteLine($"Unexpected error ({ex.GetType().Name}): {ex.Message}");
if (details.Length > 0)
{
Console.Error.WriteLine($"Details: {details}");
}
Console.Error.WriteLine("This looks like a bug in gclo; please report it at https://github.com/KofTwentyTwo/gclo/issues.");
return ExitCodes.Unexpected;
}
private static Task<int> RunAsync(string[] args, CancellationToken cancellationToken)
{
if (args.Length == 0)
{
throw new CliUsageException("No command given.");
}
string command = args[0];
string[] rest = args[1..];
switch (command)
{
case "sync":
return SyncCommand.RunAsync(rest, cancellationToken);
case "repos":
return ReposCommand.RunAsync(rest, cancellationToken);
case "orgs":
return OrgsCommand.RunAsync(rest, cancellationToken);
case "accounts":
return Task.FromResult(AccountsCommand.Run(rest));
case "--help" or "-h":
Console.Out.WriteLine(RootHelp);
return Task.FromResult(ExitCodes.Success);
case "help":
if (rest.Length == 0)
{
Console.Out.WriteLine(RootHelp);
return Task.FromResult(ExitCodes.Success);
}
// 'gclo help sync' means 'gclo sync --help'; an unknown name is a usage error.
return rest[0] is "sync" or "repos" or "orgs" or "accounts"
? RunAsync([rest[0], "--help"], cancellationToken)
: throw new CliUsageException($"Unknown command '{rest[0]}'.");
case "--version":
Console.Out.WriteLine(Version());
return Task.FromResult(ExitCodes.Success);
default:
throw new CliUsageException($"Unknown command '{command}'.");
}
}
private static string Version()
// Shared with the GUI's About dialog: "0.1.0-beta.6 (9ac6c2b)" for
// releases, "0.1.0-dev (<hash>)" for local builds.
=> BuildVersion.Describe(typeof(Program).Assembly);
private static string Version()
// Shared with the GUI's About dialog: "0.1.0-beta.6 (9ac6c2b)" for
// releases, "0.1.0-dev (<hash>)" for local builds.
=> BuildVersion.Describe(typeof(Program).Assembly);
}
+107 -98
View File
@@ -1,23 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>How 'gclo sync' reports progress and the result.</summary>
internal enum OutputMode
{
/// <summary>Transitions on stdout, failures on stderr, a summary line at the end.</summary>
Text,
/// <summary>Failures on stderr and the summary line only.</summary>
Quiet,
/// <summary>Nothing until the end, then one JSON document.</summary>
Json,
/// <summary>One JSON object per transition on stdout (NDJSON), then the summary object.</summary>
JsonLines,
}
/// <summary>
/// Prints one line per repository status transition and records failures (and
@@ -29,98 +20,116 @@ internal enum OutputMode
/// </summary>
internal sealed class ProgressPrinter : IProgress<RepoProgress>
{
private readonly object _gate = new();
private readonly Dictionary<string, SyncStatus> _lastStatus = new(StringComparer.OrdinalIgnoreCase);
private readonly List<SyncFailure> _failures = new();
private readonly List<SyncSanitized> _sanitized = new();
private readonly OutputMode _mode;
private readonly Lock _gate = new();
public ProgressPrinter(OutputMode mode) => _mode = mode;
private readonly Dictionary<string, SyncStatus> _lastStatus = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Failed repositories in the order they failed.</summary>
public IReadOnlyList<SyncFailure> Failures
{
get
{
lock (_gate)
private readonly List<SyncFailure> _failures = new();
private readonly List<SyncSanitized> _sanitized = new();
private readonly OutputMode _mode;
public ProgressPrinter(OutputMode mode) => _mode = mode;
/// <summary>Failed repositories in the order they failed.</summary>
public IReadOnlyList<SyncFailure> Failures
{
get
{
lock(_gate)
{
return _failures.ToArray();
}
}
}
/// <summary>Repositories checked out with sanitized paths, in order.</summary>
public IReadOnlyList<SyncSanitized> Sanitized
{
get
{
lock(_gate)
{
return _sanitized.ToArray();
}
}
}
/// <summary>Records a '--sanitize-paths' outcome for the summary document.</summary>
public void RecordSanitized(SyncSanitized sanitized)
{
lock(_gate)
{
_sanitized.Add(sanitized);
}
}
// The engine reports from worker threads; the lock keeps lines whole and in
// a single consistent order.
public void Report(RepoProgress value)
{
lock(_gate)
{
if(_lastStatus.TryGetValue(value.RepoName, out SyncStatus previous) && previous == value.Status)
{
return; // e.g. clone percent updates re-report Cloning
}
_lastStatus[value.RepoName] = value.Status;
if(value.Status == SyncStatus.Failed)
{
var failure = new SyncFailure(value.RepoName, value.Error ?? "unknown error", MapInvalidPaths(value.InvalidPaths));
_failures.Add(failure);
switch(_mode)
{
return _failures.ToArray();
case OutputMode.Text or OutputMode.Quiet:
Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, failure.Error);
break;
default:
break; // Json: the document carries it
}
}
}
return;
}
/// <summary>Repositories checked out with sanitized paths, in order.</summary>
public IReadOnlyList<SyncSanitized> Sanitized
{
get
{
lock (_gate)
{
return _sanitized.ToArray();
}
}
}
switch(_mode)
{
case OutputMode.Text:
Console.Out.WriteLine($"{value.RepoName} {value.Status}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, null);
break;
default:
break; // Quiet, Json: transitions are not printed
}
}
}
/// <summary>Records a '--sanitize-paths' outcome for the summary document.</summary>
public void RecordSanitized(SyncSanitized sanitized)
{
lock (_gate)
{
_sanitized.Add(sanitized);
}
}
// The engine reports from worker threads; the lock keeps lines whole and in
// a single consistent order.
public void Report(RepoProgress value)
{
lock (_gate)
{
if (_lastStatus.TryGetValue(value.RepoName, out SyncStatus previous) && previous == value.Status)
{
return; // e.g. clone percent updates re-report Cloning
}
_lastStatus[value.RepoName] = value.Status;
if (value.Status == SyncStatus.Failed)
{
var failure = new SyncFailure(value.RepoName, value.Error ?? "unknown error", MapInvalidPaths(value.InvalidPaths));
_failures.Add(failure);
switch (_mode)
{
case OutputMode.Text or OutputMode.Quiet:
Console.Error.WriteLine($"{failure.Repo} Failed {failure.Error}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, failure.Error);
break;
default:
break; // Json: the document carries it
}
return;
}
private static void WriteProgressLine(string repo, SyncStatus status, string? error)
=> Console.Out.WriteLine(JsonSerializer.Serialize(
new SyncProgressLine("progress", repo, status.ToString(), error), CliJsonContext.Default.SyncProgressLine));
switch (_mode)
{
case OutputMode.Text:
Console.Out.WriteLine($"{value.RepoName} {value.Status}");
break;
case OutputMode.JsonLines:
WriteProgressLine(value.RepoName, value.Status, null);
break;
default:
break; // Quiet, Json: transitions are not printed
}
}
}
private static void WriteProgressLine(string repo, SyncStatus status, string? error)
=> Console.Out.WriteLine(JsonSerializer.Serialize(
new SyncProgressLine("progress", repo, status.ToString(), error), CliJsonContext.Default.SyncProgressLine));
/// <summary>The full offending-path list for the document; null when the failure was not path validation.</summary>
internal static IReadOnlyList<SyncInvalidPath>? MapInvalidPaths(IReadOnlyList<InvalidPathInfo>? paths)
=> paths is { Count: > 0 }
? paths.Select(p => new SyncInvalidPath(p.RepoPath, p.Reason, p.SuggestedName)).ToList()
: null;
/// <summary>The full offending-path list for the document; null when the failure was not path validation.</summary>
internal static IReadOnlyList<SyncInvalidPath>? MapInvalidPaths(IReadOnlyList<InvalidPathInfo>? paths)
=> paths is { Count: > 0 }
? paths.Select(p => new SyncInvalidPath(p.RepoPath, p.Reason, p.SuggestedName)).ToList()
: null;
}
+63 -43
View File
@@ -1,8 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.RegularExpressions;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>
/// The repository selection shared by 'gclo sync' and 'gclo repos': any number of
/// '--include' and '--exclude' name globs ('*' and '?', case-insensitive, matched
@@ -13,54 +20,67 @@ namespace gclo.Cli;
/// </summary>
internal sealed class RepoFilterSpec
{
private readonly List<Regex> _include = new();
private readonly List<Regex> _exclude = new();
private readonly List<Regex> _include = new();
/// <summary>When true, archived repositories are dropped from the selection.</summary>
public bool SkipArchived { get; set; }
private readonly List<Regex> _exclude = new();
/// <summary>True when no filter was given: every listed repository is selected.</summary>
public bool IsEmpty => _include.Count == 0 && _exclude.Count == 0 && !SkipArchived;
/// <summary>When true, archived repositories are dropped from the selection.</summary>
public bool SkipArchived { get; set; }
/// <summary>Adds an '--include' glob.</summary>
public void Include(string glob) => _include.Add(GlobToRegex(glob, "--include"));
/// <summary>Adds an '--exclude' glob.</summary>
public void Exclude(string glob) => _exclude.Add(GlobToRegex(glob, "--exclude"));
/// <summary>Whether <paramref name="repo"/> is part of the selection.</summary>
public bool Matches(RepoDescriptor repo)
=> (!SkipArchived || !repo.IsArchived)
&& (_include.Count == 0 || _include.Any(r => r.IsMatch(repo.Name)))
&& !_exclude.Any(r => r.IsMatch(repo.Name));
/// <summary>True when no filter was given: every listed repository is selected.</summary>
public bool IsEmpty => _include.Count == 0 && _exclude.Count == 0 && !SkipArchived;
/// <summary>Human-readable description for the activity log.</summary>
public override string ToString()
{
var parts = new List<string>();
if (_include.Count > 0)
{
parts.Add($"include={_include.Count}");
}
if (_exclude.Count > 0)
{
parts.Add($"exclude={_exclude.Count}");
}
if (SkipArchived)
{
parts.Add("skipArchived");
}
return parts.Count == 0 ? "none" : string.Join(", ", parts);
}
internal static Regex GlobToRegex(string glob, string option)
{
if (string.IsNullOrWhiteSpace(glob))
{
throw new CliUsageException($"{option} expects a repository name pattern such as 'platform-*'.");
}
string pattern = "^" + Regex.Escape(glob.Trim()).Replace("\\*", ".*", StringComparison.Ordinal)
.Replace("\\?", ".", StringComparison.Ordinal) + "$";
return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
/// <summary>Adds an '--include' glob.</summary>
public void Include(string glob) => _include.Add(GlobToRegex(glob, "--include"));
/// <summary>Adds an '--exclude' glob.</summary>
public void Exclude(string glob) => _exclude.Add(GlobToRegex(glob, "--exclude"));
/// <summary>Whether <paramref name="repo"/> is part of the selection.</summary>
public bool Matches(RepoDescriptor repo)
=> (!SkipArchived || !repo.IsArchived)
&& (_include.Count == 0 || _include.Any(r => r.IsMatch(repo.Name)))
&& !_exclude.Any(r => r.IsMatch(repo.Name));
/// <summary>Human-readable description for the activity log.</summary>
public override string ToString()
{
var parts = new List<string>();
if(_include.Count > 0)
{
parts.Add($"include={_include.Count}");
}
if(_exclude.Count > 0)
{
parts.Add($"exclude={_exclude.Count}");
}
if(SkipArchived)
{
parts.Add("skipArchived");
}
return parts.Count == 0 ? "none" : string.Join(", ", parts);
}
internal static Regex GlobToRegex(string glob, string option)
{
if(string.IsNullOrWhiteSpace(glob))
{
throw new CliUsageException($"{option} expects a repository name pattern such as 'platform-*'.");
}
string pattern = "^" + Regex.Escape(glob.Trim()).Replace("\\*", ".*", StringComparison.Ordinal)
.Replace("\\?", ".", StringComparison.Ordinal) + "$";
return new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
}
}
+10
View File
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>One repository in the JSON listing of 'gclo repos --json'.</summary>
internal sealed record RepoSummary(string Name, string? DefaultBranch, bool Archived, string CloneUrl);
+117 -106
View File
@@ -1,12 +1,19 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using gclo.Engine;
namespace gclo.Cli;
/// <summary>'gclo repos': list the repositories a sync would see, with the same filters.</summary>
internal static class ReposCommand
{
private const string HelpText = """
private const string HelpText = """
Usage: gclo repos --org <name> [options]
Lists the repositories of <name> that 'gclo sync' would process — one per
@@ -38,120 +45,124 @@ internal static class ReposCommand
4 rate limited; retry later
""";
/// <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 GitHubRepositoryLister(), new FileActivityLog(), cancellationToken);
internal static async Task<int> RunAsync(
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
{
string? org = null;
bool json = false;
var filter = new RepoFilterSpec();
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
while (reader.MoveNext())
{
if (tokenOptions.TryConsume(reader))
{
continue;
}
switch (reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--org":
org = reader.RequireValue();
break;
case "--include":
filter.Include(reader.RequireValue());
break;
case "--exclude":
filter.Exclude(reader.RequireValue());
break;
case "--skip-archived":
reader.RejectValue();
filter.SkipArchived = true;
break;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo repos'.");
}
}
/// <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 GitHubRepositoryLister(), new FileActivityLog(), cancellationToken);
if (string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
log.Info($"repos started: org='{org}', filters={filter}, json={json}");
try
{
string token = tokenOptions.Resolve();
internal static async Task<int> RunAsync(
string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken)
{
string? org = null;
bool json = false;
var filter = new RepoFilterSpec();
var tokenOptions = new TokenOptions();
IReadOnlyList<RepoDescriptor> repositories;
try
{
repositories = await lister
.ListOrganizationRepositoriesAsync(org.Trim(), token, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
throw CliErrorException.FromEngine(ex);
}
var reader = new OptionReader(args);
while(reader.MoveNext())
{
if(tokenOptions.TryConsume(reader))
{
continue;
}
switch(reader.Current)
{
case "--help" or "-h":
Console.Out.WriteLine(HelpText);
return ExitCodes.Success;
case "--org":
org = reader.RequireValue();
break;
case "--include":
filter.Include(reader.RequireValue());
break;
case "--exclude":
filter.Exclude(reader.RequireValue());
break;
case "--skip-archived":
reader.RejectValue();
filter.SkipArchived = true;
break;
case "--json":
reader.RejectValue();
json = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo repos'.");
}
}
int listed = repositories.Count;
if (!filter.IsEmpty)
{
repositories = repositories.Where(filter.Matches).ToList();
}
log.Info($"repos finished: {listed} listed, {repositories.Count} selected.");
if(string.IsNullOrWhiteSpace(org))
{
throw new CliUsageException("--org is required.");
}
if (json)
{
IReadOnlyList<RepoSummary> summaries = repositories
.Select(r => new RepoSummary(r.Name, r.DefaultBranch, r.IsArchived, r.CloneUrl))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary));
return ExitCodes.Success;
}
log.Info($"repos started: org='{org}', filters={filter}, json={json}");
if (repositories.Count == 0)
{
Console.Error.WriteLine(listed == 0
? $"No repositories visible in '{org}'."
: $"No repositories of '{org}' match the filters ({listed} listed).");
return ExitCodes.Success;
}
try
{
string token = tokenOptions.Resolve();
int nameWidth = repositories.Max(r => r.Name.Length);
int branchWidth = repositories.Max(r => (r.DefaultBranch ?? "-").Length);
foreach (RepoDescriptor repo in repositories)
{
string branch = (repo.DefaultBranch ?? "-").PadRight(branchWidth);
Console.Out.WriteLine(
$"{repo.Name.PadRight(nameWidth)} {branch}" + (repo.IsArchived ? " archived" : ""));
}
IReadOnlyList<RepoDescriptor> repositories;
try
{
repositories = await lister
.ListOrganizationRepositoriesAsync(org.Trim(), token, cancellationToken)
.ConfigureAwait(false);
}
catch(InvalidOperationException ex)
{
throw CliErrorException.FromEngine(ex);
}
int listed = repositories.Count;
if(!filter.IsEmpty)
{
repositories = repositories.Where(filter.Matches).ToList();
}
log.Info($"repos finished: {listed} listed, {repositories.Count} selected.");
if(json)
{
IReadOnlyList<RepoSummary> summaries = repositories
.Select(r => new RepoSummary(r.Name, r.DefaultBranch, r.IsArchived, r.CloneUrl))
.ToList();
Console.Out.WriteLine(JsonSerializer.Serialize(summaries, CliJsonContext.Default.IReadOnlyListRepoSummary));
return ExitCodes.Success;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
log.Info("repos canceled.");
throw;
}
catch (Exception ex)
{
log.Error($"repos failed: {ex.Message}", ex);
throw;
}
}
}
if(repositories.Count == 0)
{
Console.Error.WriteLine(listed == 0
? $"No repositories visible in '{org}'."
: $"No repositories of '{org}' match the filters ({listed} listed).");
return ExitCodes.Success;
}
int nameWidth = repositories.Max(r => r.Name.Length);
int branchWidth = repositories.Max(r => (r.DefaultBranch ?? "-").Length);
foreach(RepoDescriptor repo in repositories)
{
string branch = (repo.DefaultBranch ?? "-").PadRight(branchWidth);
Console.Out.WriteLine(
$"{repo.Name.PadRight(nameWidth)} {branch}" + (repo.IsArchived ? " archived" : ""));
}
return ExitCodes.Success;
}
catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested)
{
log.Info("repos canceled.");
throw;
}
catch(Exception ex)
{
log.Error($"repos failed: {ex.Message}", ex);
throw;
}
}
}
+573 -469
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// One failed repository in the JSON summary. <see cref="InvalidPaths"/> is present
/// (non-null) only when the failure was Windows path validation — the full list, not
/// the three-path excerpt the message carries.
/// </summary>
internal sealed record SyncFailure(string Repo, string Error, IReadOnlyList<SyncInvalidPath>? InvalidPaths = null);
+10
View File
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>One Windows-invalid path behind a failure, as the validator reported it.</summary>
internal sealed record SyncInvalidPath(string Path, string Reason, string? SuggestedName);
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// The single-line JSON document printed by 'gclo sync --json', and the last line of
/// 'gclo sync --json-lines'. <see cref="Type"/> is always "summary" so a line reader
/// can tell it from progress lines.
/// </summary>
internal sealed record SyncJsonResult(
string Type,
int Total,
int Cloned,
int Updated,
int Failed,
int Canceled,
bool WasCanceled,
IReadOnlyList<SyncFailure> Failures,
IReadOnlyList<SyncSanitized> Sanitized);
+10
View File
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>One status transition in 'gclo sync --json-lines'; <see cref="Type"/> is always "progress".</summary>
internal sealed record SyncProgressLine(string Type, string Repo, string Status, string? Error);
+14
View File
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// One repository that '--sanitize-paths' checked out with renames and skips: it
/// counts as cloned, but its working tree is incomplete by <see cref="Skipped"/>
/// paths, which a mirroring pipeline needs to know.
/// </summary>
internal sealed record SyncSanitized(string Repo, int Renamed, int Skipped, IReadOnlyList<string> SkippedPaths);
+154 -130
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Cli;
/// <summary>
/// Collects and resolves the GitHub token source options shared by all commands.
/// There is deliberately no plain '--token &lt;value&gt;' option: process command
@@ -10,148 +16,166 @@ namespace gclo.Cli;
/// </summary>
internal sealed class TokenOptions
{
/// <summary>Environment variable consulted when no token option is given.</summary>
public const string DefaultVariable = "GITHUB_TOKEN";
/// <summary>Environment variable consulted when no token option is given.</summary>
public const string DefaultVariable = "GITHUB_TOKEN";
private string? _envVariable;
private string? _filePath;
private bool _useStdin;
private string? _envVariable;
/// <summary>
/// True when any token option (--token-env, --token-file, --token-stdin) was
/// given. 'gclo sync --account' uses this to let an explicit token source
/// override the account's vault-stored token.
/// </summary>
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin;
private string? _filePath;
/// <summary>Consumes the reader's current option when it is a token option.</summary>
public bool TryConsume(OptionReader reader)
{
switch (reader.Current)
{
case "--token-env":
_envVariable = reader.RequireValue();
return true;
case "--token-file":
_filePath = reader.RequireValue();
return true;
case "--token-stdin":
reader.RejectValue();
_useStdin = true;
return true;
default:
return false;
}
}
private bool _useStdin;
/// <summary>
/// Resolves the token from the single configured source, or from the
/// <see cref="DefaultVariable"/> environment variable when none was given.
/// Throws with an actionable message when the token is missing or empty.
/// </summary>
public string Resolve()
{
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0);
if (sources > 1)
{
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin.");
}
if (_useStdin)
{
return FromStdin();
}
if (_filePath is not null)
{
return FromFile(_filePath);
}
return FromEnvironment(_envVariable ?? DefaultVariable, wasExplicit: _envVariable is not null);
}
private static string FromEnvironment(string variable, bool wasExplicit)
{
string? token = Environment.GetEnvironmentVariable(variable)?.Trim();
if (string.IsNullOrEmpty(token))
{
throw new CliErrorException(wasExplicit
? $"Environment variable '{variable}' is not set or is empty."
: $"No token source given and the default environment variable '{variable}' is not set. "
+ "Provide a token with --token-env, --token-file, or --token-stdin.");
}
return token;
}
/// <summary>
/// True when any token option (--token-env, --token-file, --token-stdin) was
/// given. 'gclo sync --account' uses this to let an explicit token source
/// override the account's vault-stored token.
/// </summary>
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin;
private static string FromFile(string path)
{
string? line;
try
{
// First non-blank line: token files often end with a newline, and some
// editors append trailing (or prepend leading) blank lines.
line = File.ReadLines(path).FirstOrDefault(l => !string.IsNullOrWhiteSpace(l));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
throw new CliErrorException($"Cannot read token file '{path}': {ex.Message}", ex);
}
line = line?.Trim();
if (string.IsNullOrEmpty(line))
{
throw new CliErrorException($"Token file '{path}' is empty.");
}
return line;
}
private static string FromStdin()
{
string? line = Console.IsInputRedirected
? Console.In.ReadLine()?.Trim()
: ReadInteractiveToken();
/// <summary>Consumes the reader's current option when it is a token option.</summary>
public bool TryConsume(OptionReader reader)
{
switch(reader.Current)
{
case "--token-env":
_envVariable = reader.RequireValue();
return true;
case "--token-file":
_filePath = reader.RequireValue();
return true;
case "--token-stdin":
reader.RejectValue();
_useStdin = true;
return true;
default:
return false;
}
}
if (string.IsNullOrEmpty(line))
{
throw new CliErrorException("No token arrived on standard input (expected one line).");
}
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();
while (true)
{
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter)
/// <summary>
/// Resolves the token from the single configured source, or from the
/// <see cref="DefaultVariable"/> environment variable when none was given.
/// Throws with an actionable message when the token is missing or empty.
/// </summary>
public string Resolve()
{
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0);
if(sources > 1)
{
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin.");
}
if(_useStdin)
{
return FromStdin();
}
if(_filePath is not null)
{
return FromFile(_filePath);
}
return FromEnvironment(_envVariable ?? DefaultVariable, wasExplicit: _envVariable is not null);
}
private static string FromEnvironment(string variable, bool wasExplicit)
{
string? token = Environment.GetEnvironmentVariable(variable)?.Trim();
if(string.IsNullOrEmpty(token))
{
throw new CliErrorException(wasExplicit
? $"Environment variable '{variable}' is not set or is empty."
: $"No token source given and the default environment variable '{variable}' is not set. "
+ "Provide a token with --token-env, --token-file, or --token-stdin.");
}
return token;
}
private static string FromFile(string path)
{
string? line;
try
{
// First non-blank line: token files often end with a newline, and some
// editors append trailing (or prepend leading) blank lines.
line = File.ReadLines(path).FirstOrDefault(l => !string.IsNullOrWhiteSpace(l));
}
catch(Exception ex) when(ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
throw new CliErrorException($"Cannot read token file '{path}': {ex.Message}", ex);
}
line = line?.Trim();
if(string.IsNullOrEmpty(line))
{
throw new CliErrorException($"Token file '{path}' is empty.");
}
return line;
}
private static string FromStdin()
{
string? line = Console.IsInputRedirected
? Console.In.ReadLine()?.Trim()
: ReadInteractiveToken();
if(string.IsNullOrEmpty(line))
{
throw new CliErrorException("No token arrived on standard input (expected one line).");
}
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();
while(true)
{
ConsoleKeyInfo key = Console.ReadKey(intercept: true);
if(key.Key == ConsoleKey.Enter)
{
return buffer.ToString();
}
if(key.Key == ConsoleKey.Backspace)
{
if(buffer.Length > 0)
{
return buffer.ToString();
buffer.Length--;
}
if (key.Key == ConsoleKey.Backspace)
{
if (buffer.Length > 0)
{
buffer.Length--;
}
}
else if (key.KeyChar != '\0')
{
buffer.Append(key.KeyChar);
}
}
}
}
else if(key.KeyChar != '\0')
{
buffer.Append(key.KeyChar);
}
}
}
}
+27 -18
View File
@@ -1,30 +1,18 @@
{
"version": 1,
"version": 2,
"dependencies": {
"net10.0": {
"CommunityToolkit.Mvvm": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
"contentHash": "5YZVGNuO9nYFQfzRQGPGY9fdpSsABwlF2jSaZ7ydxCfjvt5Krbj/eHbXkvvgAQzp4lkjvgDrlAuuI4KTmqhDlA=="
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"gclo.engine": {
"type": "Project",
"dependencies": {
@@ -38,6 +26,27 @@
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "CentralTransitive",
"requested": "[0.32.0, )",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"Octokit": {
"type": "CentralTransitive",
"requested": "[14.0.0, )",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
}
},
"net10.0/win-x64": {
+515 -448
View File
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="AccountWizardViewModel"/>: step gating, token validation,
/// defaults-vs-edit seeding, and save semantics. Every wizard runs against a real
@@ -10,452 +17,512 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class AccountWizardViewModelTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly FakeOrganizationLister _orgs = new();
private readonly AccountsStore _store;
private readonly AppSettings _defaults = new()
{
DefaultTargetFolder = @"C:\repos-default",
DefaultMaxConcurrency = 6,
};
public AccountWizardViewModelTests()
{
_store = new AccountsStore(_vault, _root);
}
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private AccountWizardViewModel NewWizard(Account? existing = null, string? existingToken = null)
=> new(_store, _orgs, _defaults, existing, () => existingToken);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
/// <summary>Advances until the wizard sits on <paramref name="step"/>, asserting each hop.</summary>
private static async Task AdvanceToStepAsync(AccountWizardViewModel wizard, int step)
{
while (wizard.Step < step)
{
int before = wizard.Step;
Assert.True(await wizard.TryAdvanceAsync(), $"expected to advance past step {before}");
}
Assert.Equal(step, wizard.Step);
}
/// <summary>A new-account wizard walked to the last step with valid inputs everywhere.</summary>
private async Task<AccountWizardViewModel> CreateWizardAtLastStepAsync()
{
var wizard = NewWizard();
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "acme";
await AdvanceToStepAsync(wizard, 4);
return wizard;
}
// ---------------------------------------------------------------- seeding
[Fact]
public void NewWizard_StartsAtStepOne_SeededFromDefaults()
{
var wizard = NewWizard();
Assert.Equal(1, wizard.Step);
Assert.True(wizard.IsFirstStep);
Assert.False(wizard.IsLastStep);
Assert.False(wizard.IsEditing);
Assert.Equal("Add account", wizard.Title);
Assert.Equal("", wizard.Name);
Assert.Equal("", wizard.Description);
Assert.Equal("", wizard.Token);
Assert.Equal("", wizard.Organization);
Assert.Equal(@"C:\repos-default", wizard.TargetRoot);
Assert.False(wizard.CreateOrgSubfolder);
Assert.Equal(6, wizard.MaxConcurrency);
Assert.Equal("", wizard.NameError);
Assert.Equal("", wizard.TokenError);
}
[Fact]
public void EditWizard_SeedsEveryFieldFromTheExistingAccount_ButNeverItsToken()
{
var account = MakeAccount("Work") with
{
Description = "primary org",
Organization = "acme-inc",
TargetRoot = @"C:\work-repos",
CreateOrgSubfolder = true,
MaxConcurrency = 4,
};
var wizard = NewWizard(account, "ghp_original");
Assert.True(wizard.IsEditing);
Assert.Equal("Edit account", wizard.Title);
Assert.Equal("Work", wizard.Name);
Assert.Equal("primary org", wizard.Description);
Assert.Equal("", wizard.Token); // the stored token is never loaded into the box (#32)
Assert.Equal("acme-inc", wizard.Organization);
Assert.Equal(@"C:\work-repos", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
Assert.Equal(4, wizard.MaxConcurrency);
}
[Fact]
public async Task EditWizard_WithNoVaultToken_CannotPassStepTwoWithAnEmptyBox()
{
var wizard = NewWizard(MakeAccount("Work"), existingToken: null);
Assert.Equal("", wizard.Token);
await AdvanceToStepAsync(wizard, 2);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("This account has no stored token. Enter one to continue.", wizard.TokenError);
wizard.Token = "ghp_typed";
Assert.True(await wizard.TryAdvanceAsync());
}
[Fact]
public async Task EditWizard_EmptyBox_ValidatesWithTheStoredToken_WithoutExposingIt()
{
string? tokenSeenByLister = null;
_orgs.Handler = (token, _) =>
{
tokenSeenByLister = token;
return Task.FromResult<IReadOnlyList<string>>(["me"]);
};
var wizard = NewWizard(MakeAccount("Work"), "ghp_stored");
await AdvanceToStepAsync(wizard, 2);
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("ghp_stored", tokenSeenByLister); // fetched for the call...
Assert.Equal("", wizard.Token); // ...and still not in the box
}
[Fact]
public void MaxConcurrency_IsClampedToTheSettingsRange()
{
var wizard = NewWizard();
wizard.MaxConcurrency = 0;
Assert.Equal(AppSettings.MinConcurrency, wizard.MaxConcurrency);
wizard.MaxConcurrency = 1000;
Assert.Equal(AppSettings.MaxConcurrency, wizard.MaxConcurrency);
}
// ---------------------------------------------------------------- step 1: identity
[Fact]
public async Task TryAdvance_BlankName_SetsNameErrorAndStays()
{
var wizard = NewWizard();
wizard.Name = " ";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.NotEqual("", wizard.NameError);
}
[Fact]
public async Task TryAdvance_DuplicateName_SetsNameErrorAndStays_ThenClearsOnceFixed()
{
_store.Save(MakeAccount("Work"), null);
var wizard = NewWizard();
wizard.Name = "WORK"; // uniqueness is case-insensitive
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.Contains("Work", wizard.NameError, StringComparison.Ordinal);
wizard.Name = "Personal";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("", wizard.NameError);
}
[Fact]
public async Task TryAdvance_EditKeepingItsOwnName_Advances()
{
var account = MakeAccount("Work");
_store.Save(account, null);
var wizard = NewWizard(account, "ghp_original");
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
}
[Fact]
public async Task TryAdvance_EditTakingAnotherAccountsName_IsBlocked()
{
var account = MakeAccount("Work");
_store.Save(account, null);
_store.Save(MakeAccount("Personal"), null);
var wizard = NewWizard(account, "ghp_original");
wizard.Name = "personal";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.NotEqual("", wizard.NameError);
}
// ---------------------------------------------------------------- step 2: token
[Fact]
public async Task TryAdvance_AcceptedToken_FillsOrganizations_ClearsError_Advances()
{
string? validatedToken = null;
_orgs.Handler = (token, _) =>
{
validatedToken = token;
return Task.FromResult<IReadOnlyList<string>>(new[] { "me", "acme" });
};
var wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = " ghp_valid ";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(3, wizard.Step);
Assert.Equal("ghp_valid", validatedToken); // validated as it will be saved: trimmed
Assert.Equal(new[] { "me", "acme" }, wizard.Organizations);
Assert.Equal("", wizard.TokenError);
Assert.False(wizard.IsValidatingToken);
}
[Fact]
public async Task TryAdvance_RejectedToken_SetsTokenErrorAndStays()
{
_orgs.Handler = (_, _) => throw new InvalidOperationException("GitHub rejected the token (401).");
var wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = "ghp_bad";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("GitHub rejected the token (401).", wizard.TokenError);
Assert.False(wizard.IsValidatingToken);
}
[Fact]
public async Task IsValidatingToken_IsTrueExactlyWhileTheLookupIsInFlight()
{
var gate = new TaskCompletionSource<IReadOnlyList<string>>(
TaskCreationOptions.RunContinuationsAsynchronously);
_orgs.Handler = (_, _) => gate.Task;
var wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = "ghp_slow";
Task<bool> advance = wizard.TryAdvanceAsync();
Assert.True(wizard.IsValidatingToken);
gate.SetResult(new[] { "me" });
Assert.True(await advance);
Assert.False(wizard.IsValidatingToken);
Assert.Equal(3, wizard.Step);
}
// ---------------------------------------------------------------- steps 3 + 4
[Fact]
public async Task TryAdvance_BlankOrganization_StaysOnStepThree()
{
var wizard = NewWizard();
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 3);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(3, wizard.Step);
Assert.Equal("Choose an organization from the list, or type one.", wizard.OrganizationError);
wizard.Organization = "acme";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.True(wizard.IsLastStep);
Assert.Equal("", wizard.OrganizationError);
}
[Fact]
public async Task TryAdvance_BlankTargetRoot_ReturnsFalseOnStepFour()
{
var wizard = await CreateWizardAtLastStepAsync();
wizard.TargetRoot = " ";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.Equal("Choose a target folder.", wizard.TargetError);
wizard.TargetRoot = @"C:\repos";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("", wizard.TargetError);
}
[Fact]
public async Task SeededWizard_CarriesTheQuickSyncConnectionOver_AndSavesItsToken()
{
var seed = new AccountWizardSeed("ghp_quick", "acme", @"C:\src", CreateOrgSubfolder: true, MaxConcurrency: 12);
var wizard = new AccountWizardViewModel(_store, _orgs, seed);
Assert.Equal(1, wizard.Step);
Assert.False(wizard.IsEditing);
Assert.Equal("", wizard.Name);
Assert.Equal("ghp_quick", wizard.Token);
Assert.Equal("acme", wizard.Organization);
Assert.Equal(@"C:\src", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
Assert.Equal(12, wizard.MaxConcurrency);
Assert.DoesNotContain("ghp_quick", seed.ToString());
Assert.Contains("[redacted]", seed.ToString());
wizard.Name = "Saved from Quick Sync";
await AdvanceToStepAsync(wizard, 4);
Assert.True(await wizard.TryAdvanceAsync());
await wizard.SaveAsync();
var saved = Assert.Single(_store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(12, saved.MaxConcurrency);
Assert.Equal("ghp_quick", _vault.TryRetrieve(saved.Id));
}
[Fact]
public void SeededWizard_NullSeed_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AccountWizardViewModel(_store, _orgs, (AccountWizardSeed)null!));
}
[Fact]
public async Task TryAdvance_ValidStepFour_ReturnsTrueWithoutAdvancing()
{
var wizard = await CreateWizardAtLastStepAsync();
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step); // the host reacts by calling SaveAsync
}
[Fact]
public async Task GoBack_StepsBackwards_AndIsANoOpOnStepOne()
{
var wizard = NewWizard();
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 2);
wizard.GoBack();
Assert.Equal(1, wizard.Step);
Assert.True(wizard.IsFirstStep);
wizard.GoBack();
Assert.Equal(1, wizard.Step);
}
// ---------------------------------------------------------------- save: new accounts
[Fact]
public async Task SaveAsync_NewAccount_PersistsTheAccountAndPutsTheTokenInTheVault()
{
var wizard = await CreateWizardAtLastStepAsync();
wizard.Description = "primary org";
wizard.CreateOrgSubfolder = true;
wizard.MaxConcurrency = 4;
wizard.TargetRoot = @"C:\work-repos";
await wizard.SaveAsync();
var saved = Assert.Single(_store.GetAll());
Assert.Equal("Work", saved.Name);
Assert.Equal("primary org", saved.Description);
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\work-repos", saved.TargetRoot);
Assert.True(saved.CreateOrgSubfolder);
Assert.Equal(4, saved.MaxConcurrency);
Assert.Null(saved.LastSyncUtc);
Assert.Null(saved.LastSyncSummary);
Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id));
}
[Fact]
public async Task SaveAsync_TrimsEveryStringInput()
{
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(new[] { "acme" });
var wizard = NewWizard();
wizard.Name = " Work ";
wizard.Description = " primary org ";
wizard.Token = " ghp_token ";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = " acme ";
await AdvanceToStepAsync(wizard, 4);
wizard.TargetRoot = @" C:\work-repos ";
await wizard.SaveAsync();
var saved = Assert.Single(_store.GetAll());
Assert.Equal("Work", saved.Name);
Assert.Equal("primary org", saved.Description);
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\work-repos", saved.TargetRoot);
Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id));
}
// ---------------------------------------------------------------- save: edits
[Fact]
public async Task SaveAsync_Edit_UpdatesFields_PreservesIdAndLastSync_LeavesVaultAlone()
{
var account = MakeAccount("Work") with
{
LastSyncUtc = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero),
LastSyncSummary = "Finished: 3 cloned.",
};
_store.Save(account, "ghp_original");
var wizard = NewWizard(account, "ghp_original");
wizard.Description = "edited";
wizard.Organization = "acme-2";
await AdvanceToStepAsync(wizard, 4);
// Overwrite the vault entry out of band: if SaveAsync wrote the (unchanged)
// token back, this sentinel would be clobbered with 'ghp_original'.
_vault.Store(account.Id, "sentinel");
await wizard.SaveAsync();
var saved = Assert.Single(_store.GetAll());
Assert.Equal(account.Id, saved.Id);
Assert.Equal("edited", saved.Description);
Assert.Equal("acme-2", saved.Organization);
Assert.Equal(account.LastSyncUtc, saved.LastSyncUtc);
Assert.Equal(account.LastSyncSummary, saved.LastSyncSummary);
Assert.Equal("sentinel", _vault.TryRetrieve(account.Id));
}
[Fact]
public async Task SaveAsync_Edit_WithAChangedToken_UpdatesTheVault()
{
var account = MakeAccount("Work");
_store.Save(account, "ghp_original");
var wizard = NewWizard(account, "ghp_original");
wizard.Token = "ghp_rotated";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Assert.Equal(account.Id, Assert.Single(_store.GetAll()).Id);
Assert.Equal("ghp_rotated", _vault.TryRetrieve(account.Id));
}
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly FakeOrganizationLister _orgs = new();
private readonly AccountsStore _store;
private readonly AppSettings _defaults = new()
{
DefaultTargetFolder = @"C:\repos-default",
DefaultMaxConcurrency = 6,
};
public AccountWizardViewModelTests()
{
_store = new AccountsStore(_vault, _root);
}
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private AccountWizardViewModel NewWizard(Account? existing = null, string? existingToken = null)
=> new(_store, _orgs, _defaults, existing, () => existingToken);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
/// <summary>Advances until the wizard sits on <paramref name="step"/>, asserting each hop.</summary>
private static async Task AdvanceToStepAsync(AccountWizardViewModel wizard, int step)
{
while(wizard.Step < step)
{
int before = wizard.Step;
Assert.True(await wizard.TryAdvanceAsync(), $"expected to advance past step {before}");
}
Assert.Equal(step, wizard.Step);
}
/// <summary>A new-account wizard walked to the last step with valid inputs everywhere.</summary>
private async Task<AccountWizardViewModel> CreateWizardAtLastStepAsync()
{
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = "acme";
await AdvanceToStepAsync(wizard, 4);
return wizard;
}
// ---------------------------------------------------------------- seeding
[Fact]
public void NewWizard_StartsAtStepOne_SeededFromDefaults()
{
AccountWizardViewModel wizard = NewWizard();
Assert.Equal(1, wizard.Step);
Assert.True(wizard.IsFirstStep);
Assert.False(wizard.IsLastStep);
Assert.False(wizard.IsEditing);
Assert.Equal("Add account", wizard.Title);
Assert.Equal("", wizard.Name);
Assert.Equal("", wizard.Description);
Assert.Equal("", wizard.Token);
Assert.Equal("", wizard.Organization);
Assert.Equal(@"C:\repos-default", wizard.TargetRoot);
Assert.False(wizard.CreateOrgSubfolder);
Assert.Equal(6, wizard.MaxConcurrency);
Assert.Equal("", wizard.NameError);
Assert.Equal("", wizard.TokenError);
}
[Fact]
public void EditWizard_SeedsEveryFieldFromTheExistingAccount_ButNeverItsToken()
{
Account account = MakeAccount("Work") with
{
Description = "primary org",
Organization = "acme-inc",
TargetRoot = @"C:\work-repos",
CreateOrgSubfolder = true,
MaxConcurrency = 4,
};
AccountWizardViewModel wizard = NewWizard(account, "ghp_original");
Assert.True(wizard.IsEditing);
Assert.Equal("Edit account", wizard.Title);
Assert.Equal("Work", wizard.Name);
Assert.Equal("primary org", wizard.Description);
Assert.Equal("", wizard.Token); // the stored token is never loaded into the box (#32)
Assert.Equal("acme-inc", wizard.Organization);
Assert.Equal(@"C:\work-repos", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
Assert.Equal(4, wizard.MaxConcurrency);
}
[Fact]
public async Task EditWizard_WithNoVaultToken_CannotPassStepTwoWithAnEmptyBox()
{
AccountWizardViewModel wizard = NewWizard(MakeAccount("Work"), existingToken: null);
Assert.Equal("", wizard.Token);
await AdvanceToStepAsync(wizard, 2);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal("This account has no stored token. Enter one to continue.", wizard.TokenError);
wizard.Token = "ghp_typed";
Assert.True(await wizard.TryAdvanceAsync());
}
[Fact]
public async Task EditWizard_EmptyBox_ValidatesWithTheStoredToken_WithoutExposingIt()
{
string? tokenSeenByLister = null;
_orgs.Handler = (token, _) =>
{
tokenSeenByLister = token;
return Task.FromResult<IReadOnlyList<string>>(["me"]);
};
AccountWizardViewModel wizard = NewWizard(MakeAccount("Work"), "ghp_stored");
await AdvanceToStepAsync(wizard, 2);
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("ghp_stored", tokenSeenByLister); // fetched for the call...
Assert.Equal("", wizard.Token); // ...and still not in the box
}
[Fact]
public void MaxConcurrency_IsClampedToTheSettingsRange()
{
AccountWizardViewModel wizard = NewWizard();
wizard.MaxConcurrency = 0;
Assert.Equal(AppSettings.MinConcurrency, wizard.MaxConcurrency);
wizard.MaxConcurrency = 1000;
Assert.Equal(AppSettings.MaxConcurrency, wizard.MaxConcurrency);
}
// ---------------------------------------------------------------- step 1: identity
[Fact]
public async Task TryAdvance_BlankName_SetsNameErrorAndStays()
{
AccountWizardViewModel wizard = NewWizard();
wizard.Name = " ";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.NotEqual("", wizard.NameError, StringComparer.Ordinal);
}
[Fact]
public async Task TryAdvance_DuplicateName_SetsNameErrorAndStays_ThenClearsOnceFixed()
{
_store.Save(MakeAccount("Work"), null);
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "WORK"; // uniqueness is case-insensitive
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.Contains("Work", wizard.NameError, StringComparison.Ordinal);
wizard.Name = "Personal";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("", wizard.NameError);
}
[Fact]
public async Task TryAdvance_EditKeepingItsOwnName_Advances()
{
Account account = MakeAccount("Work");
_store.Save(account, null);
AccountWizardViewModel wizard = NewWizard(account, "ghp_original");
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
}
[Fact]
public async Task TryAdvance_EditTakingAnotherAccountsName_IsBlocked()
{
Account account = MakeAccount("Work");
_store.Save(account, null);
_store.Save(MakeAccount("Personal"), null);
AccountWizardViewModel wizard = NewWizard(account, "ghp_original");
wizard.Name = "personal";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(1, wizard.Step);
Assert.NotEqual("", wizard.NameError, StringComparer.Ordinal);
}
// ---------------------------------------------------------------- step 2: token
[Fact]
public async Task TryAdvance_AcceptedToken_FillsOrganizations_ClearsError_Advances()
{
string? validatedToken = null;
_orgs.Handler = (token, _) =>
{
validatedToken = token;
return Task.FromResult<IReadOnlyList<string>>(new[] { "me", "acme" });
};
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = " ghp_valid ";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(3, wizard.Step);
Assert.Equal("ghp_valid", validatedToken); // validated as it will be saved: trimmed
Assert.Equal(new[] { "me", "acme" }, wizard.Organizations, StringComparer.Ordinal);
Assert.Equal("", wizard.TokenError);
Assert.False(wizard.IsValidatingToken);
}
[Fact]
public async Task TryAdvance_RejectedToken_SetsTokenErrorAndStays()
{
_orgs.Handler = (_, _) => throw new InvalidOperationException("GitHub rejected the token (401).");
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = "ghp_bad";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(2, wizard.Step);
Assert.Equal("GitHub rejected the token (401).", wizard.TokenError);
Assert.False(wizard.IsValidatingToken);
}
[Fact]
public async Task IsValidatingToken_IsTrueExactlyWhileTheLookupIsInFlight()
{
var gate = new TaskCompletionSource<IReadOnlyList<string>>(
TaskCreationOptions.RunContinuationsAsynchronously);
_orgs.Handler = (_, _) => gate.Task;
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "Work";
await AdvanceToStepAsync(wizard, 2);
wizard.Token = "ghp_slow";
Task<bool> advance = wizard.TryAdvanceAsync();
Assert.True(wizard.IsValidatingToken);
gate.SetResult(new[] { "me" });
Assert.True(await advance);
Assert.False(wizard.IsValidatingToken);
Assert.Equal(3, wizard.Step);
}
// ---------------------------------------------------------------- steps 3 + 4
[Fact]
public async Task TryAdvance_BlankOrganization_StaysOnStepThree()
{
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 3);
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(3, wizard.Step);
Assert.Equal("Choose an organization from the list, or type one.", wizard.OrganizationError);
wizard.Organization = "acme";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.True(wizard.IsLastStep);
Assert.Equal("", wizard.OrganizationError);
}
[Fact]
public async Task TryAdvance_BlankTargetRoot_ReturnsFalseOnStepFour()
{
AccountWizardViewModel wizard = await CreateWizardAtLastStepAsync();
wizard.TargetRoot = " ";
Assert.False(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step);
Assert.Equal("Choose a target folder.", wizard.TargetError);
wizard.TargetRoot = @"C:\repos";
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal("", wizard.TargetError);
}
[Fact]
public async Task SeededWizard_CarriesTheQuickSyncConnectionOver_AndSavesItsToken()
{
var seed = new AccountWizardSeed("ghp_quick", "acme", @"C:\src", CreateOrgSubfolder: true, MaxConcurrency: 12);
var wizard = new AccountWizardViewModel(_store, _orgs, seed);
Assert.Equal(1, wizard.Step);
Assert.False(wizard.IsEditing);
Assert.Equal("", wizard.Name);
Assert.Equal("ghp_quick", wizard.Token);
Assert.Equal("acme", wizard.Organization);
Assert.Equal(@"C:\src", wizard.TargetRoot);
Assert.True(wizard.CreateOrgSubfolder);
Assert.Equal(12, wizard.MaxConcurrency);
Assert.DoesNotContain("ghp_quick", seed.ToString());
Assert.Contains("[redacted]", seed.ToString());
wizard.Name = "Saved from Quick Sync";
await AdvanceToStepAsync(wizard, 4);
Assert.True(await wizard.TryAdvanceAsync());
await wizard.SaveAsync();
Account saved = Assert.Single(_store.GetAll());
Assert.Equal("acme", saved.Organization);
Assert.Equal(12, saved.MaxConcurrency);
Assert.Equal("ghp_quick", _vault.TryRetrieve(saved.Id));
}
[Fact]
public void SeededWizard_NullSeed_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AccountWizardViewModel(_store, _orgs, (AccountWizardSeed)null!));
}
[Fact]
public async Task TryAdvance_ValidStepFour_ReturnsTrueWithoutAdvancing()
{
AccountWizardViewModel wizard = await CreateWizardAtLastStepAsync();
Assert.True(await wizard.TryAdvanceAsync());
Assert.Equal(4, wizard.Step); // the host reacts by calling SaveAsync
}
[Fact]
public async Task GoBack_StepsBackwards_AndIsANoOpOnStepOne()
{
AccountWizardViewModel wizard = NewWizard();
wizard.Name = "Work";
wizard.Token = "ghp_token";
await AdvanceToStepAsync(wizard, 2);
wizard.GoBack();
Assert.Equal(1, wizard.Step);
Assert.True(wizard.IsFirstStep);
wizard.GoBack();
Assert.Equal(1, wizard.Step);
}
// ---------------------------------------------------------------- save: new accounts
[Fact]
public async Task SaveAsync_NewAccount_PersistsTheAccountAndPutsTheTokenInTheVault()
{
AccountWizardViewModel wizard = await CreateWizardAtLastStepAsync();
wizard.Description = "primary org";
wizard.CreateOrgSubfolder = true;
wizard.MaxConcurrency = 4;
wizard.TargetRoot = @"C:\work-repos";
await wizard.SaveAsync();
Account saved = Assert.Single(_store.GetAll());
Assert.Equal("Work", saved.Name);
Assert.Equal("primary org", saved.Description);
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\work-repos", saved.TargetRoot);
Assert.True(saved.CreateOrgSubfolder);
Assert.Equal(4, saved.MaxConcurrency);
Assert.Null(saved.LastSyncUtc);
Assert.Null(saved.LastSyncSummary);
Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id));
}
[Fact]
public async Task SaveAsync_TrimsEveryStringInput()
{
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(new[] { "acme" });
AccountWizardViewModel wizard = NewWizard();
wizard.Name = " Work ";
wizard.Description = " primary org ";
wizard.Token = " ghp_token ";
await AdvanceToStepAsync(wizard, 3);
wizard.Organization = " acme ";
await AdvanceToStepAsync(wizard, 4);
wizard.TargetRoot = @" C:\work-repos ";
await wizard.SaveAsync();
Account saved = Assert.Single(_store.GetAll());
Assert.Equal("Work", saved.Name);
Assert.Equal("primary org", saved.Description);
Assert.Equal("acme", saved.Organization);
Assert.Equal(@"C:\work-repos", saved.TargetRoot);
Assert.Equal("ghp_token", _vault.TryRetrieve(saved.Id));
}
// ---------------------------------------------------------------- save: edits
[Fact]
public async Task SaveAsync_Edit_UpdatesFields_PreservesIdAndLastSync_LeavesVaultAlone()
{
Account account = MakeAccount("Work") with
{
LastSyncUtc = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero),
LastSyncSummary = "Finished: 3 cloned.",
};
_store.Save(account, "ghp_original");
AccountWizardViewModel wizard = NewWizard(account, "ghp_original");
wizard.Description = "edited";
wizard.Organization = "acme-2";
await AdvanceToStepAsync(wizard, 4);
// Overwrite the vault entry out of band: if SaveAsync wrote the (unchanged)
// token back, this sentinel would be clobbered with 'ghp_original'.
_vault.Store(account.Id, "sentinel");
await wizard.SaveAsync();
Account saved = Assert.Single(_store.GetAll());
Assert.Equal(account.Id, saved.Id);
Assert.Equal("edited", saved.Description);
Assert.Equal("acme-2", saved.Organization);
Assert.Equal(account.LastSyncUtc, saved.LastSyncUtc);
Assert.Equal(account.LastSyncSummary, saved.LastSyncSummary);
Assert.Equal("sentinel", _vault.TryRetrieve(account.Id));
}
[Fact]
public async Task SaveAsync_Edit_WithAChangedToken_UpdatesTheVault()
{
Account account = MakeAccount("Work");
_store.Save(account, "ghp_original");
AccountWizardViewModel wizard = NewWizard(account, "ghp_original");
wizard.Token = "ghp_rotated";
await AdvanceToStepAsync(wizard, 4);
await wizard.SaveAsync();
Assert.Equal(account.Id, Assert.Single(_store.GetAll()).Id);
Assert.Equal("ghp_rotated", _vault.TryRetrieve(account.Id));
}
}
+454 -377
View File
@@ -1,8 +1,14 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Contract tests for the two-store consistency rules of <see cref="AccountsStore"/>
/// (see its class remarks): the order of vault and metadata writes, the compensating
@@ -12,380 +18,451 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class AccountsStoreConsistencyTests : IDisposable
{
private const string OldToken = "ghp_OLD_TOKEN_VALUE";
private const string NewToken = "ghp_NEW_TOKEN_VALUE";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly FaultableVault _vault = new();
private readonly RecordingLog _log = new();
public void Dispose()
{
UnblockMetadataWrites();
GitTestHelpers.TryDeleteDirectory(_root);
}
private AccountsStore NewStore() => new(_vault, _root, _log);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
/// <summary>
/// Makes the next metadata write fail before it changes anything: the store writes
/// to accounts.json.tmp first, and a directory squatting on that name makes the
/// FileStream creation throw UnauthorizedAccessException.
/// </summary>
private void BlockMetadataWrites()
=> Directory.CreateDirectory(Path.Combine(_root, "accounts.json.tmp"));
private void UnblockMetadataWrites()
{
string squatter = Path.Combine(_root, "accounts.json.tmp");
if (Directory.Exists(squatter))
{
Directory.Delete(squatter);
}
}
private IReadOnlyList<Account> OnDisk() => new AccountsStore(new InMemoryVault(), _root).GetAll();
// ---------------------------------------------------------------- save: vault write fails
[Fact]
public void Save_NewAccount_VaultWriteFails_ChangesNothing()
{
var store = NewStore();
var account = MakeAccount("Work");
_vault.StoreFailure = new InvalidOperationException("credential manager locked");
var ex = Assert.Throws<InvalidOperationException>(() => store.Save(account, NewToken));
Assert.Equal("credential manager locked", ex.Message);
Assert.Empty(store.GetAll());
Assert.False(File.Exists(Path.Combine(_root, "accounts.json")), "metadata must not be written before the vault");
Assert.Null(_vault.TryRetrieve(account.Id));
}
[Fact]
public void Save_UpdateWithNewToken_VaultWriteFails_KeepsPriorMetadataAndPriorToken()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.StoreFailure = new InvalidOperationException("credential manager locked");
Assert.Throws<InvalidOperationException>(
() => store.Save(account with { Description = "edited" }, NewToken));
// The replacement failed, so the account keeps the credential that worked.
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Equal("", Assert.Single(store.GetAll()).Description);
Assert.Equal("", Assert.Single(OnDisk()).Description);
}
// ---------------------------------------------------------------- save: metadata write fails
[Fact]
public void Save_NewAccount_MetadataWriteFails_RemovesTheVaultEntryAgain()
{
var store = NewStore();
var account = MakeAccount("Work");
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account, NewToken));
// No vault entry may point at an account that was never saved.
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.Empty(store.GetAll());
Assert.Equal(1, _vault.DeleteCalls);
}
[Fact]
public void Save_UpdateWithNewToken_MetadataWriteFails_RestoresThePreviousToken()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { Description = "edited" }, NewToken));
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Equal("", Assert.Single(store.GetAll()).Description);
Assert.Equal("", Assert.Single(OnDisk()).Description);
}
[Fact]
public void Save_WithoutToken_MetadataWriteFails_NeverTouchesTheVault()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.ResetCounters();
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { Description = "edited" }, token: null));
Assert.Equal(0, _vault.StoreCalls + _vault.DeleteCalls + _vault.RetrieveCalls);
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
}
// ---------------------------------------------------------------- save: compensation fails
[Fact]
public void Save_NewAccount_MetadataAndCompensationFail_SurfacesBothWithoutTheToken()
{
var store = NewStore();
var account = MakeAccount("Work");
BlockMetadataWrites();
_vault.DeleteFailure = new InvalidOperationException("delete refused");
var ex = Assert.Throws<AccountConsistencyException>(() => store.Save(account, NewToken));
Assert.IsType<UnauthorizedAccessException>(ex.InnerException);
Assert.Equal("delete refused", ex.CompensationException.Message);
Assert.Contains("not in accounts.json", ex.Message);
Assert.Contains(account.Id.ToString("N"), ex.Message);
AssertNoTokenLeaked(ex.Message);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id)); // the documented leftover
Assert.Empty(store.GetAll());
var logged = Assert.Single(_log.Errors);
Assert.Contains("could not be restored", logged.Message);
AssertNoTokenLeaked(logged.Message);
}
[Fact]
public void Save_Update_MetadataAndCompensationFail_SurfacesBothWithoutTheToken()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.ResetCounters();
_vault.StoreFailureFromCall = 2; // the replacement write succeeds, the restore does not
_vault.StoreFailure = new InvalidOperationException("restore refused");
var ex = Assert.Throws<AccountConsistencyException>(
() => store.Save(account with { Description = "edited" }, NewToken));
Assert.Equal("restore refused", ex.CompensationException.Message);
Assert.Contains("previous metadata", ex.Message);
AssertNoTokenLeaked(ex.Message);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id)); // the documented leftover
Assert.Equal("", Assert.Single(store.GetAll()).Description);
AssertNoTokenLeaked(Assert.Single(_log.Errors).Message);
}
// ---------------------------------------------------------------- delete
[Fact]
public void Delete_VaultDeleteFails_ChangesNothing()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.DeleteFailure = new InvalidOperationException("delete refused");
Assert.Throws<InvalidOperationException>(() => store.Delete(account.Id));
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Single(store.GetAll());
Assert.Single(OnDisk());
}
[Fact]
public void Delete_MetadataWriteFails_RestoresTheTokenAndKeepsTheAccount()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(account.Id));
// Not orphaned, not lost: the account is still listed AND still has its token.
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Single(store.GetAll());
Assert.Single(OnDisk());
}
[Fact]
public void Delete_AccountWithoutToken_MetadataWriteFails_HasNothingToRestore()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, token: null);
_vault.ResetCounters();
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(account.Id));
Assert.Equal(0, _vault.StoreCalls);
Assert.Single(store.GetAll());
}
[Fact]
public void Delete_MetadataAndCompensationFail_SurfacesBothWithoutTheToken()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.StoreFailure = new InvalidOperationException("restore refused");
var ex = Assert.Throws<AccountConsistencyException>(() => store.Delete(account.Id));
Assert.IsType<UnauthorizedAccessException>(ex.InnerException);
Assert.Equal("restore refused", ex.CompensationException.Message);
Assert.Contains("still listed", ex.Message);
AssertNoTokenLeaked(ex.Message);
Assert.Null(_vault.TryRetrieve(account.Id)); // the documented leftover
Assert.Single(store.GetAll());
AssertNoTokenLeaked(Assert.Single(_log.Errors).Message);
}
// ---------------------------------------------------------------- retry reconciles every leftover
[Fact]
public void Save_RetryAfterMetadataFailure_Succeeds()
{
var store = NewStore();
var account = MakeAccount("Work");
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account, NewToken));
UnblockMetadataWrites();
store.Save(account, NewToken);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id));
Assert.Equal(account, Assert.Single(OnDisk()));
}
[Fact]
public void Save_RetryAfterOrphanedVaultEntry_Reconciles()
{
var store = NewStore();
var account = MakeAccount("Work");
BlockMetadataWrites();
_vault.DeleteFailure = new InvalidOperationException("delete refused");
Assert.Throws<AccountConsistencyException>(() => store.Save(account, NewToken));
UnblockMetadataWrites();
_vault.DeleteFailure = null;
store.Save(account, NewToken);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id));
Assert.Equal(account, Assert.Single(store.GetAll()));
}
[Fact]
public void Delete_RetryAfterTokenRestoreFailed_CompletesTheRemoval()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.StoreFailure = new InvalidOperationException("restore refused");
Assert.Throws<AccountConsistencyException>(() => store.Delete(account.Id));
UnblockMetadataWrites();
_vault.StoreFailure = null;
store.Delete(account.Id); // the vault entry is already gone; that must be fine
Assert.Empty(store.GetAll());
Assert.Empty(OnDisk());
Assert.Null(_vault.TryRetrieve(account.Id));
}
[Fact]
public void Delete_RetryAfterMetadataFailure_Succeeds()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(account.Id));
UnblockMetadataWrites();
store.Delete(account.Id);
Assert.Empty(store.GetAll());
Assert.Null(_vault.TryRetrieve(account.Id));
}
// ---------------------------------------------------------------- helpers
private static void AssertNoTokenLeaked(string text)
{
Assert.DoesNotContain(OldToken, text);
Assert.DoesNotContain(NewToken, text);
}
/// <summary>
/// An <see cref="ITokenVault"/> over <see cref="InMemoryVault"/> whose Store and
/// Delete can be made to throw (from a given call number on) without changing
/// state — the contract a real secret store keeps on failure.
/// </summary>
private sealed class FaultableVault : ITokenVault
{
private readonly InMemoryVault _inner = new();
public Exception? StoreFailure { get; set; }
public int StoreFailureFromCall { get; set; } = 1;
public Exception? DeleteFailure { get; set; }
public int StoreCalls { get; private set; }
public int DeleteCalls { get; private set; }
public int RetrieveCalls { get; private set; }
public void ResetCounters() => StoreCalls = DeleteCalls = RetrieveCalls = 0;
public void Store(Guid accountId, string token)
{
StoreCalls++;
if (StoreFailure is not null && StoreCalls >= StoreFailureFromCall)
{
throw StoreFailure;
}
_inner.Store(accountId, token);
}
public string? TryRetrieve(Guid accountId)
{
RetrieveCalls++;
return _inner.TryRetrieve(accountId);
}
public void Delete(Guid accountId)
{
DeleteCalls++;
if (DeleteFailure is not null)
{
throw DeleteFailure;
}
_inner.Delete(accountId);
}
}
private sealed class RecordingLog : IActivityLog
{
public List<(string Message, Exception? Exception)> Errors { get; } = [];
public void Info(string message)
{
}
public void Error(string message, Exception? exception = null) => Errors.Add((message, exception));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
private const string OldToken = "ghp_OLD_TOKEN_VALUE";
private const string NewToken = "ghp_NEW_TOKEN_VALUE";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly FaultableVault _vault = new();
private readonly RecordingLog _log = new();
public void Dispose()
{
UnblockMetadataWrites();
GitTestHelpers.TryDeleteDirectory(_root);
}
private AccountsStore NewStore() => new(_vault, _root, _log);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
/// <summary>
/// Makes the next metadata write fail before it changes anything: the store writes
/// to accounts.json.tmp first, and a directory squatting on that name makes the
/// FileStream creation throw UnauthorizedAccessException.
/// </summary>
private void BlockMetadataWrites()
=> Directory.CreateDirectory(Path.Combine(_root, "accounts.json.tmp"));
private void UnblockMetadataWrites()
{
string squatter = Path.Combine(_root, "accounts.json.tmp");
if(Directory.Exists(squatter))
{
Directory.Delete(squatter);
}
}
private IReadOnlyList<Account> OnDisk() => new AccountsStore(new InMemoryVault(), _root).GetAll();
// ---------------------------------------------------------------- save: vault write fails
[Fact]
public void Save_NewAccount_VaultWriteFails_ChangesNothing()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
_vault.StoreFailure = new InvalidOperationException("credential manager locked");
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => store.Save(account, NewToken));
Assert.Equal("credential manager locked", ex.Message);
Assert.Empty(store.GetAll());
Assert.False(File.Exists(Path.Combine(_root, "accounts.json")), "metadata must not be written before the vault");
Assert.Null(_vault.TryRetrieve(account.Id));
}
[Fact]
public void Save_UpdateWithNewToken_VaultWriteFails_KeepsPriorMetadataAndPriorToken()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.StoreFailure = new InvalidOperationException("credential manager locked");
Assert.Throws<InvalidOperationException>(
() => store.Save(account with { Description = "edited" }, NewToken));
// The replacement failed, so the account keeps the credential that worked.
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Equal("", Assert.Single(store.GetAll()).Description);
Assert.Equal("", Assert.Single(OnDisk()).Description);
}
// ---------------------------------------------------------------- save: metadata write fails
[Fact]
public void Save_NewAccount_MetadataWriteFails_RemovesTheVaultEntryAgain()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account, NewToken));
// No vault entry may point at an account that was never saved.
Assert.Null(_vault.TryRetrieve(account.Id));
Assert.Empty(store.GetAll());
Assert.Equal(1, _vault.DeleteCalls);
}
[Fact]
public void Save_UpdateWithNewToken_MetadataWriteFails_RestoresThePreviousToken()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { Description = "edited" }, NewToken));
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Equal("", Assert.Single(store.GetAll()).Description);
Assert.Equal("", Assert.Single(OnDisk()).Description);
}
[Fact]
public void Save_WithoutToken_MetadataWriteFails_NeverTouchesTheVault()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.ResetCounters();
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { Description = "edited" }, token: null));
Assert.Equal(0, _vault.StoreCalls + _vault.DeleteCalls + _vault.RetrieveCalls);
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
}
// ---------------------------------------------------------------- save: compensation fails
[Fact]
public void Save_NewAccount_MetadataAndCompensationFail_SurfacesBothWithoutTheToken()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
BlockMetadataWrites();
_vault.DeleteFailure = new InvalidOperationException("delete refused");
AccountConsistencyException ex = Assert.Throws<AccountConsistencyException>(() => store.Save(account, NewToken));
Assert.IsType<UnauthorizedAccessException>(ex.InnerException);
Assert.Equal("delete refused", ex.CompensationException.Message);
Assert.Contains("not in accounts.json", ex.Message);
Assert.Contains(account.Id.ToString("N"), ex.Message);
AssertNoTokenLeaked(ex.Message);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id)); // the documented leftover
Assert.Empty(store.GetAll());
(string Message, Exception? Exception) logged = Assert.Single(_log.Errors);
Assert.Contains("could not be restored", logged.Message);
AssertNoTokenLeaked(logged.Message);
}
[Fact]
public void Save_Update_MetadataAndCompensationFail_SurfacesBothWithoutTheToken()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.ResetCounters();
_vault.StoreFailureFromCall = 2; // the replacement write succeeds, the restore does not
_vault.StoreFailure = new InvalidOperationException("restore refused");
AccountConsistencyException ex = Assert.Throws<AccountConsistencyException>(
() => store.Save(account with { Description = "edited" }, NewToken));
Assert.Equal("restore refused", ex.CompensationException.Message);
Assert.Contains("previous metadata", ex.Message);
AssertNoTokenLeaked(ex.Message);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id)); // the documented leftover
Assert.Equal("", Assert.Single(store.GetAll()).Description);
AssertNoTokenLeaked(Assert.Single(_log.Errors).Message);
}
// ---------------------------------------------------------------- delete
[Fact]
public void Delete_VaultDeleteFails_ChangesNothing()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
_vault.DeleteFailure = new InvalidOperationException("delete refused");
Assert.Throws<InvalidOperationException>(() => store.Delete(account.Id));
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Single(store.GetAll());
Assert.Single(OnDisk());
}
[Fact]
public void Delete_MetadataWriteFails_RestoresTheTokenAndKeepsTheAccount()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(account.Id));
// Not orphaned, not lost: the account is still listed AND still has its token.
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
Assert.Single(store.GetAll());
Assert.Single(OnDisk());
}
[Fact]
public void Delete_AccountWithoutToken_MetadataWriteFails_HasNothingToRestore()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, token: null);
_vault.ResetCounters();
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(account.Id));
Assert.Equal(0, _vault.StoreCalls);
Assert.Single(store.GetAll());
}
[Fact]
public void Delete_MetadataAndCompensationFail_SurfacesBothWithoutTheToken()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.StoreFailure = new InvalidOperationException("restore refused");
AccountConsistencyException ex = Assert.Throws<AccountConsistencyException>(() => store.Delete(account.Id));
Assert.IsType<UnauthorizedAccessException>(ex.InnerException);
Assert.Equal("restore refused", ex.CompensationException.Message);
Assert.Contains("still listed", ex.Message);
AssertNoTokenLeaked(ex.Message);
Assert.Null(_vault.TryRetrieve(account.Id)); // the documented leftover
Assert.Single(store.GetAll());
AssertNoTokenLeaked(Assert.Single(_log.Errors).Message);
}
// ---------------------------------------------------------------- retry reconciles every leftover
[Fact]
public void Save_RetryAfterMetadataFailure_Succeeds()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account, NewToken));
UnblockMetadataWrites();
store.Save(account, NewToken);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id));
Assert.Equal(account, Assert.Single(OnDisk()));
}
[Fact]
public void Save_RetryAfterOrphanedVaultEntry_Reconciles()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
BlockMetadataWrites();
_vault.DeleteFailure = new InvalidOperationException("delete refused");
Assert.Throws<AccountConsistencyException>(() => store.Save(account, NewToken));
UnblockMetadataWrites();
_vault.DeleteFailure = null;
store.Save(account, NewToken);
Assert.Equal(NewToken, _vault.TryRetrieve(account.Id));
Assert.Equal(account, Assert.Single(store.GetAll()));
}
[Fact]
public void Delete_RetryAfterTokenRestoreFailed_CompletesTheRemoval()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
_vault.StoreFailure = new InvalidOperationException("restore refused");
Assert.Throws<AccountConsistencyException>(() => store.Delete(account.Id));
UnblockMetadataWrites();
_vault.StoreFailure = null;
store.Delete(account.Id); // the vault entry is already gone; that must be fine
Assert.Empty(store.GetAll());
Assert.Empty(OnDisk());
Assert.Null(_vault.TryRetrieve(account.Id));
}
[Fact]
public void Delete_RetryAfterMetadataFailure_Succeeds()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, OldToken);
BlockMetadataWrites();
Assert.Throws<UnauthorizedAccessException>(() => store.Delete(account.Id));
UnblockMetadataWrites();
store.Delete(account.Id);
Assert.Empty(store.GetAll());
Assert.Null(_vault.TryRetrieve(account.Id));
}
// ---------------------------------------------------------------- helpers
private static void AssertNoTokenLeaked(string text)
{
Assert.DoesNotContain(OldToken, text);
Assert.DoesNotContain(NewToken, text);
}
/// <summary>
/// An <see cref="ITokenVault"/> over <see cref="InMemoryVault"/> whose Store and
/// Delete can be made to throw (from a given call number on) without changing
/// state — the contract a real secret store keeps on failure.
/// </summary>
private sealed class FaultableVault : ITokenVault
{
private readonly InMemoryVault _inner = new();
public Exception? StoreFailure { get; set; }
public int StoreFailureFromCall { get; set; } = 1;
public Exception? DeleteFailure { get; set; }
public int StoreCalls { get; private set; }
public int DeleteCalls { get; private set; }
public int RetrieveCalls { get; private set; }
public void ResetCounters() => StoreCalls = DeleteCalls = RetrieveCalls = 0;
public void Store(Guid accountId, string token)
{
StoreCalls++;
if(StoreFailure is not null && StoreCalls >= StoreFailureFromCall)
{
throw StoreFailure;
}
_inner.Store(accountId, token);
}
public string? TryRetrieve(Guid accountId)
{
RetrieveCalls++;
return _inner.TryRetrieve(accountId);
}
public void Delete(Guid accountId)
{
DeleteCalls++;
if(DeleteFailure is not null)
{
throw DeleteFailure;
}
_inner.Delete(accountId);
}
}
private sealed class RecordingLog : IActivityLog
{
public List<(string Message, Exception? Exception)> Errors { get; } = [];
public void Info(string message)
{
}
public void Error(string message, Exception? exception = null) => Errors.Add((message, exception));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
}
+202 -162
View File
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="AccountsStore"/>: JSON round-trips across store instances,
/// case-insensitive lookups and name uniqueness, vault coordination, and tolerance
@@ -10,212 +17,245 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class AccountsStoreTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
private readonly InMemoryVault _vault = new();
private AccountsStore NewStore() => new(_vault, _root);
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
// ---------------------------------------------------------------- persistence
public void Dispose() => GitTestHelpers.TryDeleteDirectory(_root);
[Fact]
public void Crud_RoundTrips_AcrossStoreInstances()
{
var account = MakeAccount("Work") with
{
Description = "primary org",
CreateOrgSubfolder = true,
MaxConcurrency = 4,
};
NewStore().Save(account, token: null);
Assert.Equal(account, Assert.Single(NewStore().GetAll()));
var edited = account with { Name = "Work (new)", Organization = "acme-2" };
NewStore().Save(edited, token: null);
Assert.Equal(edited, Assert.Single(NewStore().GetAll()));
private AccountsStore NewStore() => new(_vault, _root);
NewStore().Delete(account.Id);
Assert.Empty(NewStore().GetAll());
}
[Fact]
public void FreshDirectory_YieldsEmptyList()
{
Assert.Empty(NewStore().GetAll());
}
// ---------------------------------------------------------------- queries
private static Account MakeAccount(string name) => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "acme",
TargetRoot = @"C:\repos",
};
[Fact]
public void GetAll_SortsByName_CaseInsensitively()
{
var store = NewStore();
store.Save(MakeAccount("Charlie"), null);
store.Save(MakeAccount("beta"), null);
store.Save(MakeAccount("Alpha"), null);
// Ordinal (case-sensitive) order would be Alpha, Charlie, beta.
Assert.Equal(
new[] { "Alpha", "beta", "Charlie" },
store.GetAll().Select(a => a.Name));
}
[Fact]
public void FindByName_MatchesCaseInsensitively()
{
var store = NewStore();
var account = MakeAccount("Work");
store.Save(account, null);
// ---------------------------------------------------------------- persistence
Assert.Equal(account, store.FindByName("WORK"));
Assert.Equal(account, store.FindByName("work"));
Assert.Null(store.FindByName("missing"));
}
[Fact]
public void Crud_RoundTrips_AcrossStoreInstances()
{
Account account = MakeAccount("Work") with
{
Description = "primary org",
CreateOrgSubfolder = true,
MaxConcurrency = 4,
};
// ---------------------------------------------------------------- name uniqueness
NewStore().Save(account, token: null);
Assert.Equal(account, Assert.Single(NewStore().GetAll()));
[Theory]
[InlineData("Work")]
[InlineData("WORK")]
[InlineData("work")]
public void Save_DuplicateNameOnDifferentId_ThrowsAndPersistsNothing(string duplicateName)
{
NewStore().Save(MakeAccount("Work"), null);
var duplicate = MakeAccount(duplicateName);
Account edited = account with { Name = "Work (new)", Organization = "acme-2" };
NewStore().Save(edited, token: null);
Assert.Equal(edited, Assert.Single(NewStore().GetAll()));
Assert.Throws<ArgumentException>(() => NewStore().Save(duplicate, "secret"));
NewStore().Delete(account.Id);
Assert.Empty(NewStore().GetAll());
}
var survivor = Assert.Single(NewStore().GetAll());
Assert.Equal("Work", survivor.Name);
Assert.Null(_vault.TryRetrieve(duplicate.Id));
}
[Fact]
public void Save_SameIdWithSameName_IsAnUpdateNotADuplicate()
{
var account = MakeAccount("Work");
var store = NewStore();
store.Save(account, null);
store.Save(account with { Description = "edited" }, null);
[Fact]
public void FreshDirectory_YieldsEmptyList()
{
Assert.Empty(NewStore().GetAll());
}
Assert.Equal("edited", Assert.Single(store.GetAll()).Description);
}
// ---------------------------------------------------------------- vault coordination
[Fact]
public void Save_WithToken_StoresItInTheVault()
{
var account = MakeAccount("Work");
// ---------------------------------------------------------------- queries
NewStore().Save(account, "ghp_secret");
[Fact]
public void GetAll_SortsByName_CaseInsensitively()
{
AccountsStore store = NewStore();
store.Save(MakeAccount("Charlie"), null);
store.Save(MakeAccount("beta"), null);
store.Save(MakeAccount("Alpha"), null);
Assert.Equal("ghp_secret", _vault.TryRetrieve(account.Id));
}
// Ordinal (case-sensitive) order would be Alpha, Charlie, beta.
Assert.Equal(
new[] { "Alpha", "beta", "Charlie" },
store.GetAll().Select(a => a.Name), StringComparer.Ordinal);
}
[Fact]
public void Save_WithNullToken_LeavesVaultUntouched()
{
var account = MakeAccount("Work");
var store = NewStore();
store.Save(account, "original");
store.Save(account with { Description = "edited" }, null);
Assert.Equal("original", _vault.TryRetrieve(account.Id));
}
[Fact]
public void FindByName_MatchesCaseInsensitively()
{
AccountsStore store = NewStore();
Account account = MakeAccount("Work");
store.Save(account, null);
[Fact]
public void Delete_RemovesMetadataAndVaultEntry_AndIsIdempotent()
{
var account = MakeAccount("Work");
var store = NewStore();
store.Save(account, "secret");
Assert.Equal(account, store.FindByName("WORK"));
Assert.Equal(account, store.FindByName("work"));
Assert.Null(store.FindByName("missing"));
}
store.Delete(account.Id);
Assert.Empty(store.GetAll());
Assert.Empty(NewStore().GetAll());
Assert.Null(_vault.TryRetrieve(account.Id));
store.Delete(account.Id); // deleting again must not throw
}
// ---------------------------------------------------------------- name uniqueness
// ---------------------------------------------------------------- sync results
[Theory]
[InlineData("Work")]
[InlineData("WORK")]
[InlineData("work")]
public void Save_DuplicateNameOnDifferentId_ThrowsAndPersistsNothing(string duplicateName)
{
NewStore().Save(MakeAccount("Work"), null);
Account duplicate = MakeAccount(duplicateName);
[Fact]
public void RecordSyncResult_UpdatesOnlySyncFields_AndPersists()
{
var account = MakeAccount("Work") with { Description = "keep me", MaxConcurrency = 4 };
NewStore().Save(account, null);
var finished = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero);
Assert.Throws<ArgumentException>(() => NewStore().Save(duplicate, "secret"));
NewStore().RecordSyncResult(account.Id, finished, "12 cloned, 3 updated, 0 failed");
Account survivor = Assert.Single(NewStore().GetAll());
Assert.Equal("Work", survivor.Name);
Assert.Null(_vault.TryRetrieve(duplicate.Id));
}
var updated = Assert.Single(NewStore().GetAll());
Assert.Equal(
account with { LastSyncUtc = finished, LastSyncSummary = "12 cloned, 3 updated, 0 failed" },
updated);
}
// ---------------------------------------------------------------- load tolerance
[Fact]
public void CorruptFile_YieldsEmptyList_AndSubsequentSaveWorks()
{
Directory.CreateDirectory(_root);
File.WriteAllText(Path.Combine(_root, "accounts.json"), "{ this is not json !!");
[Fact]
public void Save_SameIdWithSameName_IsAnUpdateNotADuplicate()
{
Account account = MakeAccount("Work");
AccountsStore store = NewStore();
store.Save(account, null);
var store = NewStore();
Assert.Empty(store.GetAll());
store.Save(account with { Description = "edited" }, null);
var account = MakeAccount("Recovered");
store.Save(account, null);
Assert.Equal(account, Assert.Single(NewStore().GetAll()));
}
Assert.Equal("edited", Assert.Single(store.GetAll()).Description);
}
[Fact]
public void Load_CorruptFile_IsPreservedAside_SoSaveCannotClobberIt()
{
Directory.CreateDirectory(_root);
string path = Path.Combine(_root, "accounts.json");
File.WriteAllText(path, "{ not valid json");
var store = NewStore();
Assert.Empty(store.GetAll());
// The unreadable original must survive as evidence for token re-association.
string? preserved = Directory.GetFiles(_root, "accounts.json.corrupt-*").SingleOrDefault();
Assert.NotNull(preserved);
Assert.Equal("{ not valid json", File.ReadAllText(preserved));
// ---------------------------------------------------------------- vault coordination
store.Save(MakeAccount("Fresh"), null);
Assert.Equal("{ not valid json", File.ReadAllText(preserved)); // untouched
}
[Fact]
public void Save_WithToken_StoresItInTheVault()
{
Account account = MakeAccount("Work");
[Fact]
public void Persist_ReplacesAtomically_KeepingABackupOfThePreviousFile()
{
var store = NewStore();
store.Save(MakeAccount("First"), null);
store.Save(MakeAccount("Second"), null);
NewStore().Save(account, "ghp_secret");
// The atomic File.Replace keeps the prior generation as .bak, and no
// temp file is left behind.
Assert.True(File.Exists(Path.Combine(_root, "accounts.json.bak")));
Assert.False(File.Exists(Path.Combine(_root, "accounts.json.tmp")));
Assert.Equal(2, NewStore().GetAll().Count);
}
Assert.Equal("ghp_secret", _vault.TryRetrieve(account.Id));
}
[Fact]
public void Save_WithNullToken_LeavesVaultUntouched()
{
Account account = MakeAccount("Work");
AccountsStore store = NewStore();
store.Save(account, "original");
store.Save(account with { Description = "edited" }, null);
Assert.Equal("original", _vault.TryRetrieve(account.Id));
}
[Fact]
public void Delete_RemovesMetadataAndVaultEntry_AndIsIdempotent()
{
Account account = MakeAccount("Work");
AccountsStore store = NewStore();
store.Save(account, "secret");
store.Delete(account.Id);
Assert.Empty(store.GetAll());
Assert.Empty(NewStore().GetAll());
Assert.Null(_vault.TryRetrieve(account.Id));
store.Delete(account.Id); // deleting again must not throw
}
// ---------------------------------------------------------------- sync results
[Fact]
public void RecordSyncResult_UpdatesOnlySyncFields_AndPersists()
{
Account account = MakeAccount("Work") with { Description = "keep me", MaxConcurrency = 4 };
NewStore().Save(account, null);
var finished = new DateTimeOffset(2026, 7, 4, 12, 0, 0, TimeSpan.Zero);
NewStore().RecordSyncResult(account.Id, finished, "12 cloned, 3 updated, 0 failed");
Account updated = Assert.Single(NewStore().GetAll());
Assert.Equal(
account with { LastSyncUtc = finished, LastSyncSummary = "12 cloned, 3 updated, 0 failed" },
updated);
}
// ---------------------------------------------------------------- load tolerance
[Fact]
public void CorruptFile_YieldsEmptyList_AndSubsequentSaveWorks()
{
Directory.CreateDirectory(_root);
File.WriteAllText(Path.Combine(_root, "accounts.json"), "{ this is not json !!");
AccountsStore store = NewStore();
Assert.Empty(store.GetAll());
Account account = MakeAccount("Recovered");
store.Save(account, null);
Assert.Equal(account, Assert.Single(NewStore().GetAll()));
}
[Fact]
public void Load_CorruptFile_IsPreservedAside_SoSaveCannotClobberIt()
{
Directory.CreateDirectory(_root);
string path = Path.Combine(_root, "accounts.json");
File.WriteAllText(path, "{ not valid json");
AccountsStore store = NewStore();
Assert.Empty(store.GetAll());
// The unreadable original must survive as evidence for token re-association.
string? preserved = Directory.GetFiles(_root, "accounts.json.corrupt-*").SingleOrDefault();
Assert.NotNull(preserved);
Assert.Equal("{ not valid json", File.ReadAllText(preserved));
store.Save(MakeAccount("Fresh"), null);
Assert.Equal("{ not valid json", File.ReadAllText(preserved)); // untouched
}
[Fact]
public void Persist_ReplacesAtomically_KeepingABackupOfThePreviousFile()
{
AccountsStore store = NewStore();
store.Save(MakeAccount("First"), null);
store.Save(MakeAccount("Second"), null);
// The atomic File.Replace keeps the prior generation as .bak, and no
// temp file is left behind.
Assert.True(File.Exists(Path.Combine(_root, "accounts.json.bak")));
Assert.False(File.Exists(Path.Combine(_root, "accounts.json.tmp")));
Assert.Equal(2, NewStore().GetAll().Count);
}
}
+179 -145
View File
@@ -1,9 +1,15 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Pins the activity-log contract of #40: every user- or app-initiated action in the
/// view-model layer writes a human-readable entry, and no entry ever contains a token.
@@ -12,184 +18,212 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class ActionLoggingTests : IDisposable
{
private const string Token = "ghp_SECRET_TOKEN_VALUE_1234";
private const string Token = "ghp_SECRET_TOKEN_VALUE_1234";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly RecordingActivityLog _log = new();
private readonly FakeRepositoryLister _lister = new();
private readonly FakeGitClient _git = new();
private readonly FakeOrganizationLister _orgs = new();
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose() => TryDeleteDirectory(_root);
private readonly RecordingActivityLog _log = new();
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
private readonly FakeRepositoryLister _lister = new();
private readonly FakeWslCloner _wsl = new();
private readonly FakeGitClient _git = new();
private WorkspaceViewModel CreateViewModel()
=> new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log, wsl: _wsl);
private readonly FakeOrganizationLister _orgs = new();
private void AssertNoTokenLogged()
=> Assert.All(_log.Messages, m => Assert.DoesNotContain(Token, m));
// ---------------------------------------------------------------- workspace
[Fact]
public async Task Workspace_TokenEntry_AndOrganizationLookup_AreLogged_WithoutTheToken()
{
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme"]);
var vm = CreateViewModel();
public void Dispose() => TryDeleteDirectory(_root);
vm.Token = Token;
await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to finish");
Assert.Contains(_log.Messages, m => m == $"Token entered in workspace 'Quick Sync' ({Token.Length} characters).");
Assert.Contains("Organization lookup started.", _log.Messages);
Assert.Contains("Organization lookup finished: 2 organizations and accounts visible.", _log.Messages);
AssertNoTokenLogged();
vm.Token = "";
Assert.Contains("Token cleared in workspace 'Quick Sync'.", _log.Messages);
}
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
[Fact]
public async Task Workspace_OrganizationLookupFailure_IsLoggedAsAnError()
{
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(new InvalidOperationException("401"));
var vm = CreateViewModel();
vm.Token = Token;
await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface");
Assert.Contains(_log.Entries, e => e.Level == "ERROR" && e.Message == "Organization lookup failed: 401");
AssertNoTokenLogged();
}
private readonly FakeWslCloner _wsl = new();
[Fact]
public async Task Workspace_RetryFailed_PathRecovery_AndFolderOpened_AreLogged()
{
_lister.Repositories = Repos("alpha");
var vm = CreateViewModel();
vm.Organization = "acme";
vm.Token = Token;
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
vm.TargetFolder = _root;
await vm.LoadReposCommand.ExecuteAsync(null);
var invalid = new List<InvalidPathInfo> { new("bad:name.txt", "invalid on Windows", "bad_name.txt") };
_git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid));
await vm.SyncCommand.ExecuteAsync(null);
Assert.True(vm.RetryFailedCommand.CanExecute(null));
await vm.RetryFailedCommand.ExecuteAsync(null);
Assert.Contains("Retrying 1 failed repositories.", _log.Messages);
// The user opens recovery and cancels it.
_wsl.Availability = new WslAvailability(false, "wsl.exe is not installed");
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(null);
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("alpha: path recovery requested (1 invalid paths).", _log.Messages);
Assert.Contains("WSL clone not available: wsl.exe is not installed", _log.Messages);
Assert.Contains("alpha: path recovery canceled; the repository stays failed.", _log.Messages);
private WorkspaceViewModel CreateViewModel()
=> new(_lister, _git, _orgs, handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), _log, wsl: _wsl);
vm.NoteFolderOpened();
Assert.Contains($"Opened folder '{vm.EffectiveTargetRoot}'.", _log.Messages);
AssertNoTokenLogged();
}
[Fact]
public async Task Workspace_CloneInWsl_IsLogged_WithoutTheToken()
{
_lister.Repositories = Repos("alpha");
var vm = CreateViewModel();
vm.Organization = "acme";
vm.Token = Token;
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
vm.TargetFolder = _root;
await vm.LoadReposCommand.ExecuteAsync(null);
var invalid = new List<InvalidPathInfo> { new("bad:name.txt", "invalid on Windows", "bad_name.txt") };
_git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid));
await vm.SyncCommand.ExecuteAsync(null);
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("WSL clone available (git version 2.53.0).", _log.Messages);
Assert.Contains("alpha: cloning in WSL instead.", _log.Messages);
Assert.Contains("alpha: cloned in WSL (Ubuntu) at /home/me/gclo/acme/alpha.", _log.Messages);
private void AssertNoTokenLogged()
=> Assert.All(_log.Messages, m => Assert.DoesNotContain(Token, m));
_wsl.CloneHandler = (_, _) => Task.FromException<WslCloneResult>(new WslCloneException("Clone in WSL failed: boom"));
vm.Repos[0].InvalidPaths = invalid;
vm.Repos[0].Status = SyncStatus.Failed;
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("alpha: clone in WSL failed: Clone in WSL failed: boom", _log.Messages);
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- accounts
[Fact]
public void AccountsStore_SaveUpdateDelete_AndSyncResult_AreLogged_WithoutTheToken()
{
var store = new AccountsStore(new InMemoryVault(), _root, _log);
var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\r" };
// ---------------------------------------------------------------- workspace
store.Save(account, Token);
Assert.Contains("Account 'Work' added with a new token.", _log.Messages);
[Fact]
public async Task Workspace_TokenEntry_AndOrganizationLookup_AreLogged_WithoutTheToken()
{
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme"]);
WorkspaceViewModel vm = CreateViewModel();
store.Save(account with { Description = "edited" }, null);
Assert.Contains("Account 'Work' updated (token unchanged).", _log.Messages);
vm.Token = Token;
await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to finish");
store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, "1 cloned");
Assert.Contains("Account 'Work': sync result recorded (1 cloned).", _log.Messages);
Assert.Contains(_log.Messages, m => string.Equals(m, $"Token entered in workspace 'Quick Sync' ({Token.Length} characters).", StringComparison.Ordinal));
Assert.Contains("Organization lookup started.", _log.Messages, StringComparer.Ordinal);
Assert.Contains("Organization lookup finished: 2 organizations and accounts visible.", _log.Messages, StringComparer.Ordinal);
AssertNoTokenLogged();
store.Delete(account.Id);
Assert.Contains("Account 'Work' deleted along with its token.", _log.Messages);
vm.Token = "";
Assert.Contains("Token cleared in workspace 'Quick Sync'.", _log.Messages, StringComparer.Ordinal);
}
var tokenless = new Account { Id = Guid.NewGuid(), Name = "Bare", Organization = "acme", TargetRoot = @"C:\r" };
store.Save(tokenless, null);
store.Delete(tokenless.Id);
Assert.Contains("Account 'Bare' deleted (it had no token).", _log.Messages);
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- wizard
[Fact]
public async Task Wizard_TokenValidation_IsLogged_WithoutTheToken()
{
var store = new AccountsStore(new InMemoryVault(), _root, _log);
var defaults = new AppSettings();
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme", "labs"]);
var wizard = new AccountWizardViewModel(store, _orgs, defaults, log: _log) { Name = "Work", Token = Token };
Assert.True(await wizard.TryAdvanceAsync());
[Fact]
public async Task Workspace_OrganizationLookupFailure_IsLoggedAsAnError()
{
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(new InvalidOperationException("401"));
WorkspaceViewModel vm = CreateViewModel();
Assert.True(await wizard.TryAdvanceAsync());
Assert.Contains("Account wizard: validating the token.", _log.Messages);
Assert.Contains("Account wizard: token accepted; 3 organizations and accounts visible.", _log.Messages);
vm.Token = Token;
await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface");
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(new InvalidOperationException("nope"));
var rejected = new AccountWizardViewModel(store, _orgs, new AccountWizardSeed(Token, "acme", @"C:\r", false, 4), _log) { Name = "Other" };
Assert.True(await rejected.TryAdvanceAsync());
Assert.False(await rejected.TryAdvanceAsync());
Assert.Contains(_log.Entries, e => e.Level == "ERROR" && e.Message == "Account wizard: token rejected: nope");
AssertNoTokenLogged();
}
Assert.Contains(_log.Entries, e => string.Equals(e.Level, "ERROR", StringComparison.Ordinal) && string.Equals(e.Message, "Organization lookup failed: 401", StringComparison.Ordinal));
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- sync all
[Fact]
public async Task SyncAll_QueueIsLogged()
{
var coordinator = new SyncAllCoordinator(_log);
var a = CreateViewModel();
var b = CreateViewModel();
await coordinator.RunAsync([a, b], CancellationToken.None);
[Fact]
public async Task Workspace_RetryFailed_PathRecovery_AndFolderOpened_AreLogged()
{
_lister.Repositories = Repos("alpha");
WorkspaceViewModel vm = CreateViewModel();
vm.Organization = "acme";
vm.Token = Token;
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
vm.TargetFolder = _root;
await vm.LoadReposCommand.ExecuteAsync(null);
var invalid = new List<InvalidPathInfo> { new("bad:name.txt", "invalid on Windows", "bad_name.txt") };
_git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid));
await vm.SyncCommand.ExecuteAsync(null);
Assert.True(vm.RetryFailedCommand.CanExecute(null));
Assert.Contains("Sync all: queued 2 accounts: 'Quick Sync', 'Quick Sync'.", _log.Messages);
}
await vm.RetryFailedCommand.ExecuteAsync(null);
Assert.Contains("Retrying 1 failed repositories.", _log.Messages, StringComparer.Ordinal);
// The user opens recovery and cancels it.
_wsl.Availability = new WslAvailability(false, "wsl.exe is not installed");
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(null);
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("alpha: path recovery requested (1 invalid paths).", _log.Messages, StringComparer.Ordinal);
Assert.Contains("WSL clone not available: wsl.exe is not installed", _log.Messages, StringComparer.Ordinal);
Assert.Contains("alpha: path recovery canceled; the repository stays failed.", _log.Messages, StringComparer.Ordinal);
vm.NoteFolderOpened();
Assert.Contains($"Opened folder '{vm.EffectiveTargetRoot}'.", _log.Messages, StringComparer.Ordinal);
AssertNoTokenLogged();
}
[Fact]
public async Task Workspace_CloneInWsl_IsLogged_WithoutTheToken()
{
_lister.Repositories = Repos("alpha");
WorkspaceViewModel vm = CreateViewModel();
vm.Organization = "acme";
vm.Token = Token;
await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle");
vm.TargetFolder = _root;
await vm.LoadReposCommand.ExecuteAsync(null);
var invalid = new List<InvalidPathInfo> { new("bad:name.txt", "invalid on Windows", "bad_name.txt") };
_git.CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(invalid));
await vm.SyncCommand.ExecuteAsync(null);
_wsl.Availability = new WslAvailability(true, "git version 2.53.0");
vm.RecoveryInteraction = _ => Task.FromResult<PathRecoveryDecision?>(new PathRecoveryDecision.CloneInWsl());
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("WSL clone available (git version 2.53.0).", _log.Messages, StringComparer.Ordinal);
Assert.Contains("alpha: cloning in WSL instead.", _log.Messages, StringComparer.Ordinal);
Assert.Contains("alpha: cloned in WSL (Ubuntu) at /home/me/gclo/acme/alpha.", _log.Messages, StringComparer.Ordinal);
_wsl.CloneHandler = (_, _) => Task.FromException<WslCloneResult>(new WslCloneException("Clone in WSL failed: boom"));
vm.Repos[0].InvalidPaths = invalid;
vm.Repos[0].Status = SyncStatus.Failed;
await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]);
Assert.Contains("alpha: clone in WSL failed: Clone in WSL failed: boom", _log.Messages, StringComparer.Ordinal);
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- accounts
[Fact]
public void AccountsStore_SaveUpdateDelete_AndSyncResult_AreLogged_WithoutTheToken()
{
var store = new AccountsStore(new InMemoryVault(), _root, _log);
var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\r" };
store.Save(account, Token);
Assert.Contains("Account 'Work' added with a new token.", _log.Messages, StringComparer.Ordinal);
store.Save(account with { Description = "edited" }, null);
Assert.Contains("Account 'Work' updated (token unchanged).", _log.Messages, StringComparer.Ordinal);
store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, "1 cloned");
Assert.Contains("Account 'Work': sync result recorded (1 cloned).", _log.Messages, StringComparer.Ordinal);
store.Delete(account.Id);
Assert.Contains("Account 'Work' deleted along with its token.", _log.Messages, StringComparer.Ordinal);
var tokenless = new Account { Id = Guid.NewGuid(), Name = "Bare", Organization = "acme", TargetRoot = @"C:\r" };
store.Save(tokenless, null);
store.Delete(tokenless.Id);
Assert.Contains("Account 'Bare' deleted (it had no token).", _log.Messages, StringComparer.Ordinal);
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- wizard
[Fact]
public async Task Wizard_TokenValidation_IsLogged_WithoutTheToken()
{
var store = new AccountsStore(new InMemoryVault(), _root, _log);
var defaults = new AppSettings();
_orgs.Handler = (_, _) => Task.FromResult<IReadOnlyList<string>>(["me", "acme", "labs"]);
var wizard = new AccountWizardViewModel(store, _orgs, defaults, log: _log) { Name = "Work", Token = Token };
Assert.True(await wizard.TryAdvanceAsync());
Assert.True(await wizard.TryAdvanceAsync());
Assert.Contains("Account wizard: validating the token.", _log.Messages, StringComparer.Ordinal);
Assert.Contains("Account wizard: token accepted; 3 organizations and accounts visible.", _log.Messages, StringComparer.Ordinal);
_orgs.Handler = (_, _) => Task.FromException<IReadOnlyList<string>>(new InvalidOperationException("nope"));
var rejected = new AccountWizardViewModel(store, _orgs, new AccountWizardSeed(Token, "acme", @"C:\r", false, 4), _log) { Name = "Other" };
Assert.True(await rejected.TryAdvanceAsync());
Assert.False(await rejected.TryAdvanceAsync());
Assert.Contains(_log.Entries, e => string.Equals(e.Level, "ERROR", StringComparison.Ordinal) && string.Equals(e.Message, "Account wizard: token rejected: nope", StringComparison.Ordinal));
AssertNoTokenLogged();
}
// ---------------------------------------------------------------- sync all
[Fact]
public async Task SyncAll_QueueIsLogged()
{
var coordinator = new SyncAllCoordinator(_log);
WorkspaceViewModel a = CreateViewModel();
WorkspaceViewModel b = CreateViewModel();
await coordinator.RunAsync([a, b], CancellationToken.None);
Assert.Contains("Sync all: queued 2 accounts: 'Quick Sync', 'Quick Sync'.", _log.Messages, StringComparer.Ordinal);
}
}
+169 -141
View File
@@ -1,9 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Text.RegularExpressions;
using gclo.Engine;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="FileActivityLog"/> (line format, daily file naming, lazy
/// directory creation, swallowing IO failures, thread safety) and
@@ -11,181 +17,203 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class ActivityLogTests : IDisposable
{
/// <summary>"yyyy-MM-dd HH:mm:ss.fff [LEVEL] " prefix every entry line must carry.</summary>
private static readonly Regex LinePattern = new(
@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \[(INFO|ERROR)\] ", RegexOptions.Compiled);
/// <summary>"yyyy-MM-dd HH:mm:ss.fff [LEVEL] " prefix every entry line must carry.</summary>
private static readonly Regex s_linePattern = new(
@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \[(INFO|ERROR)\] ", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1));
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose()
{
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// Best effort; stray empty temp dirs are harmless.
}
}
// ---------------------------------------------------------------- line format
[Fact]
public void Info_WritesOneTimestampedLine_WithInfoTag()
{
var log = new FileActivityLog(_root);
public void Dispose()
{
try
{
if(Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch
{
// Best effort; stray empty temp dirs are harmless.
}
}
log.Info("hello world");
string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath));
Assert.Matches(LinePattern, line);
Assert.Contains("[INFO] ", line);
Assert.EndsWith("hello world", line);
}
[Fact]
public void Error_WithoutException_WritesOneLineWithErrorTag()
{
var log = new FileActivityLog(_root);
// ---------------------------------------------------------------- line format
log.Error("something failed");
[Fact]
public void Info_WritesOneTimestampedLine_WithInfoTag()
{
var log = new FileActivityLog(_root);
string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath));
Assert.Matches(LinePattern, line);
Assert.Contains("[ERROR] ", line);
Assert.EndsWith("something failed", line);
}
log.Info("hello world");
[Fact]
public void Error_WithException_AppendsExceptionTextOnFollowingLines()
{
var log = new FileActivityLog(_root);
string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath));
Assert.Matches(s_linePattern, line);
Assert.Contains("[INFO] ", line);
Assert.EndsWith("hello world", line);
}
log.Error("sync blew up", new InvalidOperationException("kaboom"));
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
Assert.True(lines.Length >= 2, "exception text belongs on lines after the message");
Assert.Matches(LinePattern, lines[0]);
Assert.EndsWith("sync blew up", lines[0]);
Assert.Contains("InvalidOperationException", lines[1]);
Assert.Contains("kaboom", lines[1]);
}
[Fact]
public void Writes_Accumulate_InTheSameFile()
{
var log = new FileActivityLog(_root);
[Fact]
public void Error_WithoutException_WritesOneLineWithErrorTag()
{
var log = new FileActivityLog(_root);
log.Info("first");
log.Error("second");
log.Info("third");
log.Error("something failed");
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
Assert.Equal(3, lines.Length);
Assert.All(lines, line => Assert.Matches(LinePattern, line));
Assert.EndsWith("first", lines[0]);
Assert.EndsWith("second", lines[1]);
Assert.EndsWith("third", lines[2]);
}
string line = Assert.Single(File.ReadAllLines(log.CurrentLogFilePath));
Assert.Matches(s_linePattern, line);
Assert.Contains("[ERROR] ", line);
Assert.EndsWith("something failed", line);
}
// ---------------------------------------------------------------- paths
[Fact]
public void CurrentLogFilePath_IsTodaysDailyFile_UnderLogDirectory()
{
var log = new FileActivityLog(_root);
string expected = Path.Combine(
_root,
"gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log");
Assert.Equal(_root, log.LogDirectory);
Assert.Equal(expected, log.CurrentLogFilePath);
}
[Fact]
public void Error_WithException_AppendsExceptionTextOnFollowingLines()
{
var log = new FileActivityLog(_root);
[Fact]
public void DefaultDirectory_IsLocalAppDataGcloLogs()
{
var log = new FileActivityLog();
log.Error("sync blew up", new InvalidOperationException("kaboom"));
string expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo", "logs");
Assert.Equal(expected, log.LogDirectory);
}
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
Assert.True(lines.Length >= 2, "exception text belongs on lines after the message");
Assert.Matches(s_linePattern, lines[0]);
Assert.EndsWith("sync blew up", lines[0]);
Assert.Contains("InvalidOperationException", lines[1]);
Assert.Contains("kaboom", lines[1]);
}
[Fact]
public void Directory_IsCreatedLazily_OnFirstWriteNotInConstructor()
{
string directory = Path.Combine(_root, "lazy");
var log = new FileActivityLog(directory);
Assert.False(Directory.Exists(directory), "constructor must not create the directory");
log.Info("first write");
[Fact]
public void Writes_Accumulate_InTheSameFile()
{
var log = new FileActivityLog(_root);
Assert.True(Directory.Exists(directory));
Assert.True(File.Exists(log.CurrentLogFilePath));
}
log.Info("first");
log.Error("second");
log.Info("third");
// ---------------------------------------------------------------- never throws
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
Assert.Equal(3, lines.Length);
Assert.All(lines, line => Assert.Matches(s_linePattern, line));
Assert.EndsWith("first", lines[0]);
Assert.EndsWith("second", lines[1]);
Assert.EndsWith("third", lines[2]);
}
[Fact]
public void Write_WhenDirectoryIsUnwritable_NeverThrows()
{
// A file sitting where the log directory should go makes CreateDirectory
// throw; the log must swallow that on every call.
Directory.CreateDirectory(_root);
string fileAsDirectory = Path.Combine(_root, "not-a-directory");
File.WriteAllText(fileAsDirectory, "blocks directory creation");
var log = new FileActivityLog(fileAsDirectory);
log.Info("goes nowhere");
log.Error("still nowhere", new InvalidOperationException("boom"));
// ---------------------------------------------------------------- paths
// The properties still answer; only the writes are lost.
Assert.Equal(fileAsDirectory, log.LogDirectory);
Assert.StartsWith(fileAsDirectory, log.CurrentLogFilePath);
}
[Fact]
public void CurrentLogFilePath_IsTodaysDailyFile_UnderLogDirectory()
{
var log = new FileActivityLog(_root);
// ---------------------------------------------------------------- thread safety
string expected = Path.Combine(
_root,
"gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log");
Assert.Equal(_root, log.LogDirectory);
Assert.Equal(expected, log.CurrentLogFilePath);
}
[Fact]
public void ParallelWrites_ProduceOnlyWholeWellFormedLines()
{
var log = new FileActivityLog(_root);
const int writers = 4;
const int perWriter = 25;
Parallel.For(0, writers, writer =>
{
for (int i = 0; i < perWriter; i++)
{
log.Info($"writer {writer} message {i}");
}
});
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
Assert.Equal(writers * perWriter, lines.Length);
Assert.All(lines, line => Assert.Matches(LinePattern, line));
}
[Fact]
public void DefaultDirectory_IsLocalAppDataGcloLogs()
{
var log = new FileActivityLog();
// ---------------------------------------------------------------- NullActivityLog
string expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo", "logs");
Assert.Equal(expected, log.LogDirectory);
}
[Fact]
public void NullActivityLog_NoOps_AndReturnsEmptyStrings()
{
var log = new NullActivityLog();
log.Info("ignored");
log.Error("ignored too", new InvalidOperationException("ignored as well"));
Assert.Equal("", log.LogDirectory);
Assert.Equal("", log.CurrentLogFilePath);
}
[Fact]
public void Directory_IsCreatedLazily_OnFirstWriteNotInConstructor()
{
string directory = Path.Combine(_root, "lazy");
var log = new FileActivityLog(directory);
Assert.False(Directory.Exists(directory), "constructor must not create the directory");
log.Info("first write");
Assert.True(Directory.Exists(directory));
Assert.True(File.Exists(log.CurrentLogFilePath));
}
// ---------------------------------------------------------------- never throws
[Fact]
public void Write_WhenDirectoryIsUnwritable_NeverThrows()
{
// A file sitting where the log directory should go makes CreateDirectory
// throw; the log must swallow that on every call.
Directory.CreateDirectory(_root);
string fileAsDirectory = Path.Combine(_root, "not-a-directory");
File.WriteAllText(fileAsDirectory, "blocks directory creation");
var log = new FileActivityLog(fileAsDirectory);
log.Info("goes nowhere");
log.Error("still nowhere", new InvalidOperationException("boom"));
// The properties still answer; only the writes are lost.
Assert.Equal(fileAsDirectory, log.LogDirectory);
Assert.StartsWith(fileAsDirectory, log.CurrentLogFilePath);
}
// ---------------------------------------------------------------- thread safety
[Fact]
public void ParallelWrites_ProduceOnlyWholeWellFormedLines()
{
var log = new FileActivityLog(_root);
const int Writers = 4;
const int PerWriter = 25;
Parallel.For(0, Writers, writer =>
{
for(int i = 0; i < PerWriter; i++)
{
log.Info($"writer {writer} message {i}");
}
});
string[] lines = File.ReadAllLines(log.CurrentLogFilePath);
Assert.Equal(Writers * PerWriter, lines.Length);
Assert.All(lines, line => Assert.Matches(s_linePattern, line));
}
// ---------------------------------------------------------------- NullActivityLog
[Fact]
public void NullActivityLog_NoOps_AndReturnsEmptyStrings()
{
var log = new NullActivityLog();
log.Info("ignored");
log.Error("ignored too", new InvalidOperationException("ignored as well"));
Assert.Equal("", log.LogDirectory);
Assert.Equal("", log.CurrentLogFilePath);
}
}
+78 -67
View File
@@ -1,82 +1,93 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>Pins defaults and load-time sanitizing of <see cref="AppSettings"/>.</summary>
public sealed class AppSettingsTests
{
[Fact]
public void Defaults_SplashOnAtFiveSeconds()
{
var settings = new AppSettings();
[Fact]
public void Defaults_SplashOnAtFiveSeconds()
{
var settings = new AppSettings();
Assert.True(settings.ShowSplashScreen);
Assert.Equal(5_000, settings.SplashMilliseconds);
}
Assert.True(settings.ShowSplashScreen);
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 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" }""");
[Fact]
public void Save_ClampsSplashDuration()
{
string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
try
{
var settings = new AppSettings { SplashMilliseconds = 999_999 };
settings.Save();
Assert.Equal(AppSettings.MaxSplashMilliseconds, settings.SplashMilliseconds);
var loaded = AppSettings.Load();
settings.SplashMilliseconds = 1;
settings.Save();
Assert.Equal(AppSettings.MinSplashMilliseconds, settings.SplashMilliseconds);
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)
{
}
}
}
// Round trip: the persisted values load back as saved.
var loaded = AppSettings.Load();
Assert.Equal(AppSettings.MinSplashMilliseconds, loaded.SplashMilliseconds);
Assert.True(loaded.ShowSplashScreen);
}
finally
{
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
try
{
Directory.Delete(dir, recursive: true);
}
catch (IOException)
{
}
}
}
[Fact]
public void Save_ClampsSplashDuration()
{
string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", dir);
try
{
var settings = new AppSettings { SplashMilliseconds = 999_999 };
settings.Save();
Assert.Equal(AppSettings.MaxSplashMilliseconds, settings.SplashMilliseconds);
settings.SplashMilliseconds = 1;
settings.Save();
Assert.Equal(AppSettings.MinSplashMilliseconds, settings.SplashMilliseconds);
// Round trip: the persisted values load back as saved.
var loaded = AppSettings.Load();
Assert.Equal(AppSettings.MinSplashMilliseconds, loaded.SplashMilliseconds);
Assert.True(loaded.ShowSplashScreen);
}
finally
{
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
try
{
Directory.Delete(dir, recursive: true);
}
catch(IOException)
{
}
}
}
}
+27 -21
View File
@@ -1,30 +1,36 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>Formatting of the version + git-hash build identity string.</summary>
public sealed class BuildVersionTests
{
[Theory]
[InlineData(null, "unknown")]
[InlineData("", "unknown")]
[InlineData(" ", "unknown")]
[InlineData("1.2.3", "1.2.3")]
[InlineData("0.1.0-dev", "0.1.0-dev")]
[InlineData("0.1.0-beta.6+9ac6c2b1234567890abcdef", "0.1.0-beta.6 (9ac6c2b12)")]
[InlineData("1.2.3+abc", "1.2.3 (abc)")]
[InlineData("1.2.3+9ac6c2b1234567890.dirty", "1.2.3 (9ac6c2b12)")]
[InlineData("1.2.3+", "1.2.3")]
public void Format_ProducesReadableIdentity(string? informational, string expected)
{
Assert.Equal(expected, BuildVersion.Format(informational));
}
[Theory]
[InlineData(null, "unknown")]
[InlineData("", "unknown")]
[InlineData(" ", "unknown")]
[InlineData("1.2.3", "1.2.3")]
[InlineData("0.1.0-dev", "0.1.0-dev")]
[InlineData("0.1.0-beta.6+9ac6c2b1234567890abcdef", "0.1.0-beta.6 (9ac6c2b12)")]
[InlineData("1.2.3+abc", "1.2.3 (abc)")]
[InlineData("1.2.3+9ac6c2b1234567890.dirty", "1.2.3 (9ac6c2b12)")]
[InlineData("1.2.3+", "1.2.3")]
public void Format_ProducesReadableIdentity(string? informational, string expected)
{
Assert.Equal(expected, BuildVersion.Format(informational));
}
[Fact]
public void Describe_UsesThisAssembly_AndNeverThrows()
{
string described = BuildVersion.Describe(typeof(BuildVersionTests).Assembly);
Assert.False(string.IsNullOrWhiteSpace(described));
}
[Fact]
public void Describe_UsesThisAssembly_AndNeverThrows()
{
string described = BuildVersion.Describe(typeof(BuildVersionTests).Assembly);
Assert.False(string.IsNullOrWhiteSpace(described));
}
}
+244 -193
View File
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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
@@ -9,230 +16,274 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class CoverageResidueTests
{
// ------------------------------------------------- WindowsPathValidator
// ------------------------------------------------- WindowsPathValidator
[Fact]
public void ValidatePaths_LeafCollidingWithDirectory_IsInvalid()
{
var invalid = WindowsPathValidator.ValidatePaths(["a/b", "a"]);
[Fact]
public void ValidatePaths_LeafCollidingWithDirectory_IsInvalid()
{
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.ValidatePaths(["a/b", "a"]);
Assert.Contains(invalid, i => i.RepoPath == "a" && i.Reason.Contains("file and a directory"));
}
Assert.Contains(invalid, i => string.Equals(i.RepoPath, "a", StringComparison.Ordinal) && 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"]);
[Fact]
public void ValidatePaths_DirectoryCollidingWithFile_IsInvalid()
{
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.ValidatePaths(["a", "a/b"]);
Assert.Contains(invalid, i => i.Reason == "empty path segment" && i.SuggestedName == "_");
}
Assert.Contains(invalid, i => string.Equals(i.RepoPath, "a", StringComparison.Ordinal) && i.Reason.Contains("file and a directory"));
}
// ------------------------------------------------- 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 ValidatePaths_EmptySegment_IsInvalidWithSuggestion()
{
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.ValidatePaths(["a//b"]);
[Fact]
public void Load_CorruptSettingsFile_FallsBackToDefaults()
{
(string dir, IDisposable scope) = SettingsDir();
using (scope)
{
File.WriteAllText(Path.Combine(dir, "settings.json"), "{ not json");
Assert.Contains(invalid, i => string.Equals(i.Reason, "empty path segment", StringComparison.Ordinal) && string.Equals(i.SuggestedName, "_", StringComparison.Ordinal));
}
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");
// ------------------------------------------------- AppSettings failure paths
Assert.Equal("System", AppSettings.Load().Theme);
}
}
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));
}
[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);
private sealed class EnvScope(string dir) : IDisposable
{
public void Dispose()
{
Environment.SetEnvironmentVariable("GCLO_DATA_DIR", null);
try
{
Directory.Delete(dir, recursive: true);
}
catch(IOException)
{
}
}
}
new AppSettings().Save(); // must not throw
}
}
[Fact]
public void DefaultTokenVaultId_IsStable()
=> Assert.Equal(new Guid("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"), AppSettings.DefaultTokenVaultId);
// ------------------------------------------------- AccountsStore edges
[Fact]
public void Load_CorruptSettingsFile_FallsBackToDefaults()
{
(string dir, IDisposable scope) = SettingsDir();
using(scope)
{
File.WriteAllText(Path.Combine(dir, "settings.json"), "{ not json");
private static Account MakeAccount(string name = "acc") => new()
{
Id = Guid.NewGuid(),
Name = name,
Organization = "org",
TargetRoot = @"C:\x",
};
var settings = AppSettings.Load();
[Fact]
public void RecordSyncResult_UnknownAccount_LogsAndLeavesTheListAlone()
{
string dir = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
try
{
var log = new RecordingLog();
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);
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();
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);
}
}
// 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
// ------------------------------------------------- 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,
};
[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);
}
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 => "";
}
[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 => "";
}
}
+224 -126
View File
@@ -1,138 +1,206 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
using gclo.Engine;
namespace gclo.Engine.Tests;
/// <summary>An <see cref="IRepositoryLister"/> that returns a configurable list (or throws).</summary>
public sealed class FakeRepositoryLister : IRepositoryLister
{
private readonly ConcurrentQueue<(string Organization, string Token)> _calls = new();
private readonly ConcurrentQueue<(string Organization, string Token)> _calls = new();
public IReadOnlyList<RepoDescriptor> Repositories { get; set; } = Array.Empty<RepoDescriptor>();
public IReadOnlyList<RepoDescriptor> Repositories { get; set; } = Array.Empty<RepoDescriptor>();
/// <summary>When set, <see cref="ListOrganizationRepositoriesAsync"/> throws this instead of returning.</summary>
public Exception? ExceptionToThrow { get; set; }
/// <summary>When set, <see cref="ListOrganizationRepositoriesAsync"/> throws this instead of returning.</summary>
public Exception? ExceptionToThrow { get; set; }
public IReadOnlyList<(string Organization, string Token)> Calls => _calls.ToArray();
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
_calls.Enqueue((organization, token));
if (ExceptionToThrow is not null)
{
return Task.FromException<IReadOnlyList<RepoDescriptor>>(ExceptionToThrow);
}
return Task.FromResult(Repositories);
}
public IReadOnlyList<(string Organization, string Token)> Calls => _calls.ToArray();
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
_calls.Enqueue((organization, token));
if(ExceptionToThrow is not null)
{
return Task.FromException<IReadOnlyList<RepoDescriptor>>(ExceptionToThrow);
}
return Task.FromResult(Repositories);
}
}
public sealed record CloneCall(string Url, string LocalPath, string Token);
public sealed record PullCall(string LocalPath, string Token);
public sealed record ApplyRecoveryCall(string LocalPath, PathRecovery Recovery);
/// <summary>
/// An <see cref="IGitClient"/> whose behavior is configured with delegates.
/// All calls are recorded thread-safely; default handlers complete immediately.
/// </summary>
public sealed class FakeGitClient : IGitClient
{
private readonly ConcurrentQueue<CloneCall> _cloneCalls = new();
private readonly ConcurrentQueue<PullCall> _pullCalls = new();
private readonly ConcurrentQueue<ApplyRecoveryCall> _applyRecoveryCalls = new();
private readonly ConcurrentQueue<string> _validityChecks = new();
private readonly ConcurrentQueue<CloneCall> _cloneCalls = new();
/// <summary>Decides whether a local path counts as an existing valid repository. Default: never.</summary>
public Func<string, bool> IsValidRepositoryHandler { get; set; } = _ => false;
private readonly ConcurrentQueue<PullCall> _pullCalls = new();
/// <summary>Body of <see cref="CloneAsync"/> (url, path, token, onProgress, ct). Default: completes immediately.</summary>
public Func<string, string, string, Action<double>?, CancellationToken, Task> CloneHandler { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
private readonly ConcurrentQueue<ApplyRecoveryCall> _applyRecoveryCalls = new();
/// <summary>Body of <see cref="FetchAndPullAsync"/> (path, token, ct). Default: completes immediately.</summary>
public Func<string, string, CancellationToken, Task> FetchAndPullHandler { get; set; }
= (_, _, _) => Task.CompletedTask;
private readonly ConcurrentQueue<string> _validityChecks = new();
/// <summary>Body of <see cref="ApplyRecoveryAsync"/> (path, recovery, ct). Default: completes immediately.</summary>
public Func<string, PathRecovery, CancellationToken, Task> ApplyRecoveryHandler { get; set; }
= (_, _, _) => Task.CompletedTask;
/// <summary>Decides whether a local path counts as an existing valid repository. Default: never.</summary>
public Func<string, bool> IsValidRepositoryHandler { get; set; } = _ => false;
public IReadOnlyList<CloneCall> CloneCalls => _cloneCalls.ToArray();
public IReadOnlyList<PullCall> PullCalls => _pullCalls.ToArray();
public IReadOnlyList<ApplyRecoveryCall> ApplyRecoveryCalls => _applyRecoveryCalls.ToArray();
public IReadOnlyList<string> ValidityChecks => _validityChecks.ToArray();
/// <summary>Body of <see cref="CloneAsync"/> (url, path, token, onProgress, ct). Default: completes immediately.</summary>
public Func<string, string, string, Action<double>?, CancellationToken, Task> CloneHandler { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
/// <summary>Repo folder names (last path segment) passed to <see cref="CloneAsync"/>.</summary>
public IReadOnlyList<string> ClonedRepoNames
=> _cloneCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray();
/// <summary>Body of <see cref="FetchAndPullAsync"/> (path, token, ct). Default: completes immediately.</summary>
public Func<string, string, CancellationToken, Task> FetchAndPullHandler { get; set; }
= (_, _, _) => Task.CompletedTask;
/// <summary>Repo folder names (last path segment) passed to <see cref="FetchAndPullAsync"/>.</summary>
public IReadOnlyList<string> PulledRepoNames
=> _pullCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray();
/// <summary>Body of <see cref="ApplyRecoveryAsync"/> (path, recovery, ct). Default: completes immediately.</summary>
public Func<string, PathRecovery, CancellationToken, Task> ApplyRecoveryHandler { get; set; }
= (_, _, _) => Task.CompletedTask;
public bool IsValidRepository(string path)
{
_validityChecks.Enqueue(path);
return IsValidRepositoryHandler(path);
}
public async Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken)
{
_cloneCalls.Enqueue(new CloneCall(url, path, token));
await CloneHandler(url, path, token, onProgress, cancellationToken).ConfigureAwait(false);
}
public async Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
{
_pullCalls.Enqueue(new PullCall(path, token));
await FetchAndPullHandler(path, token, cancellationToken).ConfigureAwait(false);
}
public IReadOnlyList<CloneCall> CloneCalls => _cloneCalls.ToArray();
public async Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken)
{
_applyRecoveryCalls.Enqueue(new ApplyRecoveryCall(path, recovery));
await ApplyRecoveryHandler(path, recovery, cancellationToken).ConfigureAwait(false);
}
public IReadOnlyList<PullCall> PullCalls => _pullCalls.ToArray();
public IReadOnlyList<ApplyRecoveryCall> ApplyRecoveryCalls => _applyRecoveryCalls.ToArray();
public IReadOnlyList<string> ValidityChecks => _validityChecks.ToArray();
/// <summary>Repo folder names (last path segment) passed to <see cref="CloneAsync"/>.</summary>
public IReadOnlyList<string> ClonedRepoNames
=> _cloneCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray();
/// <summary>Repo folder names (last path segment) passed to <see cref="FetchAndPullAsync"/>.</summary>
public IReadOnlyList<string> PulledRepoNames
=> _pullCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray();
public bool IsValidRepository(string path)
{
_validityChecks.Enqueue(path);
return IsValidRepositoryHandler(path);
}
public async Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken)
{
_cloneCalls.Enqueue(new CloneCall(url, path, token));
await CloneHandler(url, path, token, onProgress, cancellationToken).ConfigureAwait(false);
}
public async Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken)
{
_pullCalls.Enqueue(new PullCall(path, token));
await FetchAndPullHandler(path, token, cancellationToken).ConfigureAwait(false);
}
public async Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken)
{
_applyRecoveryCalls.Enqueue(new ApplyRecoveryCall(path, recovery));
await ApplyRecoveryHandler(path, recovery, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// An <see cref="IActivityLog"/> that keeps every entry, for asserting that actions
/// are logged (#40) and that no entry ever carries a token.
/// </summary>
public sealed class RecordingActivityLog : IActivityLog
{
private readonly ConcurrentQueue<(string Level, string Message)> _entries = new();
private readonly ConcurrentQueue<(string Level, string Message)> _entries = new();
public IReadOnlyList<(string Level, string Message)> Entries => _entries.ToArray();
public IReadOnlyList<string> Messages => _entries.Select(e => e.Message).ToArray();
public void Info(string message) => _entries.Enqueue(("INFO", message));
public IReadOnlyList<(string Level, string Message)> Entries => _entries.ToArray();
public void Error(string message, Exception? exception = null) => _entries.Enqueue(("ERROR", message));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
public IReadOnlyList<string> Messages => _entries.Select(e => e.Message).ToArray();
public void Info(string message) => _entries.Enqueue(("INFO", message));
public void Error(string message, Exception? exception = null) => _entries.Enqueue(("ERROR", message));
public string LogDirectory => "";
public string CurrentLogFilePath => "";
}
/// <summary>An <see cref="IOrganizationLister"/> whose behavior is configured with a delegate.</summary>
public sealed class FakeOrganizationLister : IOrganizationLister
{
private int _calls;
private int _calls;
public Func<string, CancellationToken, Task<IReadOnlyList<string>>> Handler { get; set; }
= (_, _) => Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
public Func<string, CancellationToken, Task<IReadOnlyList<string>>> Handler { get; set; }
= (_, _) => Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
public int Calls => _calls;
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _calls);
return Handler(token, cancellationToken);
}
public int Calls => _calls;
public Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _calls);
return Handler(token, cancellationToken);
}
}
/// <summary>
/// Records every report synchronously into a thread-safe queue.
/// Deliberately NOT <see cref="Progress{T}"/>, which posts callbacks to a sync context
@@ -140,22 +208,32 @@ public sealed class FakeOrganizationLister : IOrganizationLister
/// </summary>
public sealed class RecordingProgress : IProgress<RepoProgress>
{
private readonly ConcurrentQueue<RepoProgress> _reports = new();
private readonly ConcurrentQueue<RepoProgress> _reports = new();
public void Report(RepoProgress value) => _reports.Enqueue(value);
/// <summary>All reports in the order they were recorded.</summary>
public IReadOnlyList<RepoProgress> Reports => _reports.ToArray();
/// <summary>Ordered statuses reported for one repository.</summary>
public IReadOnlyList<SyncStatus> StatusesFor(string repoName)
=> _reports.Where(r => r.RepoName == repoName).Select(r => r.Status).ToArray();
public void Report(RepoProgress value) => _reports.Enqueue(value);
/// <summary>The most recent report for one repository, or null if none.</summary>
public RepoProgress? LastFor(string repoName)
=> _reports.LastOrDefault(r => r.RepoName == repoName);
/// <summary>All reports in the order they were recorded.</summary>
public IReadOnlyList<RepoProgress> Reports => _reports.ToArray();
/// <summary>Ordered statuses reported for one repository.</summary>
public IReadOnlyList<SyncStatus> StatusesFor(string repoName)
=> _reports.Where(r => string.Equals(r.RepoName, repoName, StringComparison.Ordinal)).Select(r => r.Status).ToArray();
/// <summary>The most recent report for one repository, or null if none.</summary>
public RepoProgress? LastFor(string repoName)
=> _reports.LastOrDefault(r => string.Equals(r.RepoName, repoName, StringComparison.Ordinal));
}
/// <summary>One scripted <see cref="IProcessRunner"/> invocation, as recorded by <see cref="FakeProcessRunner"/>.</summary>
public sealed record ProcessCall(
string FileName,
@@ -163,61 +241,81 @@ public sealed record ProcessCall(
IReadOnlyDictionary<string, string>? Environment,
string? StandardInput);
/// <summary>An <see cref="IProcessRunner"/> that records calls and answers from a delegate.</summary>
public sealed class FakeProcessRunner : IProcessRunner
{
private readonly ConcurrentQueue<ProcessCall> _calls = new();
private readonly ConcurrentQueue<ProcessCall> _calls = new();
/// <summary>Answers each call; default: exit 0 with empty output.</summary>
public Func<ProcessCall, CancellationToken, Task<ProcessResult>> Handler { get; set; }
= (_, _) => Task.FromResult(new ProcessResult(0, "", ""));
/// <summary>Answers each call; default: exit 0 with empty output.</summary>
public Func<ProcessCall, CancellationToken, Task<ProcessResult>> Handler { get; set; }
= (_, _) => Task.FromResult(new ProcessResult(0, "", ""));
public IReadOnlyList<ProcessCall> Calls => _calls.ToArray();
public Task<ProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment,
string? standardInput,
CancellationToken cancellationToken)
{
var call = new ProcessCall(fileName, arguments, environment, standardInput);
_calls.Enqueue(call);
return Handler(call, cancellationToken);
}
public IReadOnlyList<ProcessCall> Calls => _calls.ToArray();
public Task<ProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment,
string? standardInput,
CancellationToken cancellationToken)
{
var call = new ProcessCall(fileName, arguments, environment, standardInput);
_calls.Enqueue(call);
return Handler(call, cancellationToken);
}
}
public sealed record WslCloneCall(string Url, string Organization, string RepositoryName, string Token);
/// <summary>An <see cref="IWslCloner"/> with scripted availability and clone outcome.</summary>
public sealed class FakeWslCloner : IWslCloner
{
private readonly ConcurrentQueue<WslCloneCall> _cloneCalls = new();
private int _probes;
private readonly ConcurrentQueue<WslCloneCall> _cloneCalls = new();
/// <summary>What <see cref="ProbeAsync"/> reports; default: not available.</summary>
public WslAvailability Availability { get; set; } = new(false, "wsl.exe is not installed (test default)");
private int _probes;
/// <summary>Body of <see cref="CloneAsync"/>; default: a successful clone into /home/me/gclo/org/repo.</summary>
public Func<WslCloneCall, CancellationToken, Task<WslCloneResult>> CloneHandler { get; set; }
= (call, _) => Task.FromResult(new WslCloneResult(
"Ubuntu",
$"/home/me/gclo/{call.Organization}/{call.RepositoryName}",
$@"\\wsl.localhost\Ubuntu\home\me\gclo\{call.Organization}\{call.RepositoryName}"));
/// <summary>What <see cref="ProbeAsync"/> reports; default: not available.</summary>
public WslAvailability Availability { get; set; } = new(false, "wsl.exe is not installed (test default)");
public int Probes => _probes;
public IReadOnlyList<WslCloneCall> CloneCalls => _cloneCalls.ToArray();
/// <summary>Body of <see cref="CloneAsync"/>; default: a successful clone into /home/me/gclo/org/repo.</summary>
public Func<WslCloneCall, CancellationToken, Task<WslCloneResult>> CloneHandler { get; set; }
= (call, _) => Task.FromResult(new WslCloneResult(
"Ubuntu",
$"/home/me/gclo/{call.Organization}/{call.RepositoryName}",
$@"\\wsl.localhost\Ubuntu\home\me\gclo\{call.Organization}\{call.RepositoryName}"));
public Task<WslAvailability> ProbeAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _probes);
return Task.FromResult(Availability);
}
public Task<WslCloneResult> CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken)
{
var call = new WslCloneCall(url, organization, repositoryName, token);
_cloneCalls.Enqueue(call);
return CloneHandler(call, cancellationToken);
}
public int Probes => _probes;
public IReadOnlyList<WslCloneCall> CloneCalls => _cloneCalls.ToArray();
public Task<WslAvailability> ProbeAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _probes);
return Task.FromResult(Availability);
}
public Task<WslCloneResult> CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken)
{
var call = new WslCloneCall(url, organization, repositoryName, token);
_cloneCalls.Enqueue(call);
return CloneHandler(call, cancellationToken);
}
}
+472 -360
View File
@@ -1,8 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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
@@ -10,364 +17,469 @@ namespace gclo.Engine.Tests;
/// </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 Func<string, Task<GitHubAccountKind>> AccountKind { get; set; } =
_ => Task.FromResult(GitHubAccountKind.User);
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();
public Task<GitHubAccountKind> GetAccountKindAsync(string login) => AccountKind(login);
}
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)));
private static SecondaryRateLimitExceededException SecondaryRateLimited()
=> new(new FakeResponse(HttpStatusCode.Forbidden));
private static LoginAttemptsExceededException LoginAttemptsExceeded()
=> new(new FakeResponse(HttpStatusCode.Forbidden));
private static AbuseException Abuse() => new(new FakeResponse(HttpStatusCode.Forbidden));
/// <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_ButItIsAnOrganizationTheTokenCannotSee_FailsInsteadOfListingPublicRepos()
{
// The worst failure mode for a mirror tool: a green sync of the 12 public
// repos of a 400-repo org. The account kind is resolved before any fallback.
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => Task.FromResult(GitHubAccountKind.Organization);
bool userReposAsked = false;
_gateway.UserPages = (_, _) => { userReposAsked = true; return Task.FromResult<IReadOnlyList<GitHubRepo>>([Repo("public-only")]); };
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("'acme' is an organization", ex.Message);
Assert.Contains("cannot see its repositories", ex.Message);
Assert.Equal(GitHubAccessKind.Forbidden, ex.Kind);
Assert.Contains("resource owner", ex.Message);
Assert.False(userReposAsked, "the public-repos endpoint must never be consulted for an organization");
}
[Fact]
public async Task Repos_OrgNotFound_AndNoSuchAccount_FailsWithSpellingHint()
{
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => Task.FromResult(GitHubAccountKind.NotFound);
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("typo", "tok-1234567890"));
Assert.Contains("neither as an organization nor as a user account", ex.Message);
Assert.Contains("Check the spelling", ex.Message);
Assert.Equal(GitHubAccessKind.NotFound, ex.Kind);
}
[Fact]
public async Task Repos_SecondaryRateLimit_TranslatesWithRetryHint()
{
_gateway.OrgPages = (_, _) => throw SecondaryRateLimited();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("secondary rate limit", ex.Message);
Assert.Contains("retry in", ex.Message);
Assert.IsType<SecondaryRateLimitExceededException>(ex.InnerException);
Assert.Equal(GitHubAccessKind.RateLimited, ex.Kind);
_gateway.OrgPages = (_, _) => throw Abuse();
var abuse = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("retry in 60 seconds", abuse.Message);
}
[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<GitHubAccessException>(
() => 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<GitHubAccessException>(
() => 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<GitHubAccessException>(
() => 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_SecondaryRateLimit_IsNotMistakenForAMissingScope()
{
// A throttle must surface as an error, not silently shrink the org list to
// the personal account (which reads as a permanent scope problem).
_gateway.OrgLogins = () => throw SecondaryRateLimited();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("secondary rate limit", ex.Message);
_gateway.OrgLogins = () => throw Abuse();
var abuse = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("retry in 60 seconds", abuse.Message);
}
[Fact]
public async Task Orgs_LoginAttemptsExceeded_IsNotMistakenForAMissingScope()
{
_gateway.OrgLogins = () => throw LoginAttemptsExceeded();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("too many failed attempts", ex.Message);
}
[Fact]
public async Task Orgs_BadToken_TranslatesTo401Guidance()
{
_gateway.CurrentUser = () => throw Unauthorized();
var ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => 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<GitHubAccessException>(
() => 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());
}
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 Func<string, Task<GitHubAccountKind>> AccountKind { get; set; } =
_ => Task.FromResult(GitHubAccountKind.User);
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();
public Task<GitHubAccountKind> GetAccountKindAsync(string login) => AccountKind(login);
}
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)));
private static SecondaryRateLimitExceededException SecondaryRateLimited()
=> new(new FakeResponse(HttpStatusCode.Forbidden));
private static LoginAttemptsExceededException LoginAttemptsExceeded()
=> new(new FakeResponse(HttpStatusCode.Forbidden));
private static AbuseException Abuse() => new(new FakeResponse(HttpStatusCode.Forbidden));
/// <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>(StringComparer.Ordinal);
public ApiInfo ApiInfo { get; } = new(
new Dictionary<string, Uri>(StringComparer.Ordinal), [], [], "", 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")]);
IReadOnlyList<RepoDescriptor> 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)]);
IReadOnlyList<RepoDescriptor> 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)]);
IReadOnlyList<RepoDescriptor> 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")]);
IReadOnlyList<RepoDescriptor> 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_ButItIsAnOrganizationTheTokenCannotSee_FailsInsteadOfListingPublicRepos()
{
// The worst failure mode for a mirror tool: a green sync of the 12 public
// repos of a 400-repo org. The account kind is resolved before any fallback.
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => Task.FromResult(GitHubAccountKind.Organization);
bool userReposAsked = false;
_gateway.UserPages = (_, _) => { userReposAsked = true; return Task.FromResult<IReadOnlyList<GitHubRepo>>([Repo("public-only")]); };
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("'acme' is an organization", ex.Message);
Assert.Contains("cannot see its repositories", ex.Message);
Assert.Equal(GitHubAccessKind.Forbidden, ex.Kind);
Assert.Contains("resource owner", ex.Message);
Assert.False(userReposAsked, "the public-repos endpoint must never be consulted for an organization");
}
[Fact]
public async Task Repos_OrgNotFound_AndNoSuchAccount_FailsWithSpellingHint()
{
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.AccountKind = _ => Task.FromResult(GitHubAccountKind.NotFound);
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("typo", "tok-1234567890"));
Assert.Contains("neither as an organization nor as a user account", ex.Message);
Assert.Contains("Check the spelling", ex.Message);
Assert.Equal(GitHubAccessKind.NotFound, ex.Kind);
}
[Fact]
public async Task Repos_SecondaryRateLimit_TranslatesWithRetryHint()
{
_gateway.OrgPages = (_, _) => throw SecondaryRateLimited();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("secondary rate limit", ex.Message);
Assert.Contains("retry in", ex.Message);
Assert.IsType<SecondaryRateLimitExceededException>(ex.InnerException);
Assert.Equal(GitHubAccessKind.RateLimited, ex.Kind);
_gateway.OrgPages = (_, _) => throw Abuse();
GitHubAccessException abuse = await Assert.ThrowsAsync<GitHubAccessException>(
() => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890"));
Assert.Contains("retry in 60 seconds", abuse.Message);
}
[Fact]
public async Task Repos_OrgNotFound_OtherUserAccount_ListsThatUsersRepos()
{
_gateway.OrgPages = (_, _) => throw NotFound();
_gateway.UserPages = (user, _) => Task.FromResult<IReadOnlyList<GitHubRepo>>([Repo(user + "-repo")]);
IReadOnlyList<RepoDescriptor> 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();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => 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();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => 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();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => 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"]);
IReadOnlyList<string> 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();
IReadOnlyList<string> orgs = await OrgLister().ListOrganizationsAsync("tok-1234567890");
Assert.Equal(["Kof"], orgs);
}
[Fact]
public async Task Orgs_SecondaryRateLimit_IsNotMistakenForAMissingScope()
{
// A throttle must surface as an error, not silently shrink the org list to
// the personal account (which reads as a permanent scope problem).
_gateway.OrgLogins = () => throw SecondaryRateLimited();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("secondary rate limit", ex.Message);
_gateway.OrgLogins = () => throw Abuse();
GitHubAccessException abuse = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("retry in 60 seconds", abuse.Message);
}
[Fact]
public async Task Orgs_LoginAttemptsExceeded_IsNotMistakenForAMissingScope()
{
_gateway.OrgLogins = () => throw LoginAttemptsExceeded();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("too many failed attempts", ex.Message);
}
[Fact]
public async Task Orgs_BadToken_TranslatesTo401Guidance()
{
_gateway.CurrentUser = () => throw Unauthorized();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => OrgLister().ListOrganizationsAsync("tok-1234567890"));
Assert.Contains("401", ex.Message);
}
[Fact]
public async Task Orgs_RateLimited_TranslatesWithResetTime()
{
_gateway.OrgLogins = () => throw RateLimited();
GitHubAccessException ex = await Assert.ThrowsAsync<GitHubAccessException>(
() => 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());
}
}
+117 -93
View File
@@ -1,10 +1,16 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text;
using gclo.Engine;
using LibGit2Sharp;
namespace gclo.Engine.Tests;
/// <summary>
/// Shared fixture helpers: descriptor builders for fake repositories, polling waits,
/// and real on-disk git fixtures. Forged tree entries are written through the object
@@ -13,107 +19,125 @@ namespace gclo.Engine.Tests;
/// </summary>
internal static class GitTestHelpers
{
/// <summary>Descriptor for a repository of the fake organization 'acme'.</summary>
public static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
=> new(name, $"https://example.test/acme/{name}.git", branch, archived);
/// <summary>Descriptor for a repository of the fake organization 'acme'.</summary>
public static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
=> new(name, $"https://example.test/acme/{name}.git", branch, archived);
/// <summary>Descriptors (default branch, not archived) for the given names.</summary>
public static RepoDescriptor[] Repos(params string[] names)
=> names.Select(name => Repo(name)).ToArray();
/// <summary>Standard signature for fixture commits.</summary>
public static Signature MakeSignature()
=> new("tester", "tester@example.test", DateTimeOffset.Now);
/// <summary>Polls until <paramref name="condition"/> is true; fails the test after a generous timeout.</summary>
public static async Task WaitUntilAsync(Func<bool> condition, string description)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
$"Timed out after {stopwatch.Elapsed} waiting for: {description}");
await Task.Delay(10);
}
}
/// <summary>Descriptors (default branch, not archived) for the given names.</summary>
public static RepoDescriptor[] Repos(params string[] names)
=> names.Select(name => Repo(name)).ToArray();
/// <summary>SHA of the commit at HEAD's tip.</summary>
public static string HeadSha(string workdir)
{
using var repo = new Repository(workdir);
return repo.Head.Tip.Sha;
}
/// <summary>
/// Builds a repository in a fresh directory under <paramref name="root"/> whose single
/// commit holds the given forged entries with per-entry content; returns its path.
/// </summary>
public static string CreateForgedRepo(string root, params (string Name, string Content)[] entries)
{
string path = Path.Combine(root, "forged-" + Guid.NewGuid().ToString("N")[..8]);
Repository.Init(path);
using var repo = new Repository(path);
var tree = repo.ObjectDatabase.CreateTree(AddForgedEntries(repo, new TreeDefinition(), entries));
var signature = MakeSignature();
var commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged commit", tree, [], prettifyMessage: false);
repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id);
return path;
}
/// <summary>Standard signature for fixture commits.</summary>
public static Signature MakeSignature()
=> new("tester", "tester@example.test", DateTimeOffset.Now);
/// <summary>Convenience overload: every forged entry gets the same placeholder content.</summary>
public static string CreateForgedRepo(string root, params string[] entryNames)
=> CreateForgedRepo(root, entryNames.Select(name => (name, "content")).ToArray());
/// <summary>Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA.</summary>
public static string AppendForgedCommit(string workdir, params (string Name, string Content)[] entries)
{
using var repo = new Repository(workdir);
var head = repo.Head.Tip;
var definition = AddForgedEntries(repo, TreeDefinition.From(head.Tree), entries);
var tree = repo.ObjectDatabase.CreateTree(definition);
var signature = MakeSignature();
var commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged follow-up", tree, [head], prettifyMessage: false);
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id);
return commit.Sha;
}
/// <summary>Polls until <paramref name="condition"/> is true; fails the test after a generous timeout.</summary>
public static async Task WaitUntilAsync(Func<bool> condition, string description)
{
var stopwatch = Stopwatch.StartNew();
while(!condition())
{
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
$"Timed out after {stopwatch.Elapsed} waiting for: {description}");
await Task.Delay(10);
}
}
private static TreeDefinition AddForgedEntries(
Repository repo, TreeDefinition definition, (string Name, string Content)[] entries)
{
foreach (var (name, content) in entries)
{
var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content)));
definition.Add(name, blob, Mode.NonExecutableFile);
}
return definition;
}
/// <summary>
/// Same approach as the production client: pack files under .git are written
/// read-only on Windows, so clear attributes before deleting. Best effort — a
/// stray temp dir is harmless.
/// </summary>
public static void TryDeleteDirectory(string path)
{
try
{
if (!Directory.Exists(path))
{
return;
}
foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
File.SetAttributes(file, FileAttributes.Normal);
}
Directory.Delete(path, recursive: true);
}
catch
{
// Best effort; stray temp dirs are harmless.
}
}
/// <summary>SHA of the commit at HEAD's tip.</summary>
public static string HeadSha(string workdir)
{
using var repo = new Repository(workdir);
return repo.Head.Tip.Sha;
}
/// <summary>
/// Builds a repository in a fresh directory under <paramref name="root"/> whose single
/// commit holds the given forged entries with per-entry content; returns its path.
/// </summary>
public static string CreateForgedRepo(string root, params (string Name, string Content)[] entries)
{
string path = Path.Combine(root, "forged-" + Guid.NewGuid().ToString("N")[..8]);
Repository.Init(path);
using var repo = new Repository(path);
Tree tree = repo.ObjectDatabase.CreateTree(AddForgedEntries(repo, new TreeDefinition(), entries));
Signature signature = MakeSignature();
Commit commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged commit", tree, [], prettifyMessage: false);
repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id);
return path;
}
/// <summary>Convenience overload: every forged entry gets the same placeholder content.</summary>
public static string CreateForgedRepo(string root, params string[] entryNames)
=> CreateForgedRepo(root, entryNames.Select(name => (name, "content")).ToArray());
/// <summary>Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA.</summary>
public static string AppendForgedCommit(string workdir, params (string Name, string Content)[] entries)
{
using var repo = new Repository(workdir);
Commit head = repo.Head.Tip;
TreeDefinition definition = AddForgedEntries(repo, TreeDefinition.From(head.Tree), entries);
Tree tree = repo.ObjectDatabase.CreateTree(definition);
Signature signature = MakeSignature();
Commit commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged follow-up", tree, [head], prettifyMessage: false);
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id);
return commit.Sha;
}
private static TreeDefinition AddForgedEntries(
Repository repo, TreeDefinition definition, (string Name, string Content)[] entries)
{
foreach((string? name, string? content) in entries)
{
Blob blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content)));
definition.Add(name, blob, Mode.NonExecutableFile);
}
return definition;
}
/// <summary>
/// Same approach as the production client: pack files under .git are written
/// read-only on Windows, so clear attributes before deleting. Best effort — a
/// stray temp dir is harmless.
/// </summary>
public static void TryDeleteDirectory(string path)
{
try
{
if(!Directory.Exists(path))
{
return;
}
foreach(string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
File.SetAttributes(file, FileAttributes.Normal);
}
Directory.Delete(path, recursive: true);
}
catch
{
// Best effort; stray temp dirs are harmless.
}
}
}
@@ -1,32 +1,40 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>Pins the user-facing message of <see cref="InvalidRepositoryPathsException"/>.</summary>
public sealed class InvalidRepositoryPathsExceptionTests
{
[Fact]
public void Message_SinglePath_UsesSingularWording()
{
var ex = new InvalidRepositoryPathsException(
[new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_")]);
[Fact]
public void Message_SinglePath_UsesSingularWording()
{
var ex = new InvalidRepositoryPathsException(
[new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_")]);
Assert.StartsWith("1 path in this repository cannot be created on Windows:", ex.Message);
Assert.Contains("'aux' ('aux' is a reserved Windows device name)", ex.Message);
Assert.EndsWith("Nothing was checked out.", ex.Message);
}
Assert.StartsWith("1 path in this repository cannot be created on Windows:", ex.Message);
Assert.Contains("'aux' ('aux' is a reserved Windows device name)", ex.Message);
Assert.EndsWith("Nothing was checked out.", ex.Message);
}
[Fact]
public void Message_ManyPaths_UsesPluralWording_AndCapsExamplesAtThree()
{
var paths = Enumerable.Range(1, 5)
.Select(i => new InvalidPathInfo(
$"docs/bad:{i}.txt", "contains a character that is invalid on Windows", null))
.ToList();
var ex = new InvalidRepositoryPathsException(paths);
Assert.StartsWith("5 paths in this repository cannot be created on Windows:", ex.Message);
Assert.Contains(" and 2 more", ex.Message); // examples cap at three
Assert.DoesNotContain("bad:4", ex.Message);
Assert.EndsWith("Nothing was checked out.", ex.Message);
}
[Fact]
public void Message_ManyPaths_UsesPluralWording_AndCapsExamplesAtThree()
{
var paths = Enumerable.Range(1, 5)
.Select(i => new InvalidPathInfo(
$"docs/bad:{i}.txt", "contains a character that is invalid on Windows", null))
.ToList();
var ex = new InvalidRepositoryPathsException(paths);
Assert.StartsWith("5 paths in this repository cannot be created on Windows:", ex.Message);
Assert.Contains(" and 2 more", ex.Message); // examples cap at three
Assert.DoesNotContain("bad:4", ex.Message);
Assert.EndsWith("Nothing was checked out.", ex.Message);
}
}
File diff suppressed because it is too large. Load diff
+431 -375
View File
@@ -1,476 +1,532 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
public sealed class OrgSyncEngineTests : IDisposable
{
private static readonly SyncStatus[] TerminalStatuses =
[SyncStatus.Done, SyncStatus.Failed, SyncStatus.Canceled];
private static readonly SyncStatus[] s_terminalStatuses =
[SyncStatus.Done, SyncStatus.Failed, SyncStatus.Canceled];
private readonly string _targetRoot =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly string _targetRoot =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly FakeRepositoryLister _lister = new();
private readonly FakeGitClient _git = new();
private readonly RecordingProgress _progress = new();
private readonly FakeRepositoryLister _lister = new();
public void Dispose() => TryDeleteDirectory(_targetRoot);
private readonly FakeGitClient _git = new();
private OrgSyncEngine CreateEngine() => new(_lister, _git);
private readonly RecordingProgress _progress = new();
private SyncRequest CreateRequest(int maxConcurrency = 8)
=> new("acme", "test-token", _targetRoot, maxConcurrency);
// ---------------------------------------------------------------- (a)
[Fact]
public async Task Sync_ClonesMissingRepos_AndPullsExistingOnes()
{
_lister.Repositories = Repos("alpha", "bravo", "charlie", "delta");
var locallyValid = new HashSet<string> { "alpha", "charlie" };
_git.IsValidRepositoryHandler = path => locallyValid.Contains(Path.GetFileName(path)!);
public void Dispose() => TryDeleteDirectory(_targetRoot);
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal(new[] { "bravo", "delta" }, _git.ClonedRepoNames.Order().ToArray());
Assert.Equal(new[] { "alpha", "charlie" }, _git.PulledRepoNames.Order().ToArray());
// Calls carry the right paths, URLs, and token.
foreach (var call in _git.CloneCalls)
{
var name = Path.GetFileName(call.LocalPath)!;
Assert.Equal(Path.Combine(_targetRoot, name), call.LocalPath);
Assert.Equal($"https://example.test/acme/{name}.git", call.Url);
Assert.Equal("test-token", call.Token);
}
foreach (var call in _git.PullCalls)
{
Assert.Equal(Path.Combine(_targetRoot, Path.GetFileName(call.LocalPath)!), call.LocalPath);
Assert.Equal("test-token", call.Token);
}
private OrgSyncEngine CreateEngine() => new(_lister, _git);
Assert.Equal(new SyncSummary(Total: 4, Cloned: 2, Updated: 2, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Equal([("acme", "test-token")], _lister.Calls);
Assert.True(Directory.Exists(_targetRoot), "engine should create TargetRoot");
}
// ---------------------------------------------------------------- (b)
[Fact]
public async Task Sync_NeverExceedsMaxConcurrency_ButActuallyRunsInParallel()
{
const int repoCount = 20;
const int maxConcurrency = 3;
_lister.Repositories = Repos(Enumerable.Range(0, repoCount).Select(i => $"repo{i:D2}").ToArray());
private SyncRequest CreateRequest(int MaxConcurrency = 8)
=> new("acme", "test-token", _targetRoot, MaxConcurrency);
int inFlight = 0;
int maxObserved = 0;
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.CloneHandler = async (_, _, _, _, _) =>
{
int now = Interlocked.Increment(ref inFlight);
InterlockedMax(ref maxObserved, now);
try
{
// Pile up here until the test releases the gate. The timeout is a safety
// valve so a regression cannot hang the test run; the wait is deliberately
// not cancelable (CancellationToken.None).
await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
}
finally
{
Interlocked.Decrement(ref inFlight);
}
};
var syncTask = CreateEngine().SyncAsync(CreateRequest(maxConcurrency), _progress);
// ---------------------------------------------------------------- (a)
// Wait until the limit is actually saturated, proving real parallelism.
await WaitUntilAsync(() => Volatile.Read(ref maxObserved) >= maxConcurrency,
$"{maxConcurrency} concurrent git operations in flight");
[Fact]
public async Task Sync_ClonesMissingRepos_AndPullsExistingOnes()
{
_lister.Repositories = Repos("alpha", "bravo", "charlie", "delta");
var locallyValid = new HashSet<string>(StringComparer.Ordinal) { "alpha", "charlie" };
_git.IsValidRepositoryHandler = path => locallyValid.Contains(Path.GetFileName(path)!);
gate.SetResult();
var summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(30));
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.True(Volatile.Read(ref maxObserved) <= maxConcurrency,
$"observed {maxObserved} concurrent operations; limit is {maxConcurrency}");
Assert.Equal(maxConcurrency, Volatile.Read(ref maxObserved));
Assert.Equal(new SyncSummary(repoCount, Cloned: repoCount, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
Assert.Equal(new[] { "bravo", "delta" }, _git.ClonedRepoNames.Order(StringComparer.Ordinal).ToArray());
Assert.Equal(new[] { "alpha", "charlie" }, _git.PulledRepoNames.Order(StringComparer.Ordinal).ToArray());
private static void InterlockedMax(ref int location, int value)
{
int snapshot;
while (value > (snapshot = Volatile.Read(ref location)))
{
if (Interlocked.CompareExchange(ref location, value, snapshot) == snapshot)
{
break;
}
}
}
// Calls carry the right paths, URLs, and token.
foreach(CloneCall call in _git.CloneCalls)
{
string name = Path.GetFileName(call.LocalPath)!;
Assert.Equal(Path.Combine(_targetRoot, name), call.LocalPath);
Assert.Equal($"https://example.test/acme/{name}.git", call.Url);
Assert.Equal("test-token", call.Token);
}
foreach(PullCall call in _git.PullCalls)
{
Assert.Equal(Path.Combine(_targetRoot, Path.GetFileName(call.LocalPath)!), call.LocalPath);
Assert.Equal("test-token", call.Token);
}
// ---------------------------------------------------------------- (c)
Assert.Equal(new SyncSummary(Total: 4, Cloned: 2, Updated: 2, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Equal([("acme", "test-token")], _lister.Calls);
Assert.True(Directory.Exists(_targetRoot), "engine should create TargetRoot");
}
[Fact]
public async Task Sync_OneRepoFailing_DoesNotAbortTheOthers()
{
string[] names = ["r0", "r1", "r2", "r3", "r4"];
_lister.Repositories = Repos(names);
_git.CloneHandler = (_, path, _, _, _) =>
Path.GetFileName(path) == "r2"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal(new SyncSummary(Total: 5, Cloned: 4, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
// ---------------------------------------------------------------- (b)
var failedReport = Assert.Single(_progress.Reports, r => r.Status == SyncStatus.Failed);
Assert.Equal("r2", failedReport.RepoName);
Assert.NotNull(failedReport.Error);
Assert.Contains("boom", failedReport.Error);
[Fact]
public async Task Sync_NeverExceedsMaxConcurrency_ButActuallyRunsInParallel()
{
const int RepoCount = 20;
const int MaxConcurrency = 3;
_lister.Repositories = Repos(Enumerable.Range(0, RepoCount).Select(i => $"repo{i:D2}").ToArray());
// Every repo reached exactly one terminal state; r2 failed, the rest are Done.
foreach (var name in names)
{
var statuses = _progress.StatusesFor(name);
Assert.Single(statuses, s => TerminalStatuses.Contains(s));
Assert.Equal(name == "r2" ? SyncStatus.Failed : SyncStatus.Done, statuses[^1]);
}
}
int inFlight = 0;
int maxObserved = 0;
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// ---------------------------------------------------------------- (d)
_git.CloneHandler = async (_, _, _, _, _) =>
{
int now = Interlocked.Increment(ref inFlight);
InterlockedMax(ref maxObserved, now);
try
{
// Pile up here until the test releases the gate. The timeout is a safety
// valve so a regression cannot hang the test run; the wait is deliberately
// not cancelable (CancellationToken.None).
await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None);
}
finally
{
Interlocked.Decrement(ref inFlight);
}
};
[Fact]
public async Task Sync_Cancellation_MarksInFlightAndUnstartedReposCanceled()
{
string[] names = ["c0", "c1", "c2", "c3", "c4", "c5"];
_lister.Repositories = Repos(names);
Task<SyncSummary> syncTask = CreateEngine().SyncAsync(CreateRequest(MaxConcurrency), _progress);
int entered = 0;
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// Wait until the limit is actually saturated, proving real parallelism.
await WaitUntilAsync(() => Volatile.Read(ref maxObserved) >= MaxConcurrency,
$"{MaxConcurrency} concurrent git operations in flight");
_git.CloneHandler = async (_, _, _, _, ct) =>
{
Interlocked.Increment(ref entered);
await gate.Task.WaitAsync(ct); // observes ct: throws OperationCanceledException on cancel
ct.ThrowIfCancellationRequested(); // even if the gate wins the race, honor cancellation
};
gate.SetResult();
SyncSummary summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(30));
using var cts = new CancellationTokenSource();
var syncTask = CreateEngine().SyncAsync(CreateRequest(maxConcurrency: 2), _progress, cts.Token);
Assert.True(Volatile.Read(ref maxObserved) <= MaxConcurrency,
$"observed {maxObserved} concurrent operations; limit is {MaxConcurrency}");
Assert.Equal(MaxConcurrency, Volatile.Read(ref maxObserved));
Assert.Equal(new SyncSummary(RepoCount, Cloned: RepoCount, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
// Both worker slots are occupied and blocked on the gate.
await WaitUntilAsync(() => Volatile.Read(ref entered) == 2, "2 clone operations in flight");
cts.Cancel();
gate.TrySetResult(); // release the gate; in-flight ops observe ct and throw
var summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(30));
private static void InterlockedMax(ref int location, int value)
{
int snapshot;
while(value > (snapshot = Volatile.Read(ref location)))
{
if(Interlocked.CompareExchange(ref location, value, snapshot) == snapshot)
{
break;
}
}
}
Assert.True(summary.WasCanceled);
Assert.True(summary.Canceled > 0, "at least the in-flight repos must be Canceled");
Assert.Equal(names.Length, summary.Total);
Assert.Equal(summary.Total, summary.Cloned + summary.Updated + summary.Failed + summary.Canceled);
Assert.Equal(new SyncSummary(Total: 6, Cloned: 0, Updated: 0, Failed: 0, Canceled: 6, WasCanceled: true), summary);
// Every repo reached a terminal state; none is stuck at Queued/Cloning/Pulling.
foreach (var name in names)
{
var last = _progress.LastFor(name);
Assert.NotNull(last);
Assert.Contains(last.Status, TerminalStatuses);
Assert.Equal(SyncStatus.Canceled, last.Status);
}
}
[Fact]
public async Task Sync_Cancellation_DoesNotWaitForAGitOperationThatIgnoresTheToken()
{
// A wedged native transport observes no callback and so no cancellation; the
// engine must still honor Cancel promptly by abandoning the await (#31).
_lister.Repositories = Repos("stuck", "queued");
var neverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.CloneHandler = (_, _, _, _, _) => neverDone.Task; // ignores ct entirely
// ---------------------------------------------------------------- (c)
using var cts = new CancellationTokenSource();
var syncTask = CreateEngine().SyncAsync(CreateRequest(maxConcurrency: 1), _progress, cts.Token);
await WaitUntilAsync(() => _git.CloneCalls.Count == 1, "the stuck clone to start");
[Fact]
public async Task Sync_OneRepoFailing_DoesNotAbortTheOthers()
{
string[] names = ["r0", "r1", "r2", "r3", "r4"];
_lister.Repositories = Repos(names);
_git.CloneHandler = (_, path, _, _, _) => string.Equals(Path.GetFileName(path), "r2"
, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
cts.Cancel();
var summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(10));
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.True(summary.WasCanceled);
Assert.Equal(new SyncSummary(Total: 2, Cloned: 0, Updated: 0, Failed: 0, Canceled: 2, WasCanceled: true), summary);
Assert.Equal(SyncStatus.Canceled, _progress.LastFor("stuck")!.Status);
Assert.Equal(SyncStatus.Canceled, _progress.LastFor("queued")!.Status);
neverDone.SetResult(); // let the orphan finish; nothing may change
}
Assert.Equal(new SyncSummary(Total: 5, Cloned: 4, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
[Fact]
public async Task Sync_RepoNamedLikeAWindowsDevice_FailsThatRowWithoutTouchingGit()
{
if (!OperatingSystem.IsWindows())
{
return; // the folder-name rule is a Windows one
}
_lister.Repositories = Repos("alpha", "aux", "bravo");
RepoProgress failedReport = Assert.Single(_progress.Reports, r => r.Status == SyncStatus.Failed);
Assert.Equal("r2", failedReport.RepoName);
Assert.NotNull(failedReport.Error);
Assert.Contains("boom", failedReport.Error);
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
// Every repo reached exactly one terminal state; r2 failed, the rest are Done.
foreach(string name in names)
{
IReadOnlyList<SyncStatus> statuses = _progress.StatusesFor(name);
Assert.Single(statuses, s => s_terminalStatuses.Contains(s));
Assert.Equal(string.Equals(name, "r2", StringComparison.Ordinal) ? SyncStatus.Failed : SyncStatus.Done, statuses[^1]);
}
}
Assert.Equal(new SyncSummary(Total: 3, Cloned: 2, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
var failed = _progress.LastFor("aux")!;
Assert.Equal(SyncStatus.Failed, failed.Status);
Assert.Contains("reserved Windows device name", failed.Error);
Assert.Contains("Rename the repository", failed.Error);
Assert.Null(failed.InvalidPaths); // not a path-recovery case: there is nothing to resolve
Assert.DoesNotContain("aux", _git.ClonedRepoNames);
Assert.DoesNotContain(_git.ValidityChecks, p => Path.GetFileName(p) == "aux");
}
// ---------------------------------------------------------------- (e)
[Fact]
public async Task Sync_ClonePath_ReportsQueuedCloningDoneInOrder()
{
_lister.Repositories = Repos("solo");
_git.IsValidRepositoryHandler = _ => false;
// ---------------------------------------------------------------- (d)
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
[Fact]
public async Task Sync_Cancellation_MarksInFlightAndUnstartedReposCanceled()
{
string[] names = ["c0", "c1", "c2", "c3", "c4", "c5"];
_lister.Repositories = Repos(names);
Assert.Equal([SyncStatus.Queued, SyncStatus.Cloning, SyncStatus.Done], _progress.StatusesFor("solo"));
Assert.Equal(new SyncSummary(1, Cloned: 1, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
int entered = 0;
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
[Fact]
public async Task Sync_PullPath_ReportsQueuedPullingDoneInOrder()
{
_lister.Repositories = Repos("solo");
_git.IsValidRepositoryHandler = _ => true;
_git.CloneHandler = async (_, _, _, _, ct) =>
{
Interlocked.Increment(ref entered);
await gate.Task.WaitAsync(ct); // observes ct: throws OperationCanceledException on cancel
ct.ThrowIfCancellationRequested(); // even if the gate wins the race, honor cancellation
};
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
using var cts = new CancellationTokenSource();
Task<SyncSummary> syncTask = CreateEngine().SyncAsync(CreateRequest(MaxConcurrency: 2), _progress, cts.Token);
Assert.Equal([SyncStatus.Queued, SyncStatus.Pulling, SyncStatus.Done], _progress.StatusesFor("solo"));
Assert.Equal(new SyncSummary(1, Cloned: 0, Updated: 1, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Empty(_git.CloneCalls);
}
// Both worker slots are occupied and blocked on the gate.
await WaitUntilAsync(() => Volatile.Read(ref entered) == 2, "2 clone operations in flight");
[Fact]
public async Task Sync_CloneProgressCallback_IsForwardedAsCloningPercent()
{
_lister.Repositories = Repos("solo");
_git.CloneHandler = (_, _, _, onProgress, _) =>
{
onProgress?.Invoke(0.25);
onProgress?.Invoke(0.75);
return Task.CompletedTask;
};
cts.Cancel();
gate.TrySetResult(); // release the gate; in-flight ops observe ct and throw
await CreateEngine().SyncAsync(CreateRequest(), _progress);
SyncSummary summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(30));
var reports = _progress.Reports.Where(r => r.RepoName == "solo").ToArray();
Assert.Equal(
[
(SyncStatus.Queued, (double?)null),
Assert.True(summary.WasCanceled);
Assert.True(summary.Canceled > 0, "at least the in-flight repos must be Canceled");
Assert.Equal(names.Length, summary.Total);
Assert.Equal(summary.Total, summary.Cloned + summary.Updated + summary.Failed + summary.Canceled);
Assert.Equal(new SyncSummary(Total: 6, Cloned: 0, Updated: 0, Failed: 0, Canceled: 6, WasCanceled: true), summary);
// Every repo reached a terminal state; none is stuck at Queued/Cloning/Pulling.
foreach(string name in names)
{
RepoProgress? last = _progress.LastFor(name);
Assert.NotNull(last);
Assert.Contains(last.Status, s_terminalStatuses);
Assert.Equal(SyncStatus.Canceled, last.Status);
}
}
[Fact]
public async Task Sync_Cancellation_DoesNotWaitForAGitOperationThatIgnoresTheToken()
{
// A wedged native transport observes no callback and so no cancellation; the
// engine must still honor Cancel promptly by abandoning the await (#31).
_lister.Repositories = Repos("stuck", "queued");
var neverDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_git.CloneHandler = (_, _, _, _, _) => neverDone.Task; // ignores ct entirely
using var cts = new CancellationTokenSource();
Task<SyncSummary> syncTask = CreateEngine().SyncAsync(CreateRequest(MaxConcurrency: 1), _progress, cts.Token);
await WaitUntilAsync(() => _git.CloneCalls.Count == 1, "the stuck clone to start");
cts.Cancel();
SyncSummary summary = await syncTask.WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(summary.WasCanceled);
Assert.Equal(new SyncSummary(Total: 2, Cloned: 0, Updated: 0, Failed: 0, Canceled: 2, WasCanceled: true), summary);
Assert.Equal(SyncStatus.Canceled, _progress.LastFor("stuck")!.Status);
Assert.Equal(SyncStatus.Canceled, _progress.LastFor("queued")!.Status);
neverDone.SetResult(); // let the orphan finish; nothing may change
}
[Fact]
public async Task Sync_RepoNamedLikeAWindowsDevice_FailsThatRowWithoutTouchingGit()
{
if(!OperatingSystem.IsWindows())
{
return; // the folder-name rule is a Windows one
}
_lister.Repositories = Repos("alpha", "aux", "bravo");
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal(new SyncSummary(Total: 3, Cloned: 2, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
RepoProgress failed = _progress.LastFor("aux")!;
Assert.Equal(SyncStatus.Failed, failed.Status);
Assert.Contains("reserved Windows device name", failed.Error);
Assert.Contains("Rename the repository", failed.Error);
Assert.Null(failed.InvalidPaths); // not a path-recovery case: there is nothing to resolve
Assert.DoesNotContain("aux", _git.ClonedRepoNames, StringComparer.Ordinal);
Assert.DoesNotContain(_git.ValidityChecks, p => string.Equals(Path.GetFileName(p), "aux", StringComparison.Ordinal));
}
// ---------------------------------------------------------------- (e)
[Fact]
public async Task Sync_ClonePath_ReportsQueuedCloningDoneInOrder()
{
_lister.Repositories = Repos("solo");
_git.IsValidRepositoryHandler = _ => false;
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal([SyncStatus.Queued, SyncStatus.Cloning, SyncStatus.Done], _progress.StatusesFor("solo"));
Assert.Equal(new SyncSummary(1, Cloned: 1, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
[Fact]
public async Task Sync_PullPath_ReportsQueuedPullingDoneInOrder()
{
_lister.Repositories = Repos("solo");
_git.IsValidRepositoryHandler = _ => true;
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal([SyncStatus.Queued, SyncStatus.Pulling, SyncStatus.Done], _progress.StatusesFor("solo"));
Assert.Equal(new SyncSummary(1, Cloned: 0, Updated: 1, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Empty(_git.CloneCalls);
}
[Fact]
public async Task Sync_CloneProgressCallback_IsForwardedAsCloningPercent()
{
_lister.Repositories = Repos("solo");
_git.CloneHandler = (_, _, _, onProgress, _) =>
{
onProgress?.Invoke(0.25);
onProgress?.Invoke(0.75);
return Task.CompletedTask;
};
await CreateEngine().SyncAsync(CreateRequest(), _progress);
RepoProgress[] reports = _progress.Reports.Where(r => string.Equals(r.RepoName, "solo", StringComparison.Ordinal)).ToArray();
Assert.Equal(
[
(SyncStatus.Queued, null),
(SyncStatus.Cloning, null),
(SyncStatus.Cloning, 0.25),
(SyncStatus.Cloning, 0.75),
(SyncStatus.Done, null),
],
reports.Select(r => (r.Status, r.Percent)).ToArray());
}
reports.Select(r => (r.Status, r.Percent)).ToArray());
}
// ---------------------------------------------------------------- (f)
[Fact]
public async Task Sync_EmptyOrganization_ReturnsZeroTotalsAndMakesNoGitCalls()
{
_lister.Repositories = [];
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
// ---------------------------------------------------------------- (f)
Assert.Equal(new SyncSummary(Total: 0, Cloned: 0, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
Assert.Empty(_git.ValidityChecks);
Assert.Empty(_progress.Reports);
}
[Fact]
public async Task Sync_EmptyOrganization_ReturnsZeroTotalsAndMakesNoGitCalls()
{
_lister.Repositories = [];
// ---------------------------------------------------------------- request validation & lister errors
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
[Fact]
public async Task Sync_NullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(() => CreateEngine().SyncAsync(null!));
}
Assert.Equal(new SyncSummary(Total: 0, Cloned: 0, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
Assert.Empty(_git.ValidityChecks);
Assert.Empty(_progress.Reports);
}
[Theory]
[InlineData("", "token")]
[InlineData(" ", "token")]
[InlineData("org", "")]
[InlineData("org", " ")]
public async Task Sync_BlankOrganizationOrToken_ThrowsArgumentException(string organization, string token)
{
var request = new SyncRequest(organization, token, _targetRoot);
await Assert.ThrowsAnyAsync<ArgumentException>(() => CreateEngine().SyncAsync(request));
Assert.Empty(_lister.Calls);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Sync_BlankTargetRoot_ThrowsArgumentException(string targetRoot)
{
var request = new SyncRequest("org", "token", targetRoot);
// ---------------------------------------------------------------- request validation & lister errors
await Assert.ThrowsAnyAsync<ArgumentException>(() => CreateEngine().SyncAsync(request));
Assert.Empty(_lister.Calls);
}
[Fact]
public async Task Sync_NullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(() => CreateEngine().SyncAsync(null!));
}
[Theory]
[InlineData("../escape")]
[InlineData("..\\escape")]
[InlineData("nested/child")]
[InlineData("nested\\child")]
[InlineData("C:\\Windows")]
[InlineData("/etc")]
[InlineData("\\\\server\\share")]
[InlineData("..")]
[InlineData(".")]
[InlineData("")]
public async Task Sync_RepoNameThatIsNotOneSafeSegment_FailsBeforeTouchingDisk(string badName)
{
// The list overload is a public API: a descriptor whose Name would land
// outside TargetRoot (or deeper than one level) must fail the whole call
// before the root is created, anything is reported, or git is invoked.
RepoDescriptor[] repositories = [Repo("alpha"), Repo(badName), Repo("bravo")];
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => CreateEngine().SyncAsync(CreateRequest(), repositories, _progress));
Assert.Contains(badName, ex.Message);
Assert.False(Directory.Exists(_targetRoot), "nothing may be created when a name is rejected");
Assert.Empty(_progress.Reports);
Assert.Empty(_git.ValidityChecks);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
}
[Theory]
[InlineData("", "token")]
[InlineData(" ", "token")]
[InlineData("org", "")]
[InlineData("org", " ")]
public async Task Sync_BlankOrganizationOrToken_ThrowsArgumentException(string organization, string token)
{
var request = new SyncRequest(organization, token, _targetRoot);
[Fact]
public async Task Sync_RepoNameFromLister_IsRejectedTheSameWay()
{
_lister.Repositories = [Repo("alpha"), Repo("../escape")];
await Assert.ThrowsAnyAsync<ArgumentException>(() => CreateEngine().SyncAsync(request));
Assert.Empty(_lister.Calls);
}
await Assert.ThrowsAsync<ArgumentException>(() => CreateEngine().SyncAsync(CreateRequest(), _progress));
Assert.False(Directory.Exists(_targetRoot));
Assert.Empty(_git.CloneCalls);
}
[Fact]
public async Task Sync_PassesTheNormalizedAbsoluteFolder_ToTheGitClient()
{
// A relative TargetRoot is resolved against the current directory once, so
// the folder handed to git is absolute and sits exactly one level below it.
_lister.Repositories = Repos("alpha");
string relativeRoot = Path.GetRelativePath(Environment.CurrentDirectory, _targetRoot);
var request = new SyncRequest("acme", "test-token", relativeRoot);
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task Sync_BlankTargetRoot_ThrowsArgumentException(string targetRoot)
{
var request = new SyncRequest("org", "token", targetRoot);
await CreateEngine().SyncAsync(request, _progress);
await Assert.ThrowsAnyAsync<ArgumentException>(() => CreateEngine().SyncAsync(request));
Assert.Empty(_lister.Calls);
}
var call = Assert.Single(_git.CloneCalls);
Assert.True(Path.IsPathRooted(call.LocalPath));
Assert.Equal(Path.GetFullPath(Path.Combine(_targetRoot, "alpha")), call.LocalPath);
}
[Fact]
public async Task Sync_ListerFailure_PropagatesAndMakesNoGitCalls()
{
_lister.ExceptionToThrow = new InvalidOperationException("org not found");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateEngine().SyncAsync(CreateRequest(), _progress));
[Theory]
[InlineData("../escape")]
[InlineData("..\\escape")]
[InlineData("nested/child")]
[InlineData("nested\\child")]
[InlineData("C:\\Windows")]
[InlineData("/etc")]
[InlineData("\\\\server\\share")]
[InlineData("..")]
[InlineData(".")]
[InlineData("")]
public async Task Sync_RepoNameThatIsNotOneSafeSegment_FailsBeforeTouchingDisk(string badName)
{
// The list overload is a public API: a descriptor whose Name would land
// outside TargetRoot (or deeper than one level) must fail the whole call
// before the root is created, anything is reported, or git is invoked.
RepoDescriptor[] repositories = [Repo("alpha"), Repo(badName), Repo("bravo")];
Assert.Equal("org not found", ex.Message);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
Assert.Empty(_progress.Reports);
}
ArgumentException ex = await Assert.ThrowsAsync<ArgumentException>(
() => CreateEngine().SyncAsync(CreateRequest(), repositories, _progress));
[Fact]
public async Task Sync_TargetRootCreationFails_PropagatesWithNoProgressReports()
{
// A file sitting where the target directory should go makes CreateDirectory throw.
Directory.CreateDirectory(Path.GetDirectoryName(_targetRoot)!);
File.WriteAllText(_targetRoot, "blocking file");
try
{
_lister.Repositories = Repos("alpha");
Assert.Contains(badName, ex.Message);
Assert.False(Directory.Exists(_targetRoot), "nothing may be created when a name is rejected");
Assert.Empty(_progress.Reports);
Assert.Empty(_git.ValidityChecks);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
}
await Assert.ThrowsAsync<IOException>(
() => CreateEngine().SyncAsync(CreateRequest(), _progress));
// Failing before any Queued report means no repo is stranded mid-state.
Assert.Empty(_progress.Reports);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
}
finally
{
File.Delete(_targetRoot); // Dispose only handles directories
}
}
[Fact]
public async Task Sync_ReportsAllReposQueued_BeforeAnyGitWork()
{
var names = new[] { "alpha", "bravo", "charlie", "delta", "echo" };
_lister.Repositories = Repos(names);
[Fact]
public async Task Sync_RepoNameFromLister_IsRejectedTheSameWay()
{
_lister.Repositories = [Repo("alpha"), Repo("../escape")];
await CreateEngine().SyncAsync(CreateRequest(), _progress);
await Assert.ThrowsAsync<ArgumentException>(() => CreateEngine().SyncAsync(CreateRequest(), _progress));
// The engine queues the full list synchronously before the parallel phase,
// so the first N reports must be Queued and cover every repo exactly once.
var first = _progress.Reports.Take(names.Length).ToArray();
Assert.All(first, r => Assert.Equal(SyncStatus.Queued, r.Status));
Assert.Equal(names.Order().ToArray(), first.Select(r => r.RepoName).Order().ToArray());
}
Assert.False(Directory.Exists(_targetRoot));
Assert.Empty(_git.CloneCalls);
}
[Fact]
public async Task Sync_OceWithoutCancellation_CountsAsFailedAndDoesNotStopOthers()
{
_lister.Repositories = Repos("alpha", "bravo", "charlie");
_git.CloneHandler = (url, path, token, onProgress, ct) =>
Path.GetFileName(path) == "bravo"
? Task.FromException(new OperationCanceledException("spurious"))
: Task.CompletedTask;
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
// An OperationCanceledException while the caller's token is NOT canceled is a
// repo failure, not a cancellation: the run must complete the other repos.
Assert.Equal(new SyncSummary(Total: 3, Cloned: 2, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
Assert.Equal(SyncStatus.Failed, _progress.LastFor("bravo")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("alpha")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("charlie")!.Status);
}
[Fact]
public async Task Sync_PassesTheNormalizedAbsoluteFolder_ToTheGitClient()
{
// A relative TargetRoot is resolved against the current directory once, so
// the folder handed to git is absolute and sits exactly one level below it.
_lister.Repositories = Repos("alpha");
string relativeRoot = Path.GetRelativePath(Environment.CurrentDirectory, _targetRoot);
var request = new SyncRequest("acme", "test-token", relativeRoot);
[Fact]
public async Task Sync_DuplicateRepoNames_AreProcessedOnce()
{
_lister.Repositories =
[
Repo("alpha"),
await CreateEngine().SyncAsync(request, _progress);
CloneCall call = Assert.Single(_git.CloneCalls);
Assert.True(Path.IsPathRooted(call.LocalPath));
Assert.Equal(Path.GetFullPath(Path.Combine(_targetRoot, "alpha")), call.LocalPath);
}
[Fact]
public async Task Sync_ListerFailure_PropagatesAndMakesNoGitCalls()
{
_lister.ExceptionToThrow = new InvalidOperationException("org not found");
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateEngine().SyncAsync(CreateRequest(), _progress));
Assert.Equal("org not found", ex.Message);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
Assert.Empty(_progress.Reports);
}
[Fact]
public async Task Sync_TargetRootCreationFails_PropagatesWithNoProgressReports()
{
// A file sitting where the target directory should go makes CreateDirectory throw.
Directory.CreateDirectory(Path.GetDirectoryName(_targetRoot)!);
File.WriteAllText(_targetRoot, "blocking file");
try
{
_lister.Repositories = Repos("alpha");
await Assert.ThrowsAsync<IOException>(
() => CreateEngine().SyncAsync(CreateRequest(), _progress));
// Failing before any Queued report means no repo is stranded mid-state.
Assert.Empty(_progress.Reports);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
}
finally
{
File.Delete(_targetRoot); // Dispose only handles directories
}
}
[Fact]
public async Task Sync_ReportsAllReposQueued_BeforeAnyGitWork()
{
string[] names = new[] { "alpha", "bravo", "charlie", "delta", "echo" };
_lister.Repositories = Repos(names);
await CreateEngine().SyncAsync(CreateRequest(), _progress);
// The engine queues the full list synchronously before the parallel phase,
// so the first N reports must be Queued and cover every repo exactly once.
RepoProgress[] first = _progress.Reports.Take(names.Length).ToArray();
Assert.All(first, r => Assert.Equal(SyncStatus.Queued, r.Status));
Assert.Equal(names.Order(StringComparer.Ordinal).ToArray(), first.Select(r => r.RepoName).Order(StringComparer.Ordinal).ToArray());
}
[Fact]
public async Task Sync_OceWithoutCancellation_CountsAsFailedAndDoesNotStopOthers()
{
_lister.Repositories = Repos("alpha", "bravo", "charlie");
_git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "bravo"
, StringComparison.Ordinal) ? Task.FromException(new OperationCanceledException("spurious"))
: Task.CompletedTask;
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
// An OperationCanceledException while the caller's token is NOT canceled is a
// repo failure, not a cancellation: the run must complete the other repos.
Assert.Equal(new SyncSummary(Total: 3, Cloned: 2, Updated: 0, Failed: 1, Canceled: 0, WasCanceled: false), summary);
Assert.Equal(SyncStatus.Failed, _progress.LastFor("bravo")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("alpha")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("charlie")!.Status);
}
[Fact]
public async Task Sync_DuplicateRepoNames_AreProcessedOnce()
{
_lister.Repositories =
[
Repo("alpha"),
new RepoDescriptor("Alpha", "https://example.test/acme/Alpha.git", "main", IsArchived: false),
];
var summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), _progress);
Assert.Equal(1, summary.Total);
Assert.Single(_git.CloneCalls);
}
Assert.Equal(1, summary.Total);
Assert.Single(_git.CloneCalls);
}
}
+70 -53
View File
@@ -1,8 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
using static gclo.ViewModels.PathRecoveryPlanner;
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="PathRecoveryPlanner"/> is the pre-flight behind the recovery dialog's
/// "Apply and check out": it must build exactly the recovery the engine expects and
@@ -10,74 +17,84 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class PathRecoveryPlannerTests
{
[Fact]
public void Build_RenamesAndSkips_ProduceTheRecovery_WithNoProblems()
{
var plan = Build(
[
new Choice("bad:name.txt", "bad_name.txt", Skip: false),
[Fact]
public void Build_RenamesAndSkips_ProduceTheRecovery_WithNoProblems()
{
Plan plan = Build(
[
new Choice("bad:name.txt", "bad_name.txt", Skip: false),
new Choice("dir/CON", "dir/CON_", Skip: false),
new Choice("Readme.md", null, Skip: true),
]);
Assert.Empty(plan.Problems);
Assert.Equal("bad_name.txt", plan.Recovery.SegmentRenames["bad:name.txt"]);
Assert.Equal("dir/CON_", plan.Recovery.SegmentRenames["dir/CON"]);
Assert.Equal(["Readme.md"], plan.Recovery.SkippedPaths);
}
Assert.Empty(plan.Problems);
Assert.Equal("bad_name.txt", plan.Recovery.SegmentRenames["bad:name.txt"]);
Assert.Equal("dir/CON_", plan.Recovery.SegmentRenames["dir/CON"]);
Assert.Equal(["Readme.md"], plan.Recovery.SkippedPaths, StringComparer.Ordinal);
}
[Fact]
public void Build_UnchangedRowThatIsNotSkipped_IsAProblem()
{
var plan = Build([new Choice("Readme.md", null, Skip: false)]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'Readme.md' is still invalid", problem);
Assert.Empty(plan.Recovery.SegmentRenames);
Assert.Empty(plan.Recovery.SkippedPaths);
}
[Fact]
public void Build_ReplacementThatIsItselfInvalid_IsAProblem()
{
var plan = Build([new Choice("bad:name.txt", "still:bad.txt", Skip: false)]);
[Fact]
public void Build_UnchangedRowThatIsNotSkipped_IsAProblem()
{
Plan plan = Build([new Choice("Readme.md", null, Skip: false)]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'still:bad.txt' cannot be used", problem);
Assert.Contains("invalid on Windows", problem);
}
string problem = Assert.Single(plan.Problems);
Assert.Contains("'Readme.md' is still invalid", problem);
Assert.Empty(plan.Recovery.SegmentRenames);
Assert.Empty(plan.Recovery.SkippedPaths);
}
[Fact]
public void Build_TwoRowsRenamedOntoOneDestination_IsAProblem()
{
var plan = Build(
[
new Choice("a:b.txt", "a_b.txt", Skip: false),
[Fact]
public void Build_ReplacementThatIsItselfInvalid_IsAProblem()
{
Plan plan = Build([new Choice("bad:name.txt", "still:bad.txt", Skip: false)]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'still:bad.txt' cannot be used", problem);
Assert.Contains("invalid on Windows", problem);
}
[Fact]
public void Build_TwoRowsRenamedOntoOneDestination_IsAProblem()
{
Plan plan = Build(
[
new Choice("a:b.txt", "a_b.txt", Skip: false),
new Choice("a?b.txt", "a_b.txt", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("more than one path maps to this destination", problem);
}
string problem = Assert.Single(plan.Problems);
Assert.Contains("more than one path maps to this destination", problem);
}
[Fact]
public void Build_SamePathInTwoRows_LastRenameWins_AndSkipTrumpsNothing()
{
// The validator can list one path twice (invalid segment AND case collision);
// the dialog shows two rows, and the recovery must still be a single mapping.
var plan = Build(
[
new Choice("x:y.txt", "x_y.txt", Skip: false),
[Fact]
public void Build_SamePathInTwoRows_LastRenameWins_AndSkipTrumpsNothing()
{
// The validator can list one path twice (invalid segment AND case collision);
// the dialog shows two rows, and the recovery must still be a single mapping.
Plan plan = Build(
[
new Choice("x:y.txt", "x_y.txt", Skip: false),
new Choice("x:y.txt", "x-y.txt", Skip: false),
]);
Assert.Empty(plan.Problems);
Assert.Equal("x-y.txt", Assert.Single(plan.Recovery.SegmentRenames).Value);
}
Assert.Empty(plan.Problems);
Assert.Equal("x-y.txt", Assert.Single(plan.Recovery.SegmentRenames).Value);
}
[Fact]
public void Build_Null_Throws()
{
Assert.Throws<ArgumentNullException>(() => Build(null!));
}
[Fact]
public void Build_Null_Throws()
{
Assert.Throws<ArgumentNullException>(() => Build(null!));
}
}
+502 -439
View File
@@ -1,10 +1,16 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text;
using gclo.Engine;
using LibGit2Sharp;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for path recovery: a clone whose tree contains Windows-invalid paths keeps
/// the fetched repository (marked checkout-pending) instead of deleting it, and
@@ -15,461 +21,518 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class PathRecoveryTests : IDisposable
{
private const string Token = "test-token";
private const string PendingKey = "gclo.checkoutpending";
private const string Token = "test-token";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private const string PendingKey = "gclo.checkoutpending";
private readonly LibGit2GitClient _client = new();
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public PathRecoveryTests()
{
Directory.CreateDirectory(_root);
}
private readonly LibGit2GitClient _client = new();
public void Dispose() => TryDeleteDirectory(_root);
// ---------------------------------------------------------------- clone marker
[Fact]
public async Task Clone_InvalidPaths_KeepsFetchedRepo_SetsPendingMarker_AndThrowsTyped()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = NewPath("marker-clone");
public PathRecoveryTests()
{
Directory.CreateDirectory(_root);
}
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "bad:file.txt");
Assert.True(_client.IsValidRepository(target));
Assert.True(IsCheckoutPending(target));
// Nothing may have been checked out, and no recovery exists yet.
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
Assert.False(File.Exists(RecoveryFilePath(target)));
}
// ---------------------------------------------------------------- apply recovery
public void Dispose() => TryDeleteDirectory(_root);
[Fact]
public async Task ApplyRecovery_RenamedFile_MaterializesContentUnderNewName()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "rename-apply");
var recovery = Recovery(renames: [("bad:file.txt", "bad_file.txt")]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("bad content", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
}
// ---------------------------------------------------------------- clone marker
[Fact]
public async Task ApplyRecovery_RenamedDirectory_RelocatesItsWholeSubtree()
{
string source = CreateForgedRepo(
_root,
("bad:dir/inner.txt", "inner content"),
("bad:dir/sub/deep.txt", "deep content"));
string target = await CloneExpectingInvalidPathsAsync(source, "dir-rename");
[Fact]
public async Task Clone_InvalidPaths_KeepsFetchedRepo_SetsPendingMarker_AndThrowsTyped()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = NewPath("marker-clone");
var recovery = Recovery(renames: [("bad:dir", "bad_dir")]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
Assert.Equal("inner content", File.ReadAllText(Path.Combine(target, "bad_dir", "inner.txt")));
Assert.Equal("deep content", File.ReadAllText(Path.Combine(target, "bad_dir", "sub", "deep.txt")));
}
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:file.txt", StringComparison.Ordinal));
Assert.True(_client.IsValidRepository(target));
Assert.True(IsCheckoutPending(target));
// Nothing may have been checked out, and no recovery exists yet.
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
Assert.False(File.Exists(RecoveryFilePath(target)));
}
[Fact]
public async Task ApplyRecovery_DirectoryAndDescendantBothRenamed_ComposesOntoEffectivePrefix()
{
// Rename composition is prefix-aware: a rename value contributes only its LAST
// segment, joined onto the parent's EFFECTIVE (already renamed) prefix. So a
// recovery renaming both a directory and a file inside it — with the file's
// replacement expressed under the ORIGINAL directory name, as validators and
// dialogs produce it — must land the file under the RENAMED directory.
string source = CreateForgedRepo(
_root,
("bad:dir/bad:inner.txt", "inner content"),
("bad:dir/sub/deep.txt", "deep content"));
string target = await CloneExpectingInvalidPathsAsync(source, "dir-and-descendant");
var recovery = Recovery(renames:
[
("bad:dir", "bad_dir"),
// ---------------------------------------------------------------- apply recovery
[Fact]
public async Task ApplyRecovery_RenamedFile_MaterializesContentUnderNewName()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "rename-apply");
PathRecovery recovery = Recovery(renames: [("bad:file.txt", "bad_file.txt")]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("bad content", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
}
[Fact]
public async Task ApplyRecovery_RenamedDirectory_RelocatesItsWholeSubtree()
{
string source = CreateForgedRepo(
_root,
("bad:dir/inner.txt", "inner content"),
("bad:dir/sub/deep.txt", "deep content"));
string target = await CloneExpectingInvalidPathsAsync(source, "dir-rename");
PathRecovery recovery = Recovery(renames: [("bad:dir", "bad_dir")]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("inner content", File.ReadAllText(Path.Combine(target, "bad_dir", "inner.txt")));
Assert.Equal("deep content", File.ReadAllText(Path.Combine(target, "bad_dir", "sub", "deep.txt")));
}
[Fact]
public async Task ApplyRecovery_DirectoryAndDescendantBothRenamed_ComposesOntoEffectivePrefix()
{
// Rename composition is prefix-aware: a rename value contributes only its LAST
// segment, joined onto the parent's EFFECTIVE (already renamed) prefix. So a
// recovery renaming both a directory and a file inside it — with the file's
// replacement expressed under the ORIGINAL directory name, as validators and
// dialogs produce it — must land the file under the RENAMED directory.
string source = CreateForgedRepo(
_root,
("bad:dir/bad:inner.txt", "inner content"),
("bad:dir/sub/deep.txt", "deep content"));
string target = await CloneExpectingInvalidPathsAsync(source, "dir-and-descendant");
PathRecovery recovery = Recovery(renames:
[
("bad:dir", "bad_dir"),
("bad:dir/bad:inner.txt", "bad:dir/bad_inner.txt"),
]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("inner content", File.ReadAllText(Path.Combine(target, "bad_dir", "bad_inner.txt")));
Assert.Equal("deep content", File.ReadAllText(Path.Combine(target, "bad_dir", "sub", "deep.txt")));
Assert.False(IsCheckoutPending(target));
}
[Fact]
public async Task ApplyRecovery_SkippedPaths_OmitFilesAndWholeDirectories()
{
string source = CreateForgedRepo(
_root,
("bad:one.txt", "unwanted"),
("good.txt", "kept"),
("junk/a.txt", "junk a"),
("junk/sub/b.txt", "junk b"),
("docs/keep.txt", "docs kept"));
string target = await CloneExpectingInvalidPathsAsync(source, "skip-apply");
// Skip the invalid file outright and a whole (valid) directory subtree.
var recovery = Recovery(skips: ["bad:one.txt", "junk"]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("kept", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("docs kept", File.ReadAllText(Path.Combine(target, "docs", "keep.txt")));
Assert.False(Directory.Exists(Path.Combine(target, "junk")));
Assert.False(IsCheckoutPending(target));
}
[Fact]
public async Task ApplyRecovery_MappingStillInvalid_ThrowsListingEffectivePaths_AndWritesNothing()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "still-invalid");
var recovery = Recovery(renames: [("bad:file.txt", "still:bad.txt")]);
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None));
// The exception lists the EFFECTIVE (post-mapping) path, not the original.
Assert.Contains(ex.Paths, p => p.RepoPath == "still:bad.txt");
// Validation runs before any write: even the valid file must not appear,
// the marker stays pending, and no recovery is persisted.
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
Assert.True(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
}
[Fact]
public async Task ApplyRecovery_TwoOriginalsMappedToOneDestination_Throws()
{
string source = CreateForgedRepo(_root, ("bad:a.txt", "content a"), ("bad:b.txt", "content b"));
string target = await CloneExpectingInvalidPathsAsync(source, "collision");
var recovery = Recovery(renames: [("bad:a.txt", "merged.txt"), ("bad:b.txt", "merged.txt")]);
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "merged.txt");
Assert.False(File.Exists(Path.Combine(target, "merged.txt")));
}
[Fact]
public async Task ApplyRecovery_PersistsRecoveryJsonUnderDotGit_AndClearsMarker()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "persist");
var recovery = Recovery(renames: [("bad:file.txt", "bad_file.txt")]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
string json = File.ReadAllText(RecoveryFilePath(target));
Assert.Contains("bad:file.txt", json);
Assert.Contains("bad_file.txt", json);
}
[Fact]
public async Task ApplyRecovery_OnRecoveryManagedRepo_MergesIncomingWithStoredRecovery()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"));
string target = await CloneExpectingInvalidPathsAsync(source, "incremental");
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
// Upstream introduces a NEW invalid path the stored recovery does not cover;
// the pull fails typed, exactly like the original clone did.
AppendForgedCommit(source, ("worse:new.txt", "new content"));
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "worse:new.txt");
// Applying a recovery that covers ONLY the new path merges with the stored one:
// the old rename keeps working and the persisted json carries both mappings.
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("worse:new.txt", "worse_new.txt")]), CancellationToken.None);
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "worse_new.txt")));
string json = File.ReadAllText(RecoveryFilePath(target));
Assert.Contains("bad_file.txt", json);
Assert.Contains("worse_new.txt", json);
}
// ---------------------------------------------------------------- persistence hardening (#31)
[Fact]
public async Task FetchAndPull_UpstreamDeletedAFile_RemovesItFromTheMaterializedTree()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("gone.txt", "going"), ("keep.txt", "keep"));
string target = await CloneExpectingInvalidPathsAsync(source, "deletion-pass");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
Assert.True(File.Exists(Path.Combine(target, "gone.txt")));
ReplaceForgedEntry(source, remove: "gone.txt", add: ("new/nested.txt", "new"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
// A real checkout removes what left the tree; the materialization must too.
Assert.False(File.Exists(Path.Combine(target, "gone.txt")));
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "new", "nested.txt")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
// And a directory emptied by a later deletion is pruned too.
ReplaceForgedEntry(source, remove: "new/nested.txt", add: ("other.txt", "o"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.False(Directory.Exists(Path.Combine(target, "new")));
}
[Fact]
public async Task FetchAndPull_RecoveryManagedRepoAlreadyAtTip_DoesNotRewriteTheTree()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("keep.txt", "keep"));
string target = await CloneExpectingInvalidPathsAsync(source, "no-rewrite");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
// A local edit is the witness: an unchanged tip must leave the tree alone.
File.WriteAllText(Path.Combine(target, "keep.txt"), "locally edited");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("locally edited", File.ReadAllText(Path.Combine(target, "keep.txt")));
// A new upstream commit re-materializes through the mapping as before.
ReplaceForgedEntry(source, remove: "keep.txt", add: ("keep.txt", "upstream edit"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("upstream edit", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task CorruptRecoveryFile_FailsWithAnActionableMessage_NotARawJsonError()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"));
string target = await CloneExpectingInvalidPathsAsync(source, "corrupt-json");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
string file = RecoveryFilePath(target);
File.WriteAllText(file, "{ \"SegmentRenames\": { truncated");
var pullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("corrupt", pullError.Message);
Assert.Contains(file, pullError.Message);
Assert.Contains("Delete it", pullError.Message);
var applyError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None));
Assert.Contains("corrupt", applyError.Message);
// "null" is a corrupt document too.
File.WriteAllText(file, "null");
var nullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("empty", nullError.Message);
}
[Fact]
public async Task SaveRecovery_WritesAtomically_LeavingNoTempFile_AndRecordsManifestAndTip()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("dir/keep.txt", "k"));
string target = await CloneExpectingInvalidPathsAsync(source, "atomic");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
string file = RecoveryFilePath(target);
Assert.False(File.Exists(file + ".tmp"));
string json = File.ReadAllText(file);
Assert.Contains("\"MaterializedPaths\"", json);
Assert.Contains("bad_file.txt", json);
Assert.Contains("dir/keep.txt", json);
Assert.Contains(HeadSha(target), json);
}
// ---------------------------------------------------------------- pull semantics
[Fact]
public async Task FetchAndPull_MarkerRepoWithoutRecovery_RethrowsTypedException()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "marker-pull");
// The repo must not silently report success with an empty working tree.
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "bad:file.txt");
Assert.True(IsCheckoutPending(target));
}
[Fact]
public async Task FetchAndPull_MarkerRepoUpstreamFixedPaths_ChecksOutAndClearsMarker()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "fixed-upstream");
ReplaceForgedEntry(source, remove: "bad:file.txt", add: ("fixed.txt", "fixed content"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("fixed content", File.ReadAllText(Path.Combine(target, "fixed.txt")));
Assert.False(IsCheckoutPending(target));
Assert.Equal(HeadSha(source), HeadSha(target));
}
[Fact]
public async Task FetchAndPull_WithStoredRecovery_RematerializesNewCommitsThroughMapping()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"), ("good.txt", "g1"));
string target = await CloneExpectingInvalidPathsAsync(source, "recovery-pull");
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
// Upstream rewrites the still-invalid file; the pull must land the new
// content at the mapped name.
string newSha = AppendForgedCommit(source, ("bad:file.txt", "v2"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("v2", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("g1", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal(newSha, HeadSha(target));
}
[Fact]
public async Task FetchAndPull_WithStoredRecovery_NewInvalidPathNotCovered_Throws()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"));
string target = await CloneExpectingInvalidPathsAsync(source, "uncovered-pull");
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
AppendForgedCommit(source, ("worse:new.txt", "not covered"));
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "worse:new.txt");
// The previously materialized working tree is untouched.
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
}
[Fact]
public async Task CloneAndPull_ValidRepo_NeverWritesMarkerOrRecovery()
{
string source = CreateForgedRepo(_root, ("readme.txt", "hello"));
string target = NewPath("normal");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
Assert.Equal("hello", File.ReadAllText(Path.Combine(target, "readme.txt")));
}
// ---------------------------------------------------------------- engine payload
[Fact]
public async Task OrgSync_InvalidPathFailure_AttachesInvalidPathsToFailedReport()
{
var lister = new FakeRepositoryLister
{
Repositories = [new RepoDescriptor("broken", "https://example.test/acme/broken.git", "main", IsArchived: false)],
};
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(
[new InvalidPathInfo("bad:file.txt", "contains a character that is invalid on Windows", "bad_file.txt")])),
};
var progress = new RecordingProgress();
var summary = await new OrgSyncEngine(lister, git)
.SyncAsync(new SyncRequest("acme", Token, NewPath("org-root")), progress);
Assert.Equal(1, summary.Failed);
var report = progress.LastFor("broken");
Assert.NotNull(report);
Assert.Equal(SyncStatus.Failed, report!.Status);
Assert.NotNull(report.InvalidPaths);
var info = Assert.Single(report.InvalidPaths!);
Assert.Equal("bad:file.txt", info.RepoPath);
Assert.Equal("bad_file.txt", info.SuggestedName);
}
[Fact]
public async Task OrgSync_OtherFailures_AttachNoInvalidPathsPayload()
{
var lister = new FakeRepositoryLister
{
Repositories = [new RepoDescriptor("flaky", "https://example.test/acme/flaky.git", "main", IsArchived: false)],
};
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidOperationException("boom")),
};
var progress = new RecordingProgress();
await new OrgSyncEngine(lister, git)
.SyncAsync(new SyncRequest("acme", Token, NewPath("org-root-2")), progress);
var report = progress.LastFor("flaky");
Assert.NotNull(report);
Assert.Equal(SyncStatus.Failed, report!.Status);
Assert.Null(report.InvalidPaths);
}
// ---------------------------------------------------------------- fixture helpers
private string NewPath(string name) => Path.Combine(_root, name);
/// <summary>Clones a repo known to hold invalid paths, asserting the typed failure; returns the kept target.</summary>
private async Task<string> CloneExpectingInvalidPathsAsync(string source, string name)
{
string target = NewPath(name);
await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
return target;
}
private static PathRecovery Recovery(
(string From, string To)[]? renames = null,
string[]? skips = null)
=> new(
(renames ?? []).ToDictionary(r => r.From, r => r.To, StringComparer.Ordinal),
new HashSet<string>(skips ?? [], StringComparer.Ordinal));
/// <summary>Adds a commit that removes one forged entry and adds another — "upstream fixed the path".</summary>
private static void ReplaceForgedEntry(string workdir, string remove, (string Name, string Content) add)
{
using var repo = new Repository(workdir);
var head = repo.Head.Tip;
var definition = TreeDefinition.From(head.Tree).Remove(remove);
var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(add.Content)));
definition.Add(add.Name, blob, Mode.NonExecutableFile);
var tree = repo.ObjectDatabase.CreateTree(definition);
var signature = MakeSignature();
var commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged fix", tree, [head], prettifyMessage: false);
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id);
}
private static bool IsCheckoutPending(string repoPath)
{
using var repo = new Repository(repoPath);
return repo.Config.Get<bool>(PendingKey)?.Value == true;
}
private static string RecoveryFilePath(string repoPath)
{
using var repo = new Repository(repoPath);
return Path.Combine(repo.Info.Path, "gclo-recovery.json");
}
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("inner content", File.ReadAllText(Path.Combine(target, "bad_dir", "bad_inner.txt")));
Assert.Equal("deep content", File.ReadAllText(Path.Combine(target, "bad_dir", "sub", "deep.txt")));
Assert.False(IsCheckoutPending(target));
}
[Fact]
public async Task ApplyRecovery_SkippedPaths_OmitFilesAndWholeDirectories()
{
string source = CreateForgedRepo(
_root,
("bad:one.txt", "unwanted"),
("good.txt", "kept"),
("junk/a.txt", "junk a"),
("junk/sub/b.txt", "junk b"),
("docs/keep.txt", "docs kept"));
string target = await CloneExpectingInvalidPathsAsync(source, "skip-apply");
// Skip the invalid file outright and a whole (valid) directory subtree.
PathRecovery recovery = Recovery(skips: ["bad:one.txt", "junk"]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.Equal("kept", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("docs kept", File.ReadAllText(Path.Combine(target, "docs", "keep.txt")));
Assert.False(Directory.Exists(Path.Combine(target, "junk")));
Assert.False(IsCheckoutPending(target));
}
[Fact]
public async Task ApplyRecovery_MappingStillInvalid_ThrowsListingEffectivePaths_AndWritesNothing()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "still-invalid");
PathRecovery recovery = Recovery(renames: [("bad:file.txt", "still:bad.txt")]);
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None));
// The exception lists the EFFECTIVE (post-mapping) path, not the original.
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "still:bad.txt", StringComparison.Ordinal));
// Validation runs before any write: even the valid file must not appear,
// the marker stays pending, and no recovery is persisted.
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
Assert.True(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
}
[Fact]
public async Task ApplyRecovery_TwoOriginalsMappedToOneDestination_Throws()
{
string source = CreateForgedRepo(_root, ("bad:a.txt", "content a"), ("bad:b.txt", "content b"));
string target = await CloneExpectingInvalidPathsAsync(source, "collision");
PathRecovery recovery = Recovery(renames: [("bad:a.txt", "merged.txt"), ("bad:b.txt", "merged.txt")]);
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "merged.txt", StringComparison.Ordinal));
Assert.False(File.Exists(Path.Combine(target, "merged.txt")));
}
[Fact]
public async Task ApplyRecovery_PersistsRecoveryJsonUnderDotGit_AndClearsMarker()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "persist");
PathRecovery recovery = Recovery(renames: [("bad:file.txt", "bad_file.txt")]);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
string json = File.ReadAllText(RecoveryFilePath(target));
Assert.Contains("bad:file.txt", json);
Assert.Contains("bad_file.txt", json);
}
[Fact]
public async Task ApplyRecovery_OnRecoveryManagedRepo_MergesIncomingWithStoredRecovery()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"));
string target = await CloneExpectingInvalidPathsAsync(source, "incremental");
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
// Upstream introduces a NEW invalid path the stored recovery does not cover;
// the pull fails typed, exactly like the original clone did.
AppendForgedCommit(source, ("worse:new.txt", "new content"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "worse:new.txt", StringComparison.Ordinal));
// Applying a recovery that covers ONLY the new path merges with the stored one:
// the old rename keeps working and the persisted json carries both mappings.
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("worse:new.txt", "worse_new.txt")]), CancellationToken.None);
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "worse_new.txt")));
string json = File.ReadAllText(RecoveryFilePath(target));
Assert.Contains("bad_file.txt", json);
Assert.Contains("worse_new.txt", json);
}
// ---------------------------------------------------------------- persistence hardening (#31)
[Fact]
public async Task FetchAndPull_UpstreamDeletedAFile_RemovesItFromTheMaterializedTree()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("gone.txt", "going"), ("keep.txt", "keep"));
string target = await CloneExpectingInvalidPathsAsync(source, "deletion-pass");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
Assert.True(File.Exists(Path.Combine(target, "gone.txt")));
ReplaceForgedEntry(source, remove: "gone.txt", add: ("new/nested.txt", "new"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
// A real checkout removes what left the tree; the materialization must too.
Assert.False(File.Exists(Path.Combine(target, "gone.txt")));
Assert.Equal("new", File.ReadAllText(Path.Combine(target, "new", "nested.txt")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
// And a directory emptied by a later deletion is pruned too.
ReplaceForgedEntry(source, remove: "new/nested.txt", add: ("other.txt", "o"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.False(Directory.Exists(Path.Combine(target, "new")));
}
[Fact]
public async Task FetchAndPull_RecoveryManagedRepoAlreadyAtTip_DoesNotRewriteTheTree()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("keep.txt", "keep"));
string target = await CloneExpectingInvalidPathsAsync(source, "no-rewrite");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
// A local edit is the witness: an unchanged tip must leave the tree alone.
File.WriteAllText(Path.Combine(target, "keep.txt"), "locally edited");
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("locally edited", File.ReadAllText(Path.Combine(target, "keep.txt")));
// A new upstream commit re-materializes through the mapping as before.
ReplaceForgedEntry(source, remove: "keep.txt", add: ("keep.txt", "upstream edit"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("upstream edit", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task CorruptRecoveryFile_FailsWithAnActionableMessage_NotARawJsonError()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"));
string target = await CloneExpectingInvalidPathsAsync(source, "corrupt-json");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
string file = RecoveryFilePath(target);
File.WriteAllText(file, "{ \"SegmentRenames\": { truncated");
InvalidOperationException pullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("corrupt", pullError.Message);
Assert.Contains(file, pullError.Message);
Assert.Contains("Delete it", pullError.Message);
InvalidOperationException applyError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None));
Assert.Contains("corrupt", applyError.Message);
// "null" is a corrupt document too.
File.WriteAllText(file, "null");
InvalidOperationException nullError = await Assert.ThrowsAsync<InvalidOperationException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains("empty", nullError.Message);
}
[Fact]
public async Task SaveRecovery_WritesAtomically_LeavingNoTempFile_AndRecordsManifestAndTip()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad"), ("dir/keep.txt", "k"));
string target = await CloneExpectingInvalidPathsAsync(source, "atomic");
await _client.ApplyRecoveryAsync(target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
string file = RecoveryFilePath(target);
Assert.False(File.Exists(file + ".tmp"));
string json = File.ReadAllText(file);
Assert.Contains("\"MaterializedPaths\"", json);
Assert.Contains("bad_file.txt", json);
Assert.Contains("dir/keep.txt", json);
Assert.Contains(HeadSha(target), json);
}
// ---------------------------------------------------------------- pull semantics
[Fact]
public async Task FetchAndPull_MarkerRepoWithoutRecovery_RethrowsTypedException()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "marker-pull");
// The repo must not silently report success with an empty working tree.
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:file.txt", StringComparison.Ordinal));
Assert.True(IsCheckoutPending(target));
}
[Fact]
public async Task FetchAndPull_MarkerRepoUpstreamFixedPaths_ChecksOutAndClearsMarker()
{
string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "fixed-upstream");
ReplaceForgedEntry(source, remove: "bad:file.txt", add: ("fixed.txt", "fixed content"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("fixed content", File.ReadAllText(Path.Combine(target, "fixed.txt")));
Assert.False(IsCheckoutPending(target));
Assert.Equal(HeadSha(source), HeadSha(target));
}
[Fact]
public async Task FetchAndPull_WithStoredRecovery_RematerializesNewCommitsThroughMapping()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"), ("good.txt", "g1"));
string target = await CloneExpectingInvalidPathsAsync(source, "recovery-pull");
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
// Upstream rewrites the still-invalid file; the pull must land the new
// content at the mapped name.
string newSha = AppendForgedCommit(source, ("bad:file.txt", "v2"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("v2", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("g1", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal(newSha, HeadSha(target));
}
[Fact]
public async Task FetchAndPull_WithStoredRecovery_NewInvalidPathNotCovered_Throws()
{
string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"));
string target = await CloneExpectingInvalidPathsAsync(source, "uncovered-pull");
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None);
AppendForgedCommit(source, ("worse:new.txt", "not covered"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "worse:new.txt", StringComparison.Ordinal));
// The previously materialized working tree is untouched.
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
}
[Fact]
public async Task CloneAndPull_ValidRepo_NeverWritesMarkerOrRecovery()
{
string source = CreateForgedRepo(_root, ("readme.txt", "hello"));
string target = NewPath("normal");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
Assert.Equal("hello", File.ReadAllText(Path.Combine(target, "readme.txt")));
}
// ---------------------------------------------------------------- engine payload
[Fact]
public async Task OrgSync_InvalidPathFailure_AttachesInvalidPathsToFailedReport()
{
var lister = new FakeRepositoryLister
{
Repositories = [new RepoDescriptor("broken", "https://example.test/acme/broken.git", "main", IsArchived: false)],
};
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(
[new InvalidPathInfo("bad:file.txt", "contains a character that is invalid on Windows", "bad_file.txt")])),
};
var progress = new RecordingProgress();
SyncSummary summary = await new OrgSyncEngine(lister, git)
.SyncAsync(new SyncRequest("acme", Token, NewPath("org-root")), progress);
Assert.Equal(1, summary.Failed);
RepoProgress? report = progress.LastFor("broken");
Assert.NotNull(report);
Assert.Equal(SyncStatus.Failed, report!.Status);
Assert.NotNull(report.InvalidPaths);
InvalidPathInfo info = Assert.Single(report.InvalidPaths!);
Assert.Equal("bad:file.txt", info.RepoPath);
Assert.Equal("bad_file.txt", info.SuggestedName);
}
[Fact]
public async Task OrgSync_OtherFailures_AttachNoInvalidPathsPayload()
{
var lister = new FakeRepositoryLister
{
Repositories = [new RepoDescriptor("flaky", "https://example.test/acme/flaky.git", "main", IsArchived: false)],
};
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidOperationException("boom")),
};
var progress = new RecordingProgress();
await new OrgSyncEngine(lister, git)
.SyncAsync(new SyncRequest("acme", Token, NewPath("org-root-2")), progress);
RepoProgress? report = progress.LastFor("flaky");
Assert.NotNull(report);
Assert.Equal(SyncStatus.Failed, report!.Status);
Assert.Null(report.InvalidPaths);
}
// ---------------------------------------------------------------- fixture helpers
private string NewPath(string name) => Path.Combine(_root, name);
/// <summary>Clones a repo known to hold invalid paths, asserting the typed failure; returns the kept target.</summary>
private async Task<string> CloneExpectingInvalidPathsAsync(string source, string name)
{
string target = NewPath(name);
await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
return target;
}
private static PathRecovery Recovery(
(string From, string To)[]? renames = null,
string[]? skips = null)
=> new(
(renames ?? []).ToDictionary(r => r.From, r => r.To, StringComparer.Ordinal),
new HashSet<string>(skips ?? [], StringComparer.Ordinal));
/// <summary>Adds a commit that removes one forged entry and adds another — "upstream fixed the path".</summary>
private static void ReplaceForgedEntry(string workdir, string remove, (string Name, string Content) add)
{
using var repo = new Repository(workdir);
Commit head = repo.Head.Tip;
TreeDefinition definition = TreeDefinition.From(head.Tree).Remove(remove);
Blob blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(add.Content)));
definition.Add(add.Name, blob, Mode.NonExecutableFile);
Tree tree = repo.ObjectDatabase.CreateTree(definition);
Signature signature = MakeSignature();
Commit commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged fix", tree, [head], prettifyMessage: false);
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id);
}
private static bool IsCheckoutPending(string repoPath)
{
using var repo = new Repository(repoPath);
return repo.Config.Get<bool>(PendingKey)?.Value == true;
}
private static string RecoveryFilePath(string repoPath)
{
using var repo = new Repository(repoPath);
return Path.Combine(repo.Info.Path, "gclo-recovery.json");
}
}
+155 -129
View File
@@ -1,7 +1,11 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="RepositoryPathResolver"/> is the trust boundary between caller-supplied
/// repository names and the file system: every name must be exactly one safe folder
@@ -9,156 +13,178 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class RepositoryPathResolverTests
{
private static readonly string Root = Path.Combine(Path.GetTempPath(), "gclo-tests", "resolver-root");
private static readonly string s_root = Path.Combine(Path.GetTempPath(), "gclo-tests", "resolver-root");
// ---------------------------------------------------------------- valid names
[Theory]
[InlineData("gclo")]
[InlineData("my-repo")]
[InlineData("my_repo")]
[InlineData("my.repo")]
[InlineData("Repo.Name-With_Everything.v2")]
[InlineData("123")]
[InlineData(".github")]
[InlineData("...dots")]
[InlineData("name with spaces")]
public void Resolve_ValidGitHubStyleName_ReturnsFolderDirectlyUnderRoot(string name)
{
string resolved = RepositoryPathResolver.Resolve(Root, name);
Assert.Equal(Path.Combine(Path.GetFullPath(Root), name), resolved);
Assert.Equal(Path.GetFullPath(Root), Path.GetDirectoryName(resolved));
Assert.True(Path.IsPathRooted(resolved));
}
// ---------------------------------------------------------------- valid names
[Fact]
public void Resolve_RootWithTrailingSeparator_ResolvesIdentically()
{
string withSeparator = Root + Path.DirectorySeparatorChar;
[Theory]
[InlineData("gclo")]
[InlineData("my-repo")]
[InlineData("my_repo")]
[InlineData("my.repo")]
[InlineData("Repo.Name-With_Everything.v2")]
[InlineData("123")]
[InlineData(".github")]
[InlineData("...dots")]
[InlineData("name with spaces")]
public void Resolve_ValidGitHubStyleName_ReturnsFolderDirectlyUnderRoot(string name)
{
string resolved = RepositoryPathResolver.Resolve(s_root, name);
Assert.Equal(
RepositoryPathResolver.Resolve(Root, "alpha"),
RepositoryPathResolver.Resolve(withSeparator, "alpha"));
}
Assert.Equal(Path.Combine(Path.GetFullPath(s_root), name), resolved);
Assert.Equal(Path.GetFullPath(s_root), Path.GetDirectoryName(resolved));
Assert.True(Path.IsPathRooted(resolved));
}
[Fact]
public void Resolve_RelativeRoot_ResolvesAgainstCurrentDirectory()
{
string resolved = RepositoryPathResolver.Resolve("relative-root", "alpha");
Assert.Equal(Path.Combine(Path.GetFullPath("relative-root"), "alpha"), resolved);
}
[Fact]
public void Resolve_RootContainingDotSegments_IsNormalizedBeforeContainmentCheck()
{
string messyRoot = Path.Combine(Root, "sub", "..");
[Fact]
public void Resolve_RootWithTrailingSeparator_ResolvesIdentically()
{
string withSeparator = s_root + Path.DirectorySeparatorChar;
string resolved = RepositoryPathResolver.Resolve(messyRoot, "alpha");
Assert.Equal(
RepositoryPathResolver.Resolve(s_root, "alpha"),
RepositoryPathResolver.Resolve(withSeparator, "alpha"));
}
Assert.Equal(Path.Combine(Path.GetFullPath(Root), "alpha"), resolved);
}
[Theory]
[InlineData("gclo")]
[InlineData("my-repo.v2")]
public void TryValidateName_ValidName_ReturnsTrueWithNoReason(string name)
{
Assert.True(RepositoryPathResolver.TryValidateName(name, out string? reason));
Assert.Null(reason);
}
// ---------------------------------------------------------------- rejected names
[Fact]
public void Resolve_RelativeRoot_ResolvesAgainstCurrentDirectory()
{
string resolved = RepositoryPathResolver.Resolve("relative-root", "alpha");
[Theory]
[InlineData(null, "empty")]
[InlineData("", "empty")]
[InlineData(" ", "empty")]
[InlineData(".", "current or parent")]
[InlineData("..", "current or parent")]
[InlineData("../escape", "separator")]
[InlineData("..\\escape", "separator")]
[InlineData("nested/child", "separator")]
[InlineData("nested\\child", "separator")]
[InlineData("trailing/", "separator")]
[InlineData("/etc", "separator")]
[InlineData("\\\\server\\share", "separator")]
[InlineData("C:\\Windows", "separator")]
[InlineData("C:", ":")]
[InlineData("C:repo", ":")]
[InlineData("name:stream", ":")]
[InlineData("tab\there", "not allowed")]
[InlineData("nul\0byte", "not allowed")]
public void TryValidateName_UnsafeName_ReturnsFalseWithReason(string? name, string expectedReasonFragment)
{
Assert.False(RepositoryPathResolver.TryValidateName(name, out string? reason));
Assert.NotNull(reason);
Assert.Contains(expectedReasonFragment, reason);
}
Assert.Equal(Path.Combine(Path.GetFullPath("relative-root"), "alpha"), resolved);
}
[Theory]
[InlineData("../escape")]
[InlineData("..\\escape")]
[InlineData("nested/child")]
[InlineData("C:\\Windows")]
[InlineData("/etc")]
[InlineData("\\\\server\\share")]
[InlineData("..")]
[InlineData("")]
public void Resolve_UnsafeName_ThrowsArgumentExceptionNamingTheRepo(string name)
{
var ex = Assert.Throws<ArgumentException>(() => RepositoryPathResolver.Resolve(Root, name));
Assert.Equal("repoName", ex.ParamName);
Assert.Contains($"'{name}'", ex.Message);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Resolve_BlankRoot_ThrowsArgumentException(string root)
{
Assert.ThrowsAny<ArgumentException>(() => RepositoryPathResolver.Resolve(root, "alpha"));
}
[Fact]
public void Resolve_RootContainingDotSegments_IsNormalizedBeforeContainmentCheck()
{
string messyRoot = Path.Combine(s_root, "sub", "..");
[Fact]
public void Resolve_NullRoot_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => RepositoryPathResolver.Resolve(null!, "alpha"));
}
string resolved = RepositoryPathResolver.Resolve(messyRoot, "alpha");
// ---------------------------------------------------------------- containment (the backstop)
Assert.Equal(Path.Combine(Path.GetFullPath(s_root), "alpha"), resolved);
}
[Fact]
public void Resolve_NameThatNormalizesOntoTheRootItself_IsRejected()
{
// "..." passes the textual checks (it is not "." or ".." and has no separator),
// but Windows path normalization trims trailing dots so it would resolve to the
// root itself. The containment check must catch it on Windows; elsewhere "..."
// is a legitimate folder name.
if (!OperatingSystem.IsWindows())
{
Assert.Equal(Path.Combine(Path.GetFullPath(Root), "..."), RepositoryPathResolver.Resolve(Root, "..."));
return;
}
var ex = Assert.Throws<ArgumentException>(() => RepositoryPathResolver.Resolve(Root, "..."));
Assert.Contains("not inside the target root", ex.Message);
}
[Theory]
[InlineData("gclo")]
[InlineData("my-repo.v2")]
public void TryValidateName_ValidName_ReturnsTrueWithNoReason(string name)
{
Assert.True(RepositoryPathResolver.TryValidateName(name, out string? reason));
Assert.Null(reason);
}
[Fact]
public void Resolve_IsCaseInsensitiveOnWindows_ForTheRootPrefix()
{
if (!OperatingSystem.IsWindows())
{
return;
}
// The root's own casing must not matter for containment on Windows.
string resolved = RepositoryPathResolver.Resolve(Root.ToUpperInvariant(), "alpha");
Assert.EndsWith(Path.DirectorySeparatorChar + "alpha", resolved);
}
// ---------------------------------------------------------------- rejected names
[Theory]
[InlineData(null, "empty")]
[InlineData("", "empty")]
[InlineData(" ", "empty")]
[InlineData(".", "current or parent")]
[InlineData("..", "current or parent")]
[InlineData("../escape", "separator")]
[InlineData("..\\escape", "separator")]
[InlineData("nested/child", "separator")]
[InlineData("nested\\child", "separator")]
[InlineData("trailing/", "separator")]
[InlineData("/etc", "separator")]
[InlineData("\\\\server\\share", "separator")]
[InlineData("C:\\Windows", "separator")]
[InlineData("C:", ":")]
[InlineData("C:repo", ":")]
[InlineData("name:stream", ":")]
[InlineData("tab\there", "not allowed")]
[InlineData("nul\0byte", "not allowed")]
public void TryValidateName_UnsafeName_ReturnsFalseWithReason(string? name, string expectedReasonFragment)
{
Assert.False(RepositoryPathResolver.TryValidateName(name, out string? reason));
Assert.NotNull(reason);
Assert.Contains(expectedReasonFragment, reason);
}
[Theory]
[InlineData("../escape")]
[InlineData("..\\escape")]
[InlineData("nested/child")]
[InlineData("C:\\Windows")]
[InlineData("/etc")]
[InlineData("\\\\server\\share")]
[InlineData("..")]
[InlineData("")]
public void Resolve_UnsafeName_ThrowsArgumentExceptionNamingTheRepo(string name)
{
ArgumentException ex = Assert.Throws<ArgumentException>(() => RepositoryPathResolver.Resolve(s_root, name));
Assert.Equal("repoName", ex.ParamName);
Assert.Contains($"'{name}'", ex.Message);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Resolve_BlankRoot_ThrowsArgumentException(string root)
{
Assert.ThrowsAny<ArgumentException>(() => RepositoryPathResolver.Resolve(root, "alpha"));
}
[Fact]
public void Resolve_NullRoot_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => RepositoryPathResolver.Resolve(null!, "alpha"));
}
// ---------------------------------------------------------------- containment (the backstop)
[Fact]
public void Resolve_NameThatNormalizesOntoTheRootItself_IsRejected()
{
// "..." passes the textual checks (it is not "." or ".." and has no separator),
// but Windows path normalization trims trailing dots so it would resolve to the
// root itself. The containment check must catch it on Windows; elsewhere "..."
// is a legitimate folder name.
if(!OperatingSystem.IsWindows())
{
Assert.Equal(Path.Combine(Path.GetFullPath(s_root), "..."), RepositoryPathResolver.Resolve(s_root, "..."));
return;
}
ArgumentException ex = Assert.Throws<ArgumentException>(() => RepositoryPathResolver.Resolve(s_root, "..."));
Assert.Contains("not inside the target root", ex.Message);
}
[Fact]
public void Resolve_IsCaseInsensitiveOnWindows_ForTheRootPrefix()
{
if(!OperatingSystem.IsWindows())
{
return;
}
// The root's own casing must not matter for containment on Windows.
string resolved = RepositoryPathResolver.Resolve(s_root.ToUpperInvariant(), "alpha");
Assert.EndsWith(Path.DirectorySeparatorChar + "alpha", resolved);
}
}
+323 -272
View File
@@ -1,8 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for <see cref="SyncAllCoordinator"/> over fake-backed, account-seeded
/// <see cref="WorkspaceViewModel"/>s: strict sequential order, load-before-sync,
@@ -10,342 +17,386 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class SyncAllCoordinatorTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly InMemoryVault _vault = new();
private readonly OrderedLister _lister = new();
private readonly FakeOrganizationLister _orgs = new();
private readonly List<WorkspaceViewModel> _workspaces = new();
private readonly SyncAllCoordinator _coordinator = new(new NullActivityLog());
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
public void Dispose()
{
foreach (WorkspaceViewModel workspace in _workspaces)
{
workspace.Dispose();
}
TryDeleteDirectory(_root);
}
private readonly InMemoryVault _vault = new();
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
private readonly OrderedLister _lister = new();
/// <summary>
/// One lister shared by every workspace, so the recorded organization order proves
/// globally which account loaded when. Behavior is scripted per organization.
/// </summary>
private sealed class OrderedLister : IRepositoryLister
{
private readonly List<string> _organizations = new();
private readonly object _gate = new();
private readonly FakeOrganizationLister _orgs = new();
/// <summary>Repositories (or exception) per organization. Default: one repo named '&lt;org&gt;-repo'.</summary>
public Func<string, IReadOnlyList<RepoDescriptor>> Handler { get; set; }
= org => new[] { Repo(org + "-repo") };
private readonly List<WorkspaceViewModel> _workspaces = new();
/// <summary>Organizations listed so far, in call order.</summary>
public IReadOnlyList<string> Organizations
{
get
private readonly SyncAllCoordinator _coordinator = new(new NullActivityLog());
public void Dispose()
{
foreach(WorkspaceViewModel workspace in _workspaces)
{
workspace.Dispose();
}
TryDeleteDirectory(_root);
}
/// <summary>Synchronous progress: handler runs inline on the reporting thread.</summary>
private sealed class SyncProgress(Action<RepoProgress> handler) : IProgress<RepoProgress>
{
public void Report(RepoProgress value) => handler(value);
}
/// <summary>
/// One lister shared by every workspace, so the recorded organization order proves
/// globally which account loaded when. Behavior is scripted per organization.
/// </summary>
private sealed class OrderedLister : IRepositoryLister
{
private readonly List<string> _organizations = new();
private readonly Lock _gate = new();
/// <summary>Repositories (or exception) per organization. Default: one repo named '&lt;org&gt;-repo'.</summary>
public Func<string, IReadOnlyList<RepoDescriptor>> Handler { get; set; }
= org => new[] { Repo(org + "-repo") };
/// <summary>Organizations listed so far, in call order.</summary>
public IReadOnlyList<string> Organizations
{
get
{
lock(_gate)
{
lock (_gate)
{
return _organizations.ToArray();
}
return _organizations.ToArray();
}
}
}
}
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
lock (_gate)
{
_organizations.Add(organization);
}
try
{
return Task.FromResult(Handler(organization));
}
catch (Exception ex)
{
return Task.FromException<IReadOnlyList<RepoDescriptor>>(ex);
}
}
}
/// <summary>
/// An account-backed workspace syncing organization 'org-&lt;name&gt;' into a folder
/// under the test root. The seeded vault token is deliberately shorter than a
/// plausible PAT so the constructor's debounced org lookup never fires.
/// </summary>
private WorkspaceViewModel CreateWorkspace(string name, FakeGitClient? git = null)
{
var account = new Account
{
Id = Guid.NewGuid(),
Name = name,
Organization = "org-" + name,
TargetRoot = Path.Combine(_root, name),
};
_vault.Store(account.Id, "tok-" + name);
var workspace = new WorkspaceViewModel(
_lister,
git ?? new FakeGitClient(),
_orgs,
handler => new SyncProgress(handler),
TimeSpan.FromMilliseconds(1),
new NullActivityLog(),
account,
_vault);
_workspaces.Add(workspace);
return workspace;
}
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
lock(_gate)
{
_organizations.Add(organization);
}
try
{
return Task.FromResult(Handler(organization));
}
catch(Exception ex)
{
return Task.FromException<IReadOnlyList<RepoDescriptor>>(ex);
}
}
}
// ---------------------------------------------------------------- ordering
[Fact]
public async Task RunAsync_RunsAccountsStrictlySequentially_InListOrder()
{
var gitAlpha = new FakeGitClient();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
gitAlpha.CloneHandler = (_, _, _, _, _) =>
{
entered.TrySetResult();
return gate.Task;
};
var alpha = CreateWorkspace("alpha", gitAlpha);
var beta = CreateWorkspace("beta");
Task<SyncAllResult> run = _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
/// <summary>
/// An account-backed workspace syncing organization 'org-&lt;name&gt;' into a folder
/// under the test root. The seeded vault token is deliberately shorter than a
/// plausible PAT so the constructor's debounced org lookup never fires.
/// </summary>
private WorkspaceViewModel CreateWorkspace(string name, FakeGitClient? git = null)
{
var account = new Account
{
Id = Guid.NewGuid(),
Name = name,
Organization = "org-" + name,
TargetRoot = Path.Combine(_root, name),
};
_vault.Store(account.Id, "tok-" + name);
await entered.Task; // alpha's sync is in flight...
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // ...and beta has not started
var workspace = new WorkspaceViewModel(
_lister,
git ?? new FakeGitClient(),
_orgs,
handler => new SyncProgress(handler),
TimeSpan.FromMilliseconds(1),
new NullActivityLog(),
account,
_vault);
_workspaces.Add(workspace);
return workspace;
}
gate.SetResult();
SyncAllResult result = await run;
Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 0, WasCanceled: false), result);
Assert.Equal(new[] { "org-alpha", "org-beta" }, _lister.Organizations);
}
// ---------------------------------------------------------------- load-before-sync
// ---------------------------------------------------------------- ordering
[Fact]
public async Task RunAsync_LoadsAnUnloadedWorkspace_ThenSyncsIt()
{
var git = new FakeGitClient();
var workspace = CreateWorkspace("alpha", git);
Assert.False(workspace.HasLoadedRepos);
[Fact]
public async Task RunAsync_RunsAccountsStrictlySequentially_InListOrder()
{
var gitAlpha = new FakeGitClient();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
gitAlpha.CloneHandler = (_, _, _, _, _) =>
{
entered.TrySetResult();
return gate.Task;
};
WorkspaceViewModel alpha = CreateWorkspace("alpha", gitAlpha);
WorkspaceViewModel beta = CreateWorkspace("beta");
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
Task<SyncAllResult> run = _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false), result);
Assert.True(workspace.HasLoadedRepos);
Assert.Equal(new[] { "org-alpha-repo" }, git.ClonedRepoNames);
}
await entered.Task; // alpha's sync is in flight...
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // ...and beta has not started
[Fact]
public async Task RunAsync_DoesNotReloadAnAlreadyLoadedWorkspace()
{
var git = new FakeGitClient();
var workspace = CreateWorkspace("alpha", git);
await workspace.LoadReposCommand.ExecuteAsync(null);
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations);
gate.SetResult();
SyncAllResult result = await run;
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 2, Skipped: 0, WasCanceled: false), result);
Assert.Equal(new[] { "org-alpha", "org-beta" }, _lister.Organizations);
}
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false), result);
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // still exactly one listing
Assert.Equal(new[] { "org-alpha-repo" }, git.ClonedRepoNames);
}
// ---------------------------------------------------------------- skipping
[Fact]
public async Task RunAsync_SkipsBusyWorkspaces_AndStillRunsTheRest()
{
var running = CreateWorkspace("alpha");
running.IsRunning = true;
var loading = CreateWorkspace("beta");
loading.IsLoadingRepos = true;
var resolving = CreateWorkspace("gamma");
resolving.IsResolvingPaths = true;
var git = new FakeGitClient();
var idle = CreateWorkspace("delta", git);
// ---------------------------------------------------------------- load-before-sync
SyncAllResult result = await _coordinator.RunAsync(
new[] { running, loading, resolving, idle }, CancellationToken.None);
[Fact]
public async Task RunAsync_LoadsAnUnloadedWorkspace_ThenSyncsIt()
{
var git = new FakeGitClient();
WorkspaceViewModel workspace = CreateWorkspace("alpha", git);
Assert.False(workspace.HasLoadedRepos);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 3, WasCanceled: false), result);
Assert.Equal(new[] { "org-delta" }, _lister.Organizations); // busy ones were never even loaded
Assert.Equal(new[] { "org-delta-repo" }, git.ClonedRepoNames);
}
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
[Fact]
public async Task RunAsync_SkipsAWorkspaceWhoseLoadFails_AndMovesOn()
{
_lister.Handler = org => org == "org-alpha"
? throw new InvalidOperationException("org not found")
: new[] { Repo(org + "-repo") };
var alpha = CreateWorkspace("alpha");
var git = new FakeGitClient();
var beta = CreateWorkspace("beta", git);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false), result);
Assert.True(workspace.HasLoadedRepos);
Assert.Equal(new[] { "org-alpha-repo" }, git.ClonedRepoNames);
}
SyncAllResult result = await _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 1, WasCanceled: false), result);
Assert.False(alpha.HasLoadedRepos);
Assert.Equal(new[] { "org-beta-repo" }, git.ClonedRepoNames);
}
[Fact]
public async Task RunAsync_SkipsALoadedWorkspaceWhoseSyncCannotRun()
{
_lister.Handler = _ => Array.Empty<RepoDescriptor>();
var workspace = CreateWorkspace("alpha");
[Fact]
public async Task RunAsync_DoesNotReloadAnAlreadyLoadedWorkspace()
{
var git = new FakeGitClient();
WorkspaceViewModel workspace = CreateWorkspace("alpha", git);
await workspace.LoadReposCommand.ExecuteAsync(null);
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations);
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 1, WasCanceled: false), result);
Assert.True(workspace.HasLoadedRepos); // it loaded fine; there was just nothing to sync
}
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: false), result);
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations); // still exactly one listing
Assert.Equal(new[] { "org-alpha-repo" }, git.ClonedRepoNames);
}
// ---------------------------------------------------------------- cancellation
[Fact]
public async Task RunAsync_CancellationStopsTheQueue_ButLetsTheInFlightSyncFinish()
{
var gitAlpha = new FakeGitClient();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
gitAlpha.CloneHandler = (_, _, _, _, _) =>
{
entered.TrySetResult();
return gate.Task;
};
var alpha = CreateWorkspace("alpha", gitAlpha);
var gitBeta = new FakeGitClient();
var beta = CreateWorkspace("beta", gitBeta);
using var cts = new CancellationTokenSource();
Task<SyncAllResult> run = _coordinator.RunAsync(new[] { alpha, beta }, cts.Token);
await entered.Task;
cts.Cancel(); // requested while alpha's sync is in flight...
gate.SetResult(); // ...which must still run to completion
SyncAllResult result = await run;
// ---------------------------------------------------------------- skipping
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: true), result);
Assert.Equal(SyncStatus.Done, alpha.Repos[0].Status); // finished, not canceled
Assert.False(alpha.IsRunning);
Assert.False(beta.HasLoadedRepos); // the second account never started
Assert.Empty(gitBeta.CloneCalls);
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations);
}
[Fact]
public async Task RunAsync_SkipsBusyWorkspaces_AndStillRunsTheRest()
{
WorkspaceViewModel running = CreateWorkspace("alpha");
running.IsRunning = true;
WorkspaceViewModel loading = CreateWorkspace("beta");
loading.IsLoadingRepos = true;
WorkspaceViewModel resolving = CreateWorkspace("gamma");
resolving.IsResolvingPaths = true;
var git = new FakeGitClient();
WorkspaceViewModel idle = CreateWorkspace("delta", git);
[Fact]
public async Task RunAsync_TokenAlreadyCanceled_StartsNothing()
{
var git = new FakeGitClient();
var workspace = CreateWorkspace("alpha", git);
using var cts = new CancellationTokenSource();
cts.Cancel();
SyncAllResult result = await _coordinator.RunAsync(
new[] { running, loading, resolving, idle }, CancellationToken.None);
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, cts.Token);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 3, WasCanceled: false), result);
Assert.Equal(new[] { "org-delta" }, _lister.Organizations); // busy ones were never even loaded
Assert.Equal(new[] { "org-delta-repo" }, git.ClonedRepoNames);
}
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: true), result);
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_SkipsAWorkspaceWhoseLoadFails_AndMovesOn()
{
_lister.Handler = org => string.Equals(org, "org-alpha"
, StringComparison.Ordinal) ? throw new InvalidOperationException("org not found")
: new[] { Repo(org + "-repo") };
WorkspaceViewModel alpha = CreateWorkspace("alpha");
var git = new FakeGitClient();
WorkspaceViewModel beta = CreateWorkspace("beta", git);
[Fact]
public async Task RunAsync_AnnouncesQueuedForAll_ThenRunningAndTerminalPerAccount()
{
var alpha = CreateWorkspace("alpha");
var beta = CreateWorkspace("beta");
var states = RecordStates();
SyncAllResult result = await _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
await _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 1, WasCanceled: false), result);
Assert.False(alpha.HasLoadedRepos);
Assert.Equal(new[] { "org-beta-repo" }, git.ClonedRepoNames);
}
Assert.Equal(
new[]
{
[Fact]
public async Task RunAsync_SkipsALoadedWorkspaceWhoseSyncCannotRun()
{
_lister.Handler = _ => Array.Empty<RepoDescriptor>();
WorkspaceViewModel workspace = CreateWorkspace("alpha");
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 1, WasCanceled: false), result);
Assert.True(workspace.HasLoadedRepos); // it loaded fine; there was just nothing to sync
}
// ---------------------------------------------------------------- cancellation
[Fact]
public async Task RunAsync_CancellationStopsTheQueue_ButLetsTheInFlightSyncFinish()
{
var gitAlpha = new FakeGitClient();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
gitAlpha.CloneHandler = (_, _, _, _, _) =>
{
entered.TrySetResult();
return gate.Task;
};
WorkspaceViewModel alpha = CreateWorkspace("alpha", gitAlpha);
var gitBeta = new FakeGitClient();
WorkspaceViewModel beta = CreateWorkspace("beta", gitBeta);
using var cts = new CancellationTokenSource();
Task<SyncAllResult> run = _coordinator.RunAsync(new[] { alpha, beta }, cts.Token);
await entered.Task;
cts.Cancel(); // requested while alpha's sync is in flight...
gate.SetResult(); // ...which must still run to completion
SyncAllResult result = await run;
Assert.Equal(new SyncAllResult(Ran: 1, Skipped: 0, WasCanceled: true), result);
Assert.Equal(SyncStatus.Done, alpha.Repos[0].Status); // finished, not canceled
Assert.False(alpha.IsRunning);
Assert.False(beta.HasLoadedRepos); // the second account never started
Assert.Empty(gitBeta.CloneCalls);
Assert.Equal(new[] { "org-alpha" }, _lister.Organizations);
}
[Fact]
public async Task RunAsync_TokenAlreadyCanceled_StartsNothing()
{
var git = new FakeGitClient();
WorkspaceViewModel workspace = CreateWorkspace("alpha", git);
using var cts = new CancellationTokenSource();
cts.Cancel();
SyncAllResult result = await _coordinator.RunAsync(new[] { workspace }, cts.Token);
Assert.Equal(new SyncAllResult(Ran: 0, Skipped: 0, WasCanceled: true), result);
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()
{
WorkspaceViewModel alpha = CreateWorkspace("alpha");
WorkspaceViewModel beta = CreateWorkspace("beta");
List<(Guid Id, SyncAllAccountState State)> 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);
}
},
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_AccountWithFailedRepos_AnnouncesFailed()
{
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) =>
Task.FromException(new InvalidOperationException("boom")),
};
WorkspaceViewModel workspace = CreateWorkspace("alpha", git);
List<(Guid Id, SyncAllAccountState State)> states = RecordStates();
[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);
await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
Assert.Equal(SyncAllAccountState.Failed, states[^1].State);
}
Assert.Equal(
new[]
{
[Fact]
public async Task RunAsync_BusyAccount_AnnouncesSkippedWithoutRunning()
{
WorkspaceViewModel workspace = CreateWorkspace("alpha");
workspace.IsResolvingPaths = true; // busy: must be skipped
List<(Guid Id, SyncAllAccountState State)> states = RecordStates();
await _coordinator.RunAsync(new[] { workspace }, CancellationToken.None);
Assert.Equal(
new[]
{
(workspace.AccountId!.Value, SyncAllAccountState.Queued),
(workspace.AccountId!.Value, SyncAllAccountState.Skipped),
},
states);
}
},
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[]
{
[Fact]
public async Task RunAsync_CanceledQueue_AnnouncesSkippedForUnstartedAccounts()
{
WorkspaceViewModel alpha = CreateWorkspace("alpha");
WorkspaceViewModel beta = CreateWorkspace("beta");
using var cts = new CancellationTokenSource();
cts.Cancel();
List<(Guid Id, SyncAllAccountState State)> 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);
}
},
states);
}
}
+122 -93
View File
@@ -1,8 +1,14 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for the <see cref="OrgSyncEngine.SyncAsync(SyncRequest, IReadOnlyList{RepoDescriptor},
/// IProgress{RepoProgress}?, CancellationToken)"/> overload that takes a caller-supplied
@@ -11,122 +17,145 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class SyncOverloadTests : IDisposable
{
private readonly string _targetRoot =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly string _targetRoot =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly FakeRepositoryLister _lister = new();
private readonly FakeGitClient _git = new();
private readonly RecordingProgress _progress = new();
private readonly FakeRepositoryLister _lister = new();
public void Dispose() => TryDeleteDirectory(_targetRoot);
private readonly FakeGitClient _git = new();
private OrgSyncEngine CreateEngine() => new(_lister, _git);
private readonly RecordingProgress _progress = new();
private SyncRequest CreateRequest(int maxConcurrency = 8)
=> new("acme", "test-token", _targetRoot, maxConcurrency);
[Fact]
public async Task SyncWithList_NeverCallsTheLister()
{
// Give the lister a different answer so any accidental call is caught.
_lister.Repositories = Repos("from-lister");
var summary = await CreateEngine().SyncAsync(CreateRequest(), Repos("alpha", "bravo"), _progress);
public void Dispose() => TryDeleteDirectory(_targetRoot);
Assert.Empty(_lister.Calls);
Assert.Equal(new[] { "alpha", "bravo" }, _git.ClonedRepoNames.Order().ToArray());
Assert.Equal(new SyncSummary(Total: 2, Cloned: 2, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
[Fact]
public async Task SyncWithList_SyncsExactlyTheGivenSelection()
{
// A caller that filtered a larger set (deselected repos) passes only the
// selection; nothing outside it may be touched or reported.
_lister.Repositories = Repos("alpha", "bravo", "charlie", "delta");
var summary = await CreateEngine().SyncAsync(CreateRequest(), Repos("bravo", "delta"), _progress);
private OrgSyncEngine CreateEngine() => new(_lister, _git);
Assert.Equal(new[] { "bravo", "delta" }, _git.ClonedRepoNames.Order().ToArray());
Assert.All(_progress.Reports, r => Assert.Contains(r.RepoName, new[] { "bravo", "delta" }));
Assert.Equal(2, summary.Total);
Assert.Empty(_lister.Calls);
}
[Fact]
public async Task SyncWithList_ReportsQueuedForEveryRepo_BeforeAnyGitWork()
{
var names = new[] { "alpha", "bravo", "charlie", "delta", "echo" };
await CreateEngine().SyncAsync(CreateRequest(), Repos(names), _progress);
private SyncRequest CreateRequest(int maxConcurrency = 8)
=> new("acme", "test-token", _targetRoot, maxConcurrency);
// The engine queues the full list synchronously before the parallel phase,
// so the first N reports must be Queued and cover every repo exactly once.
var first = _progress.Reports.Take(names.Length).ToArray();
Assert.All(first, r => Assert.Equal(SyncStatus.Queued, r.Status));
Assert.Equal(names.Order().ToArray(), first.Select(r => r.RepoName).Order().ToArray());
}
[Fact]
public async Task SyncWithList_DuplicateRepoNames_AreProcessedOnce()
{
RepoDescriptor[] repositories =
[
Repo("alpha"),
[Fact]
public async Task SyncWithList_NeverCallsTheLister()
{
// Give the lister a different answer so any accidental call is caught.
_lister.Repositories = Repos("from-lister");
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), Repos("alpha", "bravo"), _progress);
Assert.Empty(_lister.Calls);
Assert.Equal(new[] { "alpha", "bravo" }, _git.ClonedRepoNames.Order(StringComparer.Ordinal).ToArray());
Assert.Equal(new SyncSummary(Total: 2, Cloned: 2, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
}
[Fact]
public async Task SyncWithList_SyncsExactlyTheGivenSelection()
{
// A caller that filtered a larger set (deselected repos) passes only the
// selection; nothing outside it may be touched or reported.
_lister.Repositories = Repos("alpha", "bravo", "charlie", "delta");
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), Repos("bravo", "delta"), _progress);
Assert.Equal(new[] { "bravo", "delta" }, _git.ClonedRepoNames.Order(StringComparer.Ordinal).ToArray());
Assert.All(_progress.Reports, r => Assert.Contains(r.RepoName, new[] { "bravo", "delta" }, StringComparer.Ordinal));
Assert.Equal(2, summary.Total);
Assert.Empty(_lister.Calls);
}
[Fact]
public async Task SyncWithList_ReportsQueuedForEveryRepo_BeforeAnyGitWork()
{
string[] names = new[] { "alpha", "bravo", "charlie", "delta", "echo" };
await CreateEngine().SyncAsync(CreateRequest(), Repos(names), _progress);
// The engine queues the full list synchronously before the parallel phase,
// so the first N reports must be Queued and cover every repo exactly once.
RepoProgress[] first = _progress.Reports.Take(names.Length).ToArray();
Assert.All(first, r => Assert.Equal(SyncStatus.Queued, r.Status));
Assert.Equal(names.Order(StringComparer.Ordinal).ToArray(), first.Select(r => r.RepoName).Order(StringComparer.Ordinal).ToArray());
}
[Fact]
public async Task SyncWithList_DuplicateRepoNames_AreProcessedOnce()
{
RepoDescriptor[] repositories =
[
Repo("alpha"),
new RepoDescriptor("Alpha", "https://example.test/acme/Alpha.git", "main", IsArchived: false),
];
var summary = await CreateEngine().SyncAsync(CreateRequest(), repositories, _progress);
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), repositories, _progress);
Assert.Equal(1, summary.Total);
Assert.Single(_git.CloneCalls);
}
Assert.Equal(1, summary.Total);
Assert.Single(_git.CloneCalls);
}
[Fact]
public async Task SyncWithList_CountsClonesUpdatesAndFailures()
{
var locallyValid = new HashSet<string> { "alpha", "charlie" };
_git.IsValidRepositoryHandler = path => locallyValid.Contains(Path.GetFileName(path)!);
_git.CloneHandler = (_, path, _, _, _) =>
Path.GetFileName(path) == "echo"
? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
var summary = await CreateEngine().SyncAsync(
CreateRequest(), Repos("alpha", "bravo", "charlie", "delta", "echo"), _progress);
Assert.Equal(new SyncSummary(Total: 5, Cloned: 2, Updated: 2, Failed: 1, Canceled: 0, WasCanceled: false), summary);
Assert.Equal(new[] { "alpha", "charlie" }, _git.PulledRepoNames.Order().ToArray());
Assert.Equal(SyncStatus.Failed, _progress.LastFor("echo")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("bravo")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("delta")!.Status);
}
[Fact]
public async Task SyncWithList_CountsClonesUpdatesAndFailures()
{
var locallyValid = new HashSet<string>(StringComparer.Ordinal) { "alpha", "charlie" };
_git.IsValidRepositoryHandler = path => locallyValid.Contains(Path.GetFileName(path)!);
_git.CloneHandler = (_, path, _, _, _) => string.Equals(Path.GetFileName(path), "echo"
, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom"))
: Task.CompletedTask;
[Fact]
public async Task SyncWithList_CreatesTargetRoot()
{
await CreateEngine().SyncAsync(CreateRequest(), Repos("alpha"), _progress);
SyncSummary summary = await CreateEngine().SyncAsync(
CreateRequest(), Repos("alpha", "bravo", "charlie", "delta", "echo"), _progress);
Assert.True(Directory.Exists(_targetRoot), "engine should create TargetRoot");
}
Assert.Equal(new SyncSummary(Total: 5, Cloned: 2, Updated: 2, Failed: 1, Canceled: 0, WasCanceled: false), summary);
Assert.Equal(new[] { "alpha", "charlie" }, _git.PulledRepoNames.Order(StringComparer.Ordinal).ToArray());
Assert.Equal(SyncStatus.Failed, _progress.LastFor("echo")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("bravo")!.Status);
Assert.Equal(SyncStatus.Done, _progress.LastFor("delta")!.Status);
}
[Fact]
public async Task SyncWithList_EmptyList_ReturnsZeroTotalsAndMakesNoGitCalls()
{
var summary = await CreateEngine().SyncAsync(CreateRequest(), Array.Empty<RepoDescriptor>(), _progress);
Assert.Equal(new SyncSummary(Total: 0, Cloned: 0, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
Assert.Empty(_git.ValidityChecks);
Assert.Empty(_progress.Reports);
Assert.Empty(_lister.Calls);
}
[Fact]
public async Task SyncWithList_NullRepositories_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => CreateEngine().SyncAsync(CreateRequest(), (IReadOnlyList<RepoDescriptor>)null!));
}
[Fact]
public async Task SyncWithList_CreatesTargetRoot()
{
await CreateEngine().SyncAsync(CreateRequest(), Repos("alpha"), _progress);
Assert.True(Directory.Exists(_targetRoot), "engine should create TargetRoot");
}
[Fact]
public async Task SyncWithList_EmptyList_ReturnsZeroTotalsAndMakesNoGitCalls()
{
SyncSummary summary = await CreateEngine().SyncAsync(CreateRequest(), Array.Empty<RepoDescriptor>(), _progress);
Assert.Equal(new SyncSummary(Total: 0, Cloned: 0, Updated: 0, Failed: 0, Canceled: 0, WasCanceled: false), summary);
Assert.Empty(_git.CloneCalls);
Assert.Empty(_git.PullCalls);
Assert.Empty(_git.ValidityChecks);
Assert.Empty(_progress.Reports);
Assert.Empty(_lister.Calls);
}
[Fact]
public async Task SyncWithList_NullRepositories_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => CreateEngine().SyncAsync(CreateRequest(), (IReadOnlyList<RepoDescriptor>)null!));
}
}
+15 -9
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// Pins the security contract of <see cref="SyncRequest"/>: the record ships on
/// NuGet, and its ToString must never print the PAT (the generated record
@@ -7,15 +13,15 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class SyncRequestTests
{
[Fact]
public void ToString_NeverContainsTheToken()
{
var request = new SyncRequest("acme", "ghp_super_secret_token", @"C:\src", 8);
[Fact]
public void ToString_NeverContainsTheToken()
{
var request = new SyncRequest("acme", "ghp_super_secret_token", @"C:\src", 8);
string text = request.ToString();
string text = request.ToString();
Assert.DoesNotContain("ghp_super_secret_token", text);
Assert.Contains("[redacted]", text);
Assert.Contains("acme", text); // everything else stays diagnosable
}
Assert.DoesNotContain("ghp_super_secret_token", text);
Assert.Contains("[redacted]", text);
Assert.Contains("acme", text); // everything else stays diagnosable
}
}
+110 -91
View File
@@ -1,9 +1,16 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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
@@ -18,117 +25,129 @@ namespace gclo.Engine.Tests;
+ "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 Constructor_NotWindows_ThrowsPlatformNotSupported()
=> Assert.Throws<PlatformNotSupportedException>(() => new CredentialManagerVault(isWindows: false));
// ---------------------------------------------------------------- data-root scoping (#60)
[Fact]
public void ScopeFor_DefaultDataRoot_IsNull_SoExistingEntriesKeepTheirNames()
{
string root = Path.Combine(Path.GetTempPath(), "gclo-default");
Assert.Null(CredentialManagerVault.ScopeFor(root, root));
Assert.Null(CredentialManagerVault.ScopeFor(root + Path.DirectorySeparatorChar, root));
Assert.Null(CredentialManagerVault.ScopeFor(root.ToUpperInvariant(), root.ToLowerInvariant()));
Assert.Equal($"gclo:account:{Guid.Empty:N}", CredentialManagerVault.TargetName(Guid.Empty, null));
}
// ---------------------------------------------------------------- data-root scoping (#60)
[Fact]
public void ScopeFor_OtherDataRoot_IsAStableHash_ThatIgnoresCaseAndTrailingSeparators()
{
string def = Path.Combine(Path.GetTempPath(), "gclo-default");
string other = Path.Combine(Path.GetTempPath(), "gclo-uitests", "abc");
[Fact]
public void ScopeFor_DefaultDataRoot_IsNull_SoExistingEntriesKeepTheirNames()
{
string root = Path.Combine(Path.GetTempPath(), "gclo-default");
string? scope = CredentialManagerVault.ScopeFor(other, def);
Assert.Null(CredentialManagerVault.ScopeFor(root, root));
Assert.Null(CredentialManagerVault.ScopeFor(root + Path.DirectorySeparatorChar, root));
Assert.Null(CredentialManagerVault.ScopeFor(root.ToUpperInvariant(), root.ToLowerInvariant()));
Assert.Equal($"gclo:account:{Guid.Empty:N}", CredentialManagerVault.TargetName(Guid.Empty, null));
}
Assert.NotNull(scope);
Assert.Matches("^[0-9a-f]{16}$", scope);
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other + "\\", def));
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other.ToUpperInvariant(), def));
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other.Replace('\\', '/'), def));
Assert.NotEqual(scope, CredentialManagerVault.ScopeFor(other + "2", def));
var id = Guid.NewGuid();
Assert.Equal($"gclo:{scope}:account:{id:N}", CredentialManagerVault.TargetName(id, scope));
}
[Fact]
public void ScopedVault_DoesNotSeeTheUnscopedEntry_AndCleansUpItsOwn()
{
if (!OperatingSystem.IsWindows())
{
return;
}
[Fact]
public void ScopeFor_OtherDataRoot_IsAStableHash_ThatIgnoresCaseAndTrailingSeparators()
{
string def = Path.Combine(Path.GetTempPath(), "gclo-default");
string other = Path.Combine(Path.GetTempPath(), "gclo-uitests", "abc");
var id = Guid.NewGuid();
var unscoped = new CredentialManagerVault(isWindows: true, scope: null);
var scoped = new CredentialManagerVault(isWindows: true, scope: "0123456789abcdef");
try
{
unscoped.Store(id, "ghp_default_profile");
Assert.Null(scoped.TryRetrieve(id)); // another data root never sees the default profile's token
string? scope = CredentialManagerVault.ScopeFor(other, def);
scoped.Store(id, "ghp_isolated");
Assert.Equal("ghp_isolated", scoped.TryRetrieve(id));
Assert.Equal("ghp_default_profile", unscoped.TryRetrieve(id)); // and vice versa
}
finally
{
unscoped.Delete(id);
scoped.Delete(id);
}
Assert.Null(unscoped.TryRetrieve(id));
Assert.Null(scoped.TryRetrieve(id));
}
Assert.NotNull(scope);
Assert.Matches("^[0-9a-f]{16}$", scope);
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other + "\\", def));
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other.ToUpperInvariant(), def));
Assert.Equal(scope, CredentialManagerVault.ScopeFor(other.Replace('\\', '/'), def));
Assert.NotEqual(scope, CredentialManagerVault.ScopeFor(other + "2", def), StringComparer.Ordinal);
[Fact]
public void Store_TokenLargerThanCredManBlobLimit_SurfacesWin32Error()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var id = Guid.NewGuid();
Assert.Equal($"gclo:{scope}:account:{id:N}", CredentialManagerVault.TargetName(id, scope));
}
// 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 ScopedVault_DoesNotSeeTheUnscopedEntry_AndCleansUpItsOwn()
{
if(!OperatingSystem.IsWindows())
{
return;
}
[Fact]
public void TryRetrieve_InvalidTargetName_SurfacesWin32Error()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var id = Guid.NewGuid();
var unscoped = new CredentialManagerVault(isWindows: true, scope: null);
var scoped = new CredentialManagerVault(isWindows: true, scope: "0123456789abcdef");
try
{
unscoped.Store(id, "ghp_default_profile");
Assert.Null(scoped.TryRetrieve(id)); // another data root never sees the default profile's token
var vault = new CredentialManagerVault();
string invalidTarget = new('x', 40_000); // far past CRED_MAX_GENERIC_TARGET_NAME_LENGTH
scoped.Store(id, "ghp_isolated");
Assert.Equal("ghp_isolated", scoped.TryRetrieve(id));
Assert.Equal("ghp_default_profile", unscoped.TryRetrieve(id)); // and vice versa
}
finally
{
unscoped.Delete(id);
scoped.Delete(id);
}
Assert.Null(unscoped.TryRetrieve(id));
Assert.Null(scoped.TryRetrieve(id));
}
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;
}
[Fact]
public void Store_TokenLargerThanCredManBlobLimit_SurfacesWin32Error()
{
if(!OperatingSystem.IsWindows())
{
return;
}
var vault = new CredentialManagerVault();
string invalidTarget = new('x', 40_000);
// 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.Delete(invalidTarget, Guid.NewGuid()));
Win32Exception ex = Assert.Throws<Win32Exception>(() => vault.Store(Guid.NewGuid(), oversized));
Assert.Contains("CredDeleteW failed", ex.Message);
}
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
Win32Exception 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);
Win32Exception ex = Assert.Throws<Win32Exception>(() => vault.Delete(invalidTarget, Guid.NewGuid()));
Assert.Contains("CredDeleteW failed", ex.Message);
}
}
+75 -60
View File
@@ -1,87 +1,102 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
namespace gclo.Engine.Tests;
/// <summary>
/// Contract tests for <see cref="InMemoryVault"/> plus one real round-trip through
/// <see cref="CredentialManagerVault"/> against the Windows Credential Manager.
/// </summary>
public sealed class VaultTests
{
// ---------------------------------------------------------------- InMemoryVault
// ---------------------------------------------------------------- InMemoryVault
[Fact]
public void InMemory_StoreThenRetrieve_ReturnsTheToken()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
[Fact]
public void InMemory_StoreThenRetrieve_ReturnsTheToken()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_secret");
vault.Store(id, "ghp_secret");
Assert.Equal("ghp_secret", vault.TryRetrieve(id));
}
Assert.Equal("ghp_secret", vault.TryRetrieve(id));
}
[Fact]
public void InMemory_Store_OverwritesTheExistingToken()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_old");
vault.Store(id, "ghp_new");
Assert.Equal("ghp_new", vault.TryRetrieve(id));
}
[Fact]
public void InMemory_Store_OverwritesTheExistingToken()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_old");
[Fact]
public void InMemory_TryRetrieve_AbsentEntry_ReturnsNull()
{
Assert.Null(new InMemoryVault().TryRetrieve(Guid.NewGuid()));
}
vault.Store(id, "ghp_new");
[Fact]
public void InMemory_Delete_RemovesTheToken_AndIsIdempotent()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_secret");
Assert.Equal("ghp_new", vault.TryRetrieve(id));
}
vault.Delete(id);
Assert.Null(vault.TryRetrieve(id));
vault.Delete(id); // deleting an absent entry must not throw
}
// ---------------------------------------------------------------- CredentialManagerVault
[Fact]
public void InMemory_TryRetrieve_AbsentEntry_ReturnsNull()
{
Assert.Null(new InMemoryVault().TryRetrieve(Guid.NewGuid()));
}
[Fact]
public void CredentialManager_RoundTrips_AgainstTheRealStore()
{
// xunit 2.9 has no runtime skip, so on non-Windows this silently passes;
// CI runs windows-latest, where the body always executes.
if (!OperatingSystem.IsWindows())
{
return;
}
var vault = new CredentialManagerVault();
var id = Guid.NewGuid();
try
{
vault.Store(id, "ghp_round-trip-secret");
Assert.Equal("ghp_round-trip-secret", vault.TryRetrieve(id));
vault.Store(id, "ghp_rotated");
Assert.Equal("ghp_rotated", vault.TryRetrieve(id));
[Fact]
public void InMemory_Delete_RemovesTheToken_AndIsIdempotent()
{
var vault = new InMemoryVault();
var id = Guid.NewGuid();
vault.Store(id, "ghp_secret");
vault.Delete(id);
Assert.Null(vault.TryRetrieve(id));
vault.Delete(id);
Assert.Null(vault.TryRetrieve(id));
vault.Delete(id); // deleting an absent entry must not throw
}
finally
{
vault.Delete(id);
}
}
vault.Delete(id); // deleting an absent entry must not throw
}
// ---------------------------------------------------------------- CredentialManagerVault
[Fact]
public void CredentialManager_RoundTrips_AgainstTheRealStore()
{
// xunit 2.9 has no runtime skip, so on non-Windows this silently passes;
// CI runs windows-latest, where the body always executes.
if(!OperatingSystem.IsWindows())
{
return;
}
var vault = new CredentialManagerVault();
var id = Guid.NewGuid();
try
{
vault.Store(id, "ghp_round-trip-secret");
Assert.Equal("ghp_round-trip-secret", vault.TryRetrieve(id));
vault.Store(id, "ghp_rotated");
Assert.Equal("ghp_rotated", vault.TryRetrieve(id));
vault.Delete(id);
Assert.Null(vault.TryRetrieve(id));
vault.Delete(id); // deleting an absent entry must not throw
}
finally
{
vault.Delete(id);
}
}
}
+305 -257
View File
@@ -1,9 +1,15 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using LibGit2Sharp;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for Windows path validation on clone/pull: paths that are legal in git but
/// invalid on Windows must surface as a structured <see cref="InvalidRepositoryPathsException"/>
@@ -13,317 +19,359 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class WindowsPathValidationTests : IDisposable
{
private const string Token = "test-token";
private const string Token = "test-token";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly LibGit2GitClient _client = new();
private readonly LibGit2GitClient _client = new();
public WindowsPathValidationTests()
{
Directory.CreateDirectory(_root);
}
public void Dispose() => TryDeleteDirectory(_root);
// ---------------------------------------------------------------- validator
public WindowsPathValidationTests()
{
Directory.CreateDirectory(_root);
}
[Theory]
[InlineData("trailing-space ", "trailing space or dot", "trailing-space")]
[InlineData("trailing-dot.", "trailing space or dot", "trailing-dot")]
[InlineData("bad:colon.txt", "invalid on Windows", "bad_colon.txt")]
[InlineData("CON", "reserved Windows device name", "CON_")]
[InlineData("COM3.txt", "reserved Windows device name", "COM3.txt_")]
public void Validate_FlagsInvalidSegment_WithReasonAndSuggestion(
string entryName, string expectedReasonFragment, string expectedSuggestion)
{
var invalid = ValidateNames(entryName);
var info = Assert.Single(invalid);
Assert.Equal(entryName, info.RepoPath);
Assert.Contains(expectedReasonFragment, info.Reason);
Assert.Equal(expectedSuggestion, info.SuggestedName);
}
[Fact]
public void Validate_FlagsCaseOnlyCollision()
{
var invalid = ValidateNames("README.md", "readme.md");
public void Dispose() => TryDeleteDirectory(_root);
var info = Assert.Single(invalid);
Assert.Contains("differs only by case", info.Reason);
}
[Fact]
public void Validate_FlagsOverlongSegment()
{
var invalid = ValidateNames(new string('x', 256));
var info = Assert.Single(invalid);
Assert.Contains("longer than 255", info.Reason);
}
// ---------------------------------------------------------------- validator
[Fact]
public void Validate_FlagsInvalidDirectorySegment_InNestedPath()
{
var invalid = ValidateNames("bad-dir /nested.txt");
[Theory]
[InlineData("trailing-space ", "trailing space or dot", "trailing-space")]
[InlineData("trailing-dot.", "trailing space or dot", "trailing-dot")]
[InlineData("bad:colon.txt", "invalid on Windows", "bad_colon.txt")]
[InlineData("CON", "reserved Windows device name", "CON_")]
[InlineData("COM3.txt", "reserved Windows device name", "COM3.txt_")]
public void Validate_FlagsInvalidSegment_WithReasonAndSuggestion(
string entryName, string expectedReasonFragment, string expectedSuggestion)
{
IReadOnlyList<InvalidPathInfo> invalid = ValidateNames(entryName);
var info = Assert.Single(invalid);
Assert.Equal("bad-dir ", info.RepoPath);
Assert.Contains("trailing space or dot", info.Reason);
}
InvalidPathInfo info = Assert.Single(invalid);
Assert.Equal(entryName, info.RepoPath);
Assert.Contains(expectedReasonFragment, info.Reason);
Assert.Equal(expectedSuggestion, info.SuggestedName);
}
[Fact]
public void Validate_ValidTree_ReturnsEmpty()
{
var invalid = ValidateNames("readme.txt", "src/main.cs", ".github/workflows/ci.yml", "a.b.c");
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);
[Fact]
public void Validate_FlagsCaseOnlyCollision()
{
IReadOnlyList<InvalidPathInfo> invalid = ValidateNames("README.md", "readme.md");
var emptyTree = repo.ObjectDatabase.CreateTree(new TreeDefinition());
var definition = new TreeDefinition();
definition.Add("emptydir", emptyTree);
InvalidPathInfo info = Assert.Single(invalid);
Assert.Contains("differs only by case", info.Reason);
}
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);
[Fact]
public void Validate_FlagsOverlongSegment()
{
IReadOnlyList<InvalidPathInfo> invalid = ValidateNames(new string('x', 256));
var emptyTree = repo.ObjectDatabase.CreateTree(new TreeDefinition());
var definition = new TreeDefinition();
definition.Add("bad ", emptyTree); // trailing space on the empty directory
InvalidPathInfo info = Assert.Single(invalid);
Assert.Contains("longer than 255", info.Reason);
}
var invalid = WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
Assert.Contains(invalid, i => i.RepoPath == "bad " && i.Reason.Contains("trailing space"));
}
// ---------------------------------------------------------------- clone behavior
[Fact]
public void Validate_FlagsInvalidDirectorySegment_InNestedPath()
{
IReadOnlyList<InvalidPathInfo> invalid = ValidateNames("bad-dir /nested.txt");
[Fact]
public async Task Clone_MixedValidAndInvalidPaths_ReportsOnlyInvalidOnes_AndKeepsFetchedRepo()
{
string source = CreateForgedRepo(_root, "good.txt", "bad:name.txt", "NUL.log");
string target = NewPath("mixed-clone");
InvalidPathInfo info = Assert.Single(invalid);
Assert.Equal("bad-dir ", info.RepoPath);
Assert.Contains("trailing space or dot", info.Reason);
}
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
Assert.Equal(2, ex.Paths.Count);
Assert.DoesNotContain(ex.Paths, p => p.RepoPath == "good.txt");
// The fetched repo is kept for path recovery (marked checkout-pending),
// but nothing may have been checked out.
Assert.True(_client.IsValidRepository(target));
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
using var repo = new Repository(target);
Assert.True(repo.Config.Get<bool>("gclo.checkoutpending")?.Value);
}
[Fact]
public async Task Clone_PathLongerThanLegacyMaxPath_Succeeds()
{
// Regression for owner-reported "filename or folder too long" failures:
// core.longpaths is set before checkout, so a deep-but-valid tree clones fine.
string segment = new string('d', 60);
string repoPath = string.Join('/', Enumerable.Repeat(segment, 4)) + "/deep-file.txt";
string source = CreateSourceRepoWithLongPath(repoPath);
string target = NewPath("long-clone");
Assert.True((target + "/" + repoPath).Length > 260); // the scenario is real
[Fact]
public void Validate_ValidTree_ReturnsEmpty()
{
IReadOnlyList<InvalidPathInfo> invalid = ValidateNames("readme.txt", "src/main.cs", ".github/workflows/ci.yml", "a.b.c");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
Assert.Empty(invalid);
}
string fullPath = Path.Combine(target, repoPath.Replace('/', Path.DirectorySeparatorChar));
Assert.Equal("deep content", File.ReadAllText(fullPath));
}
// ---------------------------------------------------------------- ValidateIncoming (#30)
[Fact]
public async Task ValidateIncoming_ReportsOnlyWhatThePullIntroduces()
{
// The current tree is already checked out (hence valid); a pull that adds one
// bad path must report exactly that path, and must not re-examine the rest.
string source = CreateForgedRepo(_root, ("ok.txt", "fine"), ("dir/also-ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("dir/bad:name.txt", "x"), ("ok.txt", "modified"));
[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);
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
Tree emptyTree = repo.ObjectDatabase.CreateTree(new TreeDefinition());
var definition = new TreeDefinition();
definition.Add("emptydir", emptyTree);
var info = Assert.Single(invalid);
Assert.Equal("dir/bad:name.txt", info.RepoPath);
Assert.Equal("bad_name.txt", info.SuggestedName);
}
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
[Fact]
public async Task ValidateIncoming_ModifiedFilesOnly_ReturnsEmpty()
{
string source = CreateForgedRepo(_root, ("ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("ok.txt", "modified"));
Assert.Empty(invalid); // "emptydir" is a valid name; the branch still ran
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_NewPathDifferingOnlyByCaseFromAnExistingOne_IsFlagged()
{
string source = CreateForgedRepo(_root, ("docs/readme.md", "a"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
// Git is happy to hold both; NTFS is not.
AppendForgedCommit(source, ("Docs/README.md", "b"));
[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);
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
Tree emptyTree = repo.ObjectDatabase.CreateTree(new TreeDefinition());
var definition = new TreeDefinition();
definition.Add("bad ", emptyTree); // trailing space on the empty directory
// The directory collides first; the file under it is reported once each.
Assert.Contains(invalid, i => i.RepoPath == "Docs" && i.Reason.Contains("'docs'"));
Assert.Contains(invalid, i => i.RepoPath == "Docs/README.md" && i.Reason.Contains("'docs/readme.md'"));
}
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
[Fact]
public async Task ValidateIncoming_CaseChangeViaDeleteAndAdd_IsAllowed()
{
// Upstream renamed 'readme.md' to 'README.md' (delete + add in one commit): the
// old spelling leaves the disk with the pull, so the new one does not collide.
string source = CreateForgedRepo(_root, ("readme.md", "a"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using (var src = new Repository(source))
{
var head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
definition.Remove("readme.md");
definition.Add("README.md", src.ObjectDatabase.CreateBlob(new MemoryStream("different"u8.ToArray())), Mode.NonExecutableFile);
var sig = MakeSignature();
var commit = src.ObjectDatabase.CreateCommit(sig, sig, "recase", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
Assert.Contains(invalid, i => string.Equals(i.RepoPath, "bad ", StringComparison.Ordinal) && i.Reason.Contains("trailing space"));
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_DetectedRename_ChecksTheNewPathAndReleasesTheOld()
{
// Same content under a new name is a rename to libgit2: the new path is
// validated (and here is invalid), and the old path no longer counts as
// occupying its spelling.
string source = CreateForgedRepo(_root, ("old-name.txt", "identical content for rename detection"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using (var src = new Repository(source))
{
var head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
var blob = head.Tree["old-name.txt"].Target as Blob;
definition.Remove("old-name.txt");
definition.Add("new:name.txt", blob!, Mode.NonExecutableFile);
var sig = MakeSignature();
var commit = src.ObjectDatabase.CreateCommit(sig, sig, "rename", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
// ---------------------------------------------------------------- clone behavior
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
var invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
[Fact]
public async Task Clone_MixedValidAndInvalidPaths_ReportsOnlyInvalidOnes_AndKeepsFetchedRepo()
{
string source = CreateForgedRepo(_root, "good.txt", "bad:name.txt", "NUL.log");
string target = NewPath("mixed-clone");
Assert.Equal("new:name.txt", Assert.Single(invalid).RepoPath);
}
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
[Fact]
public void ValidateIncoming_NullArguments_Throw()
{
string path = NewPath("empty");
Repository.Init(path);
using var repo = new Repository(path);
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(null!, null!, null!));
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(repo, null!, null!));
}
Assert.Equal(2, ex.Paths.Count);
Assert.DoesNotContain(ex.Paths, p => string.Equals(p.RepoPath, "good.txt", StringComparison.Ordinal));
// The fetched repo is kept for path recovery (marked checkout-pending),
// but nothing may have been checked out.
Assert.True(_client.IsValidRepository(target));
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
using var repo = new Repository(target);
Assert.True(repo.Config.Get<bool>("gclo.checkoutpending")?.Value);
}
[Fact]
public async Task FetchAndPull_IncomingCommitWithInvalidPath_ThrowsTypedException()
{
string source = CreateForgedRepo(_root, "original.txt");
string target = NewPath("pull-target");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
AppendForgedCommit(source, ("bad:incoming.txt", "x"));
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
[Fact]
public async Task Clone_PathLongerThanLegacyMaxPath_Succeeds()
{
// Regression for owner-reported "filename or folder too long" failures:
// core.longpaths is set before checkout, so a deep-but-valid tree clones fine.
string segment = new string('d', 60);
string repoPath = string.Join('/', Enumerable.Repeat(segment, 4)) + "/deep-file.txt";
string source = CreateSourceRepoWithLongPath(repoPath);
string target = NewPath("long-clone");
Assert.True((target + "/" + repoPath).Length > 260); // the scenario is real
Assert.Contains(ex.Paths, p => p.RepoPath == "bad:incoming.txt");
// The valid original working tree must be untouched.
Assert.Equal("content", File.ReadAllText(Path.Combine(target, "original.txt")));
}
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
// ---------------------------------------------------------------- fixture helpers
string fullPath = Path.Combine(target, repoPath.Replace('/', Path.DirectorySeparatorChar));
Assert.Equal("deep content", File.ReadAllText(fullPath));
}
private string NewPath(string name) => Path.Combine(_root, name);
/// <summary>Runs the validator over a tree containing exactly the given entry names.</summary>
private IReadOnlyList<InvalidPathInfo> ValidateNames(params string[] entryNames)
{
string path = NewPath("validator-" + Guid.NewGuid().ToString("N")[..8]);
Repository.Init(path, isBare: true);
using var repo = new Repository(path);
var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("x"u8.ToArray()));
var definition = new TreeDefinition();
foreach (string name in entryNames)
{
definition.Add(name, blob, Mode.NonExecutableFile);
}
return WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
}
// ---------------------------------------------------------------- ValidateIncoming (#30)
/// <summary>Creates a repo containing one file at a deep path exceeding legacy MAX_PATH.</summary>
private string CreateSourceRepoWithLongPath(string repoPath)
{
string path = NewPath("long-source");
Repository.Init(path);
using var repo = new Repository(path);
// The fixture itself needs long-path support to stage the file.
repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local);
[Fact]
public async Task ValidateIncoming_ReportsOnlyWhatThePullIntroduces()
{
// The current tree is already checked out (hence valid); a pull that adds one
// bad path must report exactly that path, and must not re-examine the rest.
string source = CreateForgedRepo(_root, ("ok.txt", "fine"), ("dir/also-ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("dir/bad:name.txt", "x"), ("ok.txt", "modified"));
string fullPath = Path.Combine(path, repoPath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, "deep content");
Commands.Stage(repo, repoPath);
var signature = MakeSignature();
repo.Commit("long path commit", signature, signature);
return path;
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
InvalidPathInfo info = Assert.Single(invalid);
Assert.Equal("dir/bad:name.txt", info.RepoPath);
Assert.Equal("bad_name.txt", info.SuggestedName);
}
[Fact]
public async Task ValidateIncoming_ModifiedFilesOnly_ReturnsEmpty()
{
string source = CreateForgedRepo(_root, ("ok.txt", "fine"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
AppendForgedCommit(source, ("ok.txt", "modified"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_NewPathDifferingOnlyByCaseFromAnExistingOne_IsFlagged()
{
string source = CreateForgedRepo(_root, ("docs/readme.md", "a"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
// Git is happy to hold both; NTFS is not.
AppendForgedCommit(source, ("Docs/README.md", "b"));
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
// The directory collides first; the file under it is reported once each.
Assert.Contains(invalid, i => string.Equals(i.RepoPath, "Docs", StringComparison.Ordinal) && i.Reason.Contains("'docs'"));
Assert.Contains(invalid, i => string.Equals(i.RepoPath, "Docs/README.md", StringComparison.Ordinal) && i.Reason.Contains("'docs/readme.md'"));
}
[Fact]
public async Task ValidateIncoming_CaseChangeViaDeleteAndAdd_IsAllowed()
{
// Upstream renamed 'readme.md' to 'README.md' (delete + add in one commit): the
// old spelling leaves the disk with the pull, so the new one does not collide.
string source = CreateForgedRepo(_root, ("readme.md", "a"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using(var src = new Repository(source))
{
Commit head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
definition.Remove("readme.md");
definition.Add("README.md", src.ObjectDatabase.CreateBlob(new MemoryStream("different"u8.ToArray())), Mode.NonExecutableFile);
Signature sig = MakeSignature();
Commit commit = src.ObjectDatabase.CreateCommit(sig, sig, "recase", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
Assert.Empty(WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree));
}
[Fact]
public async Task ValidateIncoming_DetectedRename_ChecksTheNewPathAndReleasesTheOld()
{
// Same content under a new name is a rename to libgit2: the new path is
// validated (and here is invalid), and the old path no longer counts as
// occupying its spelling.
string source = CreateForgedRepo(_root, ("old-name.txt", "identical content for rename detection"), ("keep.txt", "k"));
string clone = NewPath("clone");
await new LibGit2GitClient().CloneAsync(source, clone, "t", null, CancellationToken.None);
using(var src = new Repository(source))
{
Commit head = src.Head.Tip;
var definition = TreeDefinition.From(head.Tree);
var blob = head.Tree["old-name.txt"].Target as Blob;
definition.Remove("old-name.txt");
definition.Add("new:name.txt", blob!, Mode.NonExecutableFile);
Signature sig = MakeSignature();
Commit commit = src.ObjectDatabase.CreateCommit(sig, sig, "rename", src.ObjectDatabase.CreateTree(definition), [head], false);
src.Refs.UpdateTarget(src.Refs.Head.ResolveToDirectReference(), commit.Id);
}
using var repo = new Repository(clone);
Commands.Fetch(repo, "origin", Array.Empty<string>(), null, null);
IReadOnlyList<InvalidPathInfo> invalid = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip.Tree, repo.Head.TrackedBranch.Tip.Tree);
Assert.Equal("new:name.txt", Assert.Single(invalid).RepoPath);
}
[Fact]
public void ValidateIncoming_NullArguments_Throw()
{
string path = NewPath("empty");
Repository.Init(path);
using var repo = new Repository(path);
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(null!, null!, null!));
Assert.Throws<ArgumentNullException>(() => WindowsPathValidator.ValidateIncoming(repo, null!, null!));
}
[Fact]
public async Task FetchAndPull_IncomingCommitWithInvalidPath_ThrowsTypedException()
{
string source = CreateForgedRepo(_root, "original.txt");
string target = NewPath("pull-target");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
AppendForgedCommit(source, ("bad:incoming.txt", "x"));
InvalidRepositoryPathsException ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => string.Equals(p.RepoPath, "bad:incoming.txt", StringComparison.Ordinal));
// The valid original working tree must be untouched.
Assert.Equal("content", File.ReadAllText(Path.Combine(target, "original.txt")));
}
// ---------------------------------------------------------------- fixture helpers
private string NewPath(string name) => Path.Combine(_root, name);
/// <summary>Runs the validator over a tree containing exactly the given entry names.</summary>
private IReadOnlyList<InvalidPathInfo> ValidateNames(params string[] entryNames)
{
string path = NewPath("validator-" + Guid.NewGuid().ToString("N")[..8]);
Repository.Init(path, isBare: true);
using var repo = new Repository(path);
Blob blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("x"u8.ToArray()));
var definition = new TreeDefinition();
foreach(string name in entryNames)
{
definition.Add(name, blob, Mode.NonExecutableFile);
}
return WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
}
/// <summary>Creates a repo containing one file at a deep path exceeding legacy MAX_PATH.</summary>
private string CreateSourceRepoWithLongPath(string repoPath)
{
string path = NewPath("long-source");
Repository.Init(path);
using var repo = new Repository(path);
// The fixture itself needs long-path support to stage the file.
repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local);
string fullPath = Path.Combine(path, repoPath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, "deep content");
Commands.Stage(repo, repoPath);
Signature signature = MakeSignature();
repo.Commit("long path commit", signature, signature);
return path;
}
}
File diff suppressed because it is too large. Load diff
+220 -177
View File
@@ -1,7 +1,11 @@
using gclo.Engine;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="WslCloner"/> (#8) through a fake process runner: no WSL needed. The
/// contract under test is what wsl.exe is asked to run, that the token never reaches
@@ -9,228 +13,267 @@ namespace gclo.Engine.Tests;
/// </summary>
public sealed class WslClonerTests
{
private const string WslExe = @"C:\Windows\System32\wsl.exe";
private const string Token = "ghp_secret_token_value_1234567890";
private const string WslExe = @"C:\Windows\System32\wsl.exe";
private readonly FakeProcessRunner _runner = new();
private const string Token = "ghp_secret_token_value_1234567890";
private WslCloner Create(bool wslExists = true)
=> new(_runner, _ => wslExists, WslExe);
private readonly FakeProcessRunner _runner = new();
// ---------------------------------------------------------------- probe
[Fact]
public async Task Probe_NoWslExe_IsUnavailable_WithoutRunningAnything()
{
WslAvailability result = await Create(wslExists: false).ProbeAsync(CancellationToken.None);
Assert.False(result.Available);
Assert.Contains("not installed", result.Detail);
Assert.Empty(_runner.Calls);
}
private WslCloner Create(bool wslExists = true)
=> new(_runner, _ => wslExists, WslExe);
[Fact]
public async Task Probe_GitRunsInDistro_IsAvailable_WithTheGitVersion()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "git version 2.53.0\n", ""));
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
Assert.True(result.Available);
Assert.Equal("git version 2.53.0", result.Detail);
ProcessCall call = Assert.Single(_runner.Calls);
Assert.Equal(WslExe, call.FileName);
Assert.Equal(["-e", "git", "--version"], call.Arguments);
Assert.Null(call.Environment);
Assert.Null(call.StandardInput);
}
// ---------------------------------------------------------------- probe
[Fact]
public async Task Probe_NoDistribution_IsUnavailable_WithWslsOwnMessage()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
-1, "", "Windows Subsystem for Linux has no installed distributions.\nUse 'wsl.exe --list --online'.\n"));
[Fact]
public async Task Probe_NoWslExe_IsUnavailable_WithoutRunningAnything()
{
WslAvailability result = await Create(wslExists: false).ProbeAsync(CancellationToken.None);
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
Assert.False(result.Available);
Assert.Contains("not installed", result.Detail);
Assert.Empty(_runner.Calls);
}
Assert.False(result.Available);
Assert.Equal("the default WSL distribution did not run git: Windows Subsystem for Linux has no installed distributions.", result.Detail);
}
[Fact]
public async Task Probe_ExitZeroButNotGit_IsUnavailable()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "sh: git: not found\n", ""));
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
[Fact]
public async Task Probe_GitRunsInDistro_IsAvailable_WithTheGitVersion()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "git version 2.53.0\n", ""));
Assert.False(result.Available);
Assert.Contains("git is not installed in the default WSL distribution (sh: git: not found)", result.Detail);
}
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
[Fact]
public async Task Probe_NonZeroWithNoOutput_ReportsTheExitCode()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(127, "", ""));
Assert.True(result.Available);
Assert.Equal("git version 2.53.0", result.Detail);
ProcessCall call = Assert.Single(_runner.Calls);
Assert.Equal(WslExe, call.FileName);
Assert.Equal(["-e", "git", "--version"], call.Arguments);
Assert.Null(call.Environment);
Assert.Null(call.StandardInput);
}
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
Assert.False(result.Available);
Assert.EndsWith("exit code 127", result.Detail);
}
[Fact]
public async Task Probe_RunnerThrows_IsUnavailable_NotAnException()
{
_runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied");
[Fact]
public async Task Probe_NoDistribution_IsUnavailable_WithWslsOwnMessage()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
-1, "", "Windows Subsystem for Linux has no installed distributions.\nUse 'wsl.exe --list --online'.\n"));
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
Assert.False(result.Available);
Assert.Contains("could not be started: access denied", result.Detail);
}
Assert.False(result.Available);
Assert.Equal("the default WSL distribution did not run git: Windows Subsystem for Linux has no installed distributions.", result.Detail);
}
[Fact]
public async Task Probe_Canceled_Propagates()
{
_runner.Handler = (_, ct) => Task.FromCanceled<ProcessResult>(new CancellationToken(canceled: true));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Create().ProbeAsync(CancellationToken.None));
}
// ---------------------------------------------------------------- clone
[Fact]
public async Task Probe_ExitZeroButNotGit_IsUnavailable()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "sh: git: not found\n", ""));
[Fact]
public async Task Clone_RunsTheScriptThroughStdin_WithTheTokenOnlyInTheEnvironment()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "Ubuntu\n/home/me/gclo/acme/repo\n", ""));
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
WslCloneResult result = await Create().CloneAsync(
"https://github.com/acme/repo.git", "acme", "repo", Token, CancellationToken.None);
Assert.False(result.Available);
Assert.Contains("git is not installed in the default WSL distribution (sh: git: not found)", result.Detail);
}
ProcessCall call = Assert.Single(_runner.Calls);
Assert.Equal(WslExe, call.FileName);
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git"], call.Arguments);
Assert.Equal(WslCloner.CloneScript, call.StandardInput);
Assert.DoesNotContain(Token, string.Join(" ", call.Arguments));
Assert.DoesNotContain(Token, call.StandardInput);
Assert.NotNull(call.Environment);
Assert.Equal(Token, call.Environment![WslCloner.TokenVariable]);
Assert.Equal("0", call.Environment["GIT_TERMINAL_PROMPT"]);
Assert.Equal(WslCloner.TokenVariable + ":GIT_TERMINAL_PROMPT", call.Environment["WSLENV"]);
Assert.Equal("Ubuntu", result.Distribution);
Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath);
Assert.Equal(@"\\wsl.localhost\Ubuntu\home\me\gclo\acme\repo", result.WindowsPath);
}
[Fact]
public void CloneScript_ReadsTheTokenThroughACredentialHelper_AndFastForwardsAnExistingClone()
{
string script = WslCloner.CloneScript;
[Fact]
public async Task Probe_NonZeroWithNoOutput_ReportsTheExitCode()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(127, "", ""));
Assert.Contains("password=$" + WslCloner.TokenVariable, script);
Assert.Contains("credential.helper=", script);
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.helper=$helper\" ", ""));
Assert.Contains("pull --ff-only", script);
Assert.Contains("$HOME/gclo/$org/$repo", script);
Assert.Contains("$WSL_DISTRO_NAME", script);
Assert.StartsWith("set -e", script);
}
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
[Fact]
public async Task Clone_IgnoresChatterBeforeTheLastTwoLines()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
0, "Cloning into '/home/me/gclo/acme/repo'...\nwarning: something\nDebian\n/home/me/gclo/acme/repo\n", "noise on stderr\n"));
Assert.False(result.Available);
Assert.EndsWith("exit code 127", result.Detail);
}
WslCloneResult result = await Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None);
Assert.Equal("Debian", result.Distribution);
Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath);
}
[Fact]
public async Task Clone_GitFails_ThrowsWithTheLastStderrLine()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
128, "", "Cloning into '/home/me/gclo/acme/repo'...\nfatal: Authentication failed for 'https://github.com/acme/repo.git/'\n"));
[Fact]
public async Task Probe_RunnerThrows_IsUnavailable_NotAnException()
{
_runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied");
var ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
WslAvailability result = await Create().ProbeAsync(CancellationToken.None);
Assert.Equal("Clone in WSL failed: fatal: Authentication failed for 'https://github.com/acme/repo.git/'", ex.Message);
}
Assert.False(result.Available);
Assert.Contains("could not be started: access denied", result.Detail);
}
[Fact]
public async Task Clone_FailsWithStdoutOnly_UsesStdout_ThenExitCode()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(1, "disk full\n", ""));
var ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Equal("Clone in WSL failed: disk full", ex.Message);
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(9, "", ""));
ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Equal("Clone in WSL failed: exit code 9", ex.Message);
}
[Fact]
public async Task Clone_SucceedsWithoutReportingAPath_Throws()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "only-one-line\n", ""));
[Fact]
public async Task Probe_Canceled_Propagates()
{
_runner.Handler = (_, ct) => Task.FromCanceled<ProcessResult>(new CancellationToken(canceled: true));
var ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => Create().ProbeAsync(CancellationToken.None));
}
Assert.Contains("did not report where", ex.Message);
}
[Fact]
public async Task Clone_RunnerThrows_IsWrapped()
{
_runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied");
var ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
// ---------------------------------------------------------------- clone
Assert.Equal("wsl.exe could not be started: access denied", ex.Message);
}
[Fact]
public async Task Clone_RunsTheScriptThroughStdin_WithTheTokenOnlyInTheEnvironment()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "Ubuntu\n/home/me/gclo/acme/repo\n", ""));
[Fact]
public async Task Clone_Canceled_Propagates()
{
_runner.Handler = (_, _) => Task.FromCanceled<ProcessResult>(new CancellationToken(canceled: true));
WslCloneResult result = await Create().CloneAsync(
"https://github.com/acme/repo.git", "acme", "repo", Token, CancellationToken.None);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
}
ProcessCall call = Assert.Single(_runner.Calls);
Assert.Equal(WslExe, call.FileName);
Assert.Equal(["-e", "sh", "-s", "acme", "repo", "https://github.com/acme/repo.git"], call.Arguments);
Assert.Equal(WslCloner.CloneScript, call.StandardInput);
Assert.DoesNotContain(Token, string.Join(" ", call.Arguments));
Assert.DoesNotContain(Token, call.StandardInput);
Assert.NotNull(call.Environment);
Assert.Equal(Token, call.Environment![WslCloner.TokenVariable]);
Assert.Equal("0", call.Environment["GIT_TERMINAL_PROMPT"]);
Assert.Equal(WslCloner.TokenVariable + ":GIT_TERMINAL_PROMPT", call.Environment["WSLENV"]);
[Theory]
[InlineData("", "acme", "repo")]
[InlineData("https://x/y.git", " ", "repo")]
[InlineData("https://x/y.git", "acme", "")]
public async Task Clone_RejectsBlankInputs(string url, string org, string repo)
{
await Assert.ThrowsAsync<ArgumentException>(
() => Create().CloneAsync(url, org, repo, Token, CancellationToken.None));
Assert.Empty(_runner.Calls);
}
Assert.Equal("Ubuntu", result.Distribution);
Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath);
Assert.Equal(@"\\wsl.localhost\Ubuntu\home\me\gclo\acme\repo", result.WindowsPath);
}
[Fact]
public async Task Clone_RejectsNullToken()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", null!, CancellationToken.None));
}
[Fact]
public void Defaults_PointAtSystem32Wsl()
{
// The default constructor must be usable by the app without arguments; its
// probe answers "not installed" cleanly on a machine without wsl.exe.
var cloner = new WslCloner();
Assert.NotNull(cloner);
}
[Fact]
public void CloneScript_ReadsTheTokenThroughACredentialHelper_AndFastForwardsAnExistingClone()
{
string script = WslCloner.CloneScript;
Assert.Contains("password=$" + WslCloner.TokenVariable, script);
Assert.Contains("credential.helper=", script);
Assert.Contains("git clone", script.Replace("-c credential.helper= -c \"credential.helper=$helper\" ", ""));
Assert.Contains("pull --ff-only", script);
Assert.Contains("$HOME/gclo/$org/$repo", script);
Assert.Contains("$WSL_DISTRO_NAME", script);
Assert.StartsWith("set -e", script);
}
[Fact]
public async Task Clone_IgnoresChatterBeforeTheLastTwoLines()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
0, "Cloning into '/home/me/gclo/acme/repo'...\nwarning: something\nDebian\n/home/me/gclo/acme/repo\n", "noise on stderr\n"));
WslCloneResult result = await Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None);
Assert.Equal("Debian", result.Distribution);
Assert.Equal("/home/me/gclo/acme/repo", result.LinuxPath);
}
[Fact]
public async Task Clone_GitFails_ThrowsWithTheLastStderrLine()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(
128, "", "Cloning into '/home/me/gclo/acme/repo'...\nfatal: Authentication failed for 'https://github.com/acme/repo.git/'\n"));
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Equal("Clone in WSL failed: fatal: Authentication failed for 'https://github.com/acme/repo.git/'", ex.Message);
}
[Fact]
public async Task Clone_FailsWithStdoutOnly_UsesStdout_ThenExitCode()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(1, "disk full\n", ""));
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Equal("Clone in WSL failed: disk full", ex.Message);
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(9, "", ""));
ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Equal("Clone in WSL failed: exit code 9", ex.Message);
}
[Fact]
public async Task Clone_SucceedsWithoutReportingAPath_Throws()
{
_runner.Handler = (_, _) => Task.FromResult(new ProcessResult(0, "only-one-line\n", ""));
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Contains("did not report where", ex.Message);
}
[Fact]
public async Task Clone_RunnerThrows_IsWrapped()
{
_runner.Handler = (_, _) => throw new System.ComponentModel.Win32Exception("access denied");
WslCloneException ex = await Assert.ThrowsAsync<WslCloneException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
Assert.Equal("wsl.exe could not be started: access denied", ex.Message);
}
[Fact]
public async Task Clone_Canceled_Propagates()
{
_runner.Handler = (_, _) => Task.FromCanceled<ProcessResult>(new CancellationToken(canceled: true));
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None));
}
[Theory]
[InlineData("", "acme", "repo")]
[InlineData("https://x/y.git", " ", "repo")]
[InlineData("https://x/y.git", "acme", "")]
public async Task Clone_RejectsBlankInputs(string url, string org, string repo)
{
await Assert.ThrowsAsync<ArgumentException>(
() => Create().CloneAsync(url, org, repo, Token, CancellationToken.None));
Assert.Empty(_runner.Calls);
}
[Fact]
public async Task Clone_RejectsNullToken()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => Create().CloneAsync("https://x/y.git", "acme", "repo", null!, CancellationToken.None));
}
[Fact]
public void Defaults_PointAtSystem32Wsl()
{
// The default constructor must be usable by the app without arguments; its
// probe answers "not installed" cleanly on a machine without wsl.exe.
var cloner = new WslCloner();
Assert.NotNull(cloner);
}
}
+5 -5
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -8,10 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
+28 -19
View File
@@ -1,5 +1,5 @@
{
"version": 1,
"version": 2,
"dependencies": {
"net10.0": {
"coverlet.collector": {
@@ -8,6 +8,12 @@
"resolved": "10.1.0",
"contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
@@ -35,19 +41,6 @@
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"CommunityToolkit.Mvvm": {
"type": "Transitive",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "Transitive",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"LibGit2Sharp.NativeBinaries": {
"type": "Transitive",
"resolved": "2.0.324",
@@ -71,11 +64,6 @@
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"Octokit": {
"type": "Transitive",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
@@ -129,6 +117,27 @@
"CommunityToolkit.Mvvm": "[8.4.2, )",
"gclo.Engine": "[1.0.1-dev, )"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"LibGit2Sharp": {
"type": "CentralTransitive",
"requested": "[0.32.0, )",
"resolved": "0.32.0",
"contentHash": "BL3eMVRnkWWS9zbz6n0H+iDVJ9ZK7hDeiuASnHlsFvwZYTJtDuBFRRVIaH//s4VlF0baOGvCeenXbobE1JLLiQ==",
"dependencies": {
"LibGit2Sharp.NativeBinaries": "[2.0.324]"
}
},
"Octokit": {
"type": "CentralTransitive",
"requested": "[14.0.0, )",
"resolved": "14.0.0",
"contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw=="
}
}
}
+42 -31
View File
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
namespace gclo.Engine;
/// <summary>
/// Human-readable build identity shared by the app's About dialog and the CLI's
/// version output: the semantic version plus the short git commit the build came
@@ -10,40 +17,44 @@ namespace gclo.Engine;
/// </summary>
public static class BuildVersion
{
private const int ShortHashLength = 9;
private const int ShortHashLength = 9;
/// <summary>Describes <paramref name="assembly"/>'s version and commit.</summary>
public static string Describe(Assembly assembly)
=> Format(assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString());
/// <summary>
/// Formats an informational version: the "+&lt;metadata&gt;" suffix that SourceLink
/// appends becomes a parenthesized short commit hash.
/// </summary>
public static string Format(string? informationalVersion)
{
if (string.IsNullOrWhiteSpace(informationalVersion))
{
return "unknown";
}
int plus = informationalVersion.IndexOf('+');
if (plus < 0)
{
return informationalVersion;
}
/// <summary>Describes <paramref name="assembly"/>'s version and commit.</summary>
public static string Describe(Assembly assembly)
=> Format(assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString());
string version = informationalVersion[..plus];
string metadata = informationalVersion[(plus + 1)..];
// Metadata may carry more than the sha (dot-separated); the hash is first.
int dot = metadata.IndexOf('.');
string hash = dot >= 0 ? metadata[..dot] : metadata;
if (hash.Length > ShortHashLength)
{
hash = hash[..ShortHashLength];
}
return hash.Length == 0 ? version : $"{version} ({hash})";
}
/// <summary>
/// Formats an informational version: the "+&lt;metadata&gt;" suffix that SourceLink
/// appends becomes a parenthesized short commit hash.
/// </summary>
public static string Format(string? informationalVersion)
{
if(string.IsNullOrWhiteSpace(informationalVersion))
{
return "unknown";
}
int plus = informationalVersion.IndexOf('+');
if(plus < 0)
{
return informationalVersion;
}
string version = informationalVersion[..plus];
string metadata = informationalVersion[(plus + 1)..];
// Metadata may carry more than the sha (dot-separated); the hash is first.
int dot = metadata.IndexOf('.');
string hash = dot >= 0 ? metadata[..dot] : metadata;
if(hash.Length > ShortHashLength)
{
hash = hash[..ShortHashLength];
}
return hash.Length == 0 ? version : $"{version} ({hash})";
}
}
+64 -65
View File
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
namespace gclo.Engine;
/// <summary>
/// An <see cref="IActivityLog"/> that appends timestamped lines to a daily file,
/// gclo-yyyy-MM-dd.log. Writes are serialized by a lock and each one opens,
@@ -12,78 +19,70 @@ namespace gclo.Engine;
/// </summary>
public sealed class FileActivityLog : IActivityLog
{
private readonly object _gate = new();
private readonly Lock _gate = new();
/// <summary>Creates a log that writes under <paramref name="directory"/>.</summary>
/// <param name="directory">
/// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank.
/// </param>
public FileActivityLog(string? directory = null)
{
LogDirectory = string.IsNullOrWhiteSpace(directory)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo", "logs")
: directory;
}
/// <inheritdoc/>
public string LogDirectory { get; }
/// <summary>Today's log file; entries roll over to a new file at midnight.</summary>
public string CurrentLogFilePath
=> Path.Combine(
LogDirectory,
"gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log");
/// <summary>Creates a log that writes under <paramref name="directory"/>.</summary>
/// <param name="directory">
/// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank.
/// </param>
public FileActivityLog(string? directory = null)
{
LogDirectory = string.IsNullOrWhiteSpace(directory)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"gclo", "logs")
: directory;
}
/// <inheritdoc/>
public void Info(string message) => Write("INFO", message, exception: null);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception);
private void Write(string level, string message, Exception? exception)
{
try
{
lock (_gate)
/// <inheritdoc/>
public string LogDirectory { get; }
/// <summary>Today's log file; entries roll over to a new file at midnight.</summary>
public string CurrentLogFilePath
=> Path.Combine(
LogDirectory,
"gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log");
/// <inheritdoc/>
public void Info(string message) => Write("INFO", message, exception: null);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception);
private void Write(string level, string message, Exception? exception)
{
try
{
lock(_gate)
{
Directory.CreateDirectory(LogDirectory);
string timestamp = DateTime.Now.ToString(
"yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"{timestamp} [{level}] {message}";
if(exception is not null)
{
Directory.CreateDirectory(LogDirectory);
string timestamp = DateTime.Now.ToString(
"yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"{timestamp} [{level}] {message}";
if (exception is not null)
{
entry += Environment.NewLine + exception;
}
File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine);
entry += Environment.NewLine + exception;
}
}
catch
{
// A logging failure must never take the operation it describes down.
}
}
}
/// <summary>An <see cref="IActivityLog"/> that discards everything.</summary>
public sealed class NullActivityLog : IActivityLog
{
/// <inheritdoc/>
public string LogDirectory => "";
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message)
{
}
/// <inheritdoc/>
public void Error(string message, Exception? exception = null)
{
}
File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine);
}
}
catch
{
// A logging failure must never take the operation it describes down.
}
}
}
+15 -23
View File
@@ -1,20 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>Why GitHub refused a request, in the terms a caller can act on.</summary>
public enum GitHubAccessKind
{
/// <summary>The token was rejected (401): expired, revoked, or malformed.</summary>
Unauthorized,
/// <summary>The token is valid but may not see the target (an ungranted organization, missing SSO).</summary>
Forbidden,
/// <summary>A primary or secondary rate limit, or temporary abuse/login throttling: retry later.</summary>
RateLimited,
/// <summary>No organization or user account with that login is visible to the token.</summary>
NotFound,
}
/// <summary>
/// A GitHub API refusal translated into an actionable message. Derives from
@@ -24,13 +14,15 @@ public enum GitHubAccessKind
/// </summary>
public sealed class GitHubAccessException : InvalidOperationException
{
/// <summary>The category of refusal.</summary>
public GitHubAccessKind Kind { get; }
/// <summary>The category of refusal.</summary>
public GitHubAccessKind Kind { get; }
/// <summary>Creates the exception with its category, user-facing message, and the Octokit cause.</summary>
public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null)
: base(message, innerException)
{
Kind = kind;
}
/// <summary>Creates the exception with its category, user-facing message, and the Octokit cause.</summary>
public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null)
: base(message, innerException)
{
Kind = kind;
}
}
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>Why GitHub refused a request, in the terms a caller can act on.</summary>
public enum GitHubAccessKind
{
/// <summary>The token was rejected (401): expired, revoked, or malformed.</summary>
Unauthorized,
/// <summary>The token is valid but may not see the target (an ungranted organization, missing SSO).</summary>
Forbidden,
/// <summary>A primary or secondary rate limit, or temporary abuse/login throttling: retry later.</summary>
RateLimited,
/// <summary>No organization or user account with that login is visible to the token.</summary>
NotFound,
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>What a GitHub login names, as far as the token can tell.</summary>
internal enum GitHubAccountKind
{
/// <summary>A user account.</summary>
User,
/// <summary>An organization (whether or not the token can see its repositories).</summary>
Organization,
/// <summary>No account with that login is visible to the token.</summary>
NotFound,
}
+88 -75
View File
@@ -1,91 +1,104 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using Octokit;
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;
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubOrganizationLister()
: this(OctokitGatewayCache.Get)
{
}
/// <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();
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubOrganizationLister()
: this(OctokitGatewayCache.Get)
{
}
IGitHubGateway gateway = _gatewayFactory(token);
string userLogin;
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.
userLogin = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
try
{
organizations = await gateway.GetOrganizationLoginsAsync().ConfigureAwait(false);
}
catch (ForbiddenException ex)
when (ex is not RateLimitExceededException
and not SecondaryRateLimitExceededException
and not AbuseException
and not LoginAttemptsExceededException)
{
// Degraded mode: classic PATs without the read:org scope get 403
// from /user/orgs even though the token is otherwise fine. The
// personal account is still a usable sync target, so return just
// that instead of failing the whole listing. Throttling 403s
// (secondary rate limit, abuse detection, login attempts) are NOT a
// scope problem and must not be degraded into "no organizations" (#31).
return [userLogin];
}
}
catch (AuthorizationException ex)
{
throw new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401). Check the PAT.", ex);
}
catch (RateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch (SecondaryRateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch (AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
catch (LoginAttemptsExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub temporarily blocked this token after too many failed attempts; wait a few minutes and retry.", ex);
}
/// <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));
cancellationToken.ThrowIfCancellationRequested();
var result = new List<string> { userLogin };
result.AddRange(organizations
.Where(login => !string.Equals(login, userLogin, StringComparison.OrdinalIgnoreCase))
.OrderBy(login => login, StringComparer.OrdinalIgnoreCase));
return result;
}
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(token);
cancellationToken.ThrowIfCancellationRequested();
IGitHubGateway gateway = _gatewayFactory(token);
string userLogin;
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.
userLogin = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
try
{
organizations = await gateway.GetOrganizationLoginsAsync().ConfigureAwait(false);
}
catch(ForbiddenException ex)
when(ex is not RateLimitExceededException
and not SecondaryRateLimitExceededException
and not AbuseException
and not LoginAttemptsExceededException)
{
// Degraded mode: classic PATs without the read:org scope get 403
// from /user/orgs even though the token is otherwise fine. The
// personal account is still a usable sync target, so return just
// that instead of failing the whole listing. Throttling 403s
// (secondary rate limit, abuse detection, login attempts) are NOT a
// scope problem and must not be degraded into "no organizations" (#31).
return [userLogin];
}
}
catch(AuthorizationException ex)
{
throw new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401). Check the PAT.", ex);
}
catch(RateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch(SecondaryRateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch(AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
catch(LoginAttemptsExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub temporarily blocked this token after too many failed attempts; wait a few minutes and retry.", ex);
}
cancellationToken.ThrowIfCancellationRequested();
var result = new List<string> { userLogin };
result.AddRange(organizations
.Where(login => !string.Equals(login, userLogin, StringComparison.OrdinalIgnoreCase))
.OrderBy(login => login, StringComparer.OrdinalIgnoreCase));
return result;
}
}
+10
View File
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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);
+134 -121
View File
@@ -1,143 +1,156 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using Octokit;
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;
private readonly Func<string, IGitHubGateway> _gatewayFactory;
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubRepositoryLister()
: this(OctokitGatewayCache.Get)
{
}
/// <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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(organization);
ArgumentException.ThrowIfNullOrWhiteSpace(token);
cancellationToken.ThrowIfCancellationRequested();
/// <summary>Production wiring: the process-wide Octokit gateway for the call's token (connection reuse).</summary>
public GitHubRepositoryLister()
: this(OctokitGatewayCache.Get)
{
}
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<GitHubRepo>> PageAsync(Func<int, Task<IReadOnlyList<GitHubRepo>>> fetchPage)
{
var all = new List<GitHubRepo>();
for (int page = 1; ; page++)
/// <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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(organization);
ArgumentException.ThrowIfNullOrWhiteSpace(token);
cancellationToken.ThrowIfCancellationRequested();
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<GitHubRepo>> PageAsync(Func<int, Task<IReadOnlyList<GitHubRepo>>> fetchPage)
{
var all = new List<GitHubRepo>();
for(int page = 1; ; page++)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<GitHubRepo> batch = await fetchPage(page).ConfigureAwait(false);
all.AddRange(batch);
if(batch.Count < IGitHubGateway.PageSize)
{
cancellationToken.ThrowIfCancellationRequested();
var batch = await fetchPage(page).ConfigureAwait(false);
all.AddRange(batch);
if (batch.Count < IGitHubGateway.PageSize)
{
break;
}
break;
}
return all;
}
}
return all;
}
List<GitHubRepo> repositories;
try
{
try
List<GitHubRepo> repositories;
try
{
try
{
repositories = await PageAsync(
page => gateway.GetOrganizationRepositoriesPageAsync(organization, page))
.ConfigureAwait(false);
}
catch(NotFoundException)
{
// /orgs/{name}/repos 404s for user accounts — but ALSO for a real
// organization the token cannot see (an ungranted fine-grained PAT, or
// SSO not authorized). Falling through to /users/{name}/repos for an
// organization would list only its public repositories and report a
// green sync that silently omitted the rest, so the account kind is
// resolved first and an invisible organization is an error (#31).
GitHubAccountKind kind = await gateway.GetAccountKindAsync(organization).ConfigureAwait(false);
if(kind == GitHubAccountKind.Organization)
{
repositories = await PageAsync(
page => gateway.GetOrganizationRepositoriesPageAsync(organization, page))
.ConfigureAwait(false);
throw new GitHubAccessException(
GitHubAccessKind.Forbidden,
$"'{organization}' is an organization, but this token cannot see its repositories (404). "
+ "Grant the PAT access to the organization — for a fine-grained token choose it as the resource owner, "
+ "for a classic token authorize it for SSO — then try again.");
}
catch (NotFoundException)
if(kind == GitHubAccountKind.NotFound)
{
// /orgs/{name}/repos 404s for user accounts — but ALSO for a real
// organization the token cannot see (an ungranted fine-grained PAT, or
// SSO not authorized). Falling through to /users/{name}/repos for an
// organization would list only its public repositories and report a
// green sync that silently omitted the rest, so the account kind is
// resolved first and an invisible organization is an error (#31).
GitHubAccountKind kind = await gateway.GetAccountKindAsync(organization).ConfigureAwait(false);
if (kind == GitHubAccountKind.Organization)
{
throw new GitHubAccessException(
GitHubAccessKind.Forbidden,
$"'{organization}' is an organization, but this token cannot see its repositories (404). "
+ "Grant the PAT access to the organization — for a fine-grained token choose it as the resource owner, "
+ "for a classic token authorize it for SSO — then try again.");
}
if (kind == GitHubAccountKind.NotFound)
{
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' was found neither as an organization nor as a user account (404). Check the spelling.");
}
// A user account: the token's own account gets its owned repos
// (including private); any other user account yields the repos the
// token can see there (public).
string currentUser = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
if (string.Equals(currentUser, organization, StringComparison.OrdinalIgnoreCase))
{
repositories = await PageAsync(gateway.GetOwnRepositoriesPageAsync)
.ConfigureAwait(false);
}
else
{
try
{
repositories = await PageAsync(
page => gateway.GetUserRepositoriesPageAsync(organization, page))
.ConfigureAwait(false);
}
catch (NotFoundException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' was found neither as an organization nor as a user account (404) — or the token cannot see it.", ex);
}
}
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' was found neither as an organization nor as a user account (404). Check the spelling.");
}
}
catch (AuthorizationException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.Unauthorized,
"GitHub rejected the token (401). Check the PAT and make sure it has 'repo' (classic) or repository read access (fine-grained).", ex);
}
catch (RateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch (SecondaryRateLimitExceededException ex)
{
// A burst of requests, not a scope problem; Octokit models it beside (not
// under) AbuseException, so both get the same translation.
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch (AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
cancellationToken.ThrowIfCancellationRequested();
// A user account: the token's own account gets its owned repos
// (including private); any other user account yields the repos the
// token can see there (public).
string currentUser = await gateway.GetCurrentUserLoginAsync().ConfigureAwait(false);
if(string.Equals(currentUser, organization, StringComparison.OrdinalIgnoreCase))
{
repositories = await PageAsync(gateway.GetOwnRepositoriesPageAsync)
.ConfigureAwait(false);
}
else
{
try
{
repositories = await PageAsync(
page => gateway.GetUserRepositoriesPageAsync(organization, page))
.ConfigureAwait(false);
}
catch(NotFoundException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.NotFound,
$"'{organization}' was found neither as an organization nor as a user account (404) — or the token cannot see it.", ex);
}
}
}
}
catch(AuthorizationException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.Unauthorized,
"GitHub rejected the token (401). Check the PAT and make sure it has 'repo' (classic) or repository read access (fine-grained).", ex);
}
catch(RateLimitExceededException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited, $"GitHub API rate limit exceeded; it resets at {ex.Reset:u}.", ex);
}
catch(SecondaryRateLimitExceededException ex)
{
// A burst of requests, not a scope problem; Octokit models it beside (not
// under) AbuseException, so both get the same translation.
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
"GitHub's secondary rate limit was hit (too many requests in a short time); retry in a minute.", ex);
}
catch(AbuseException ex)
{
throw new GitHubAccessException(
GitHubAccessKind.RateLimited,
$"GitHub's secondary rate limit was hit (too many requests in a short time); retry in {ex.RetryAfterSeconds ?? 60} seconds.", ex);
}
return repositories
// A repo created mid-listing shifts GitHub's offset pagination and can
// 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.IsArchived))
.ToList();
}
cancellationToken.ThrowIfCancellationRequested();
return repositories
// A repo created mid-listing shifts GitHub's offset pagination and can
// 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.IsArchived))
.ToList();
}
}
+18 -8
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// Application activity log for human-readable operational events. Implementations
/// must be safe to call from any thread and must never throw from a logging call;
@@ -7,15 +13,19 @@ namespace gclo.Engine;
/// </summary>
public interface IActivityLog
{
/// <summary>Records an informational event.</summary>
void Info(string message);
/// <summary>Records an informational event.</summary>
void Info(string message);
/// <summary>Records a failure, with the exception's full text when one is given.</summary>
void Error(string message, Exception? exception = null);
/// <summary>Folder that holds the log files.</summary>
string LogDirectory { get; }
/// <summary>Full path of the file new entries are currently appended to.</summary>
string CurrentLogFilePath { get; }
/// <summary>Records a failure, with the exception's full text when one is given.</summary>
void Error(string message, Exception? exception = null);
/// <summary>Folder that holds the log files.</summary>
string LogDirectory { get; }
/// <summary>Full path of the file new entries are currently appended to.</summary>
string CurrentLogFilePath { get; }
}
+35 -23
View File
@@ -1,32 +1,44 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>Git transport operations for a single repository.</summary>
public interface IGitClient
{
/// <summary>True when <paramref name="path"/> is an existing local git repository.</summary>
bool IsValidRepository(string path);
/// <summary>True when <paramref name="path"/> is an existing local git repository.</summary>
bool IsValidRepository(string path);
/// <summary>Clones <paramref name="url"/> into <paramref name="path"/>.</summary>
/// <param name="url">HTTPS clone URL.</param>
/// <param name="path">Local directory the repository is cloned into.</param>
/// <param name="token">GitHub Personal Access Token used for git transport.</param>
/// <param name="onProgress">Receives object-transfer completion in [0,1], when known.</param>
/// <param name="cancellationToken">Cancels the clone.</param>
Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken);
/// <summary>Fetches from origin and fast-forwards the current branch to its upstream.</summary>
Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken);
/// <summary>
/// Materializes the working tree of a fetched-but-never-checked-out repository
/// (one whose clone failed with <see cref="InvalidRepositoryPathsException"/>)
/// by writing HEAD's blobs to disk with <paramref name="recovery"/>'s renames
/// and skips applied. Any previously persisted recovery is merged in first,
/// with <paramref name="recovery"/> winning on conflicts, so a repository that
/// gains new invalid paths can be fixed without restating earlier choices.
/// Throws <see cref="InvalidRepositoryPathsException"/> when the effective
/// (post-mapping) path set is still invalid or collides; on success the merged
/// recovery is persisted so later pulls re-apply it to new commits.
/// </summary>
Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken);
/// <summary>Clones <paramref name="url"/> into <paramref name="path"/>.</summary>
/// <param name="url">HTTPS clone URL.</param>
/// <param name="path">Local directory the repository is cloned into.</param>
/// <param name="token">GitHub Personal Access Token used for git transport.</param>
/// <param name="onProgress">Receives object-transfer completion in [0,1], when known.</param>
/// <param name="cancellationToken">Cancels the clone.</param>
Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken);
/// <summary>Fetches from origin and fast-forwards the current branch to its upstream.</summary>
Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken);
/// <summary>
/// Materializes the working tree of a fetched-but-never-checked-out repository
/// (one whose clone failed with <see cref="InvalidRepositoryPathsException"/>)
/// by writing HEAD's blobs to disk with <paramref name="recovery"/>'s renames
/// and skips applied. Any previously persisted recovery is merged in first,
/// with <paramref name="recovery"/> winning on conflicts, so a repository that
/// gains new invalid paths can be fixed without restating earlier choices.
/// Throws <see cref="InvalidRepositoryPathsException"/> when the effective
/// (post-mapping) path set is still invalid or collides; on success the merged
/// recovery is persisted so later pulls re-apply it to new commits.
/// </summary>
Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken);
}
+20 -103
View File
@@ -1,23 +1,13 @@
using System.Diagnostics.CodeAnalysis;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
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>What a GitHub login names, as far as the token can tell.</summary>
internal enum GitHubAccountKind
{
/// <summary>A user account.</summary>
User,
/// <summary>An organization (whether or not the token can see its repositories).</summary>
Organization,
/// <summary>No account with that login is visible to the token.</summary>
NotFound,
}
/// <summary>
/// The seam between the listers and the GitHub REST API: exactly the calls gclo
@@ -29,109 +19,36 @@ internal enum GitHubAccountKind
/// </summary>
internal interface IGitHubGateway
{
/// <summary>Repositories per page; a batch smaller than this ends the paging loop.</summary>
const int PageSize = 100;
/// <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>One page of an organization's repositories (1-based page number).</summary>
Task<IReadOnlyList<GitHubRepo>> GetOrganizationRepositoriesPageAsync(string organization, 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>Whether <paramref name="login"/> is a user, an organization, or unknown to the token.</summary>
Task<GitHubAccountKind> GetAccountKindAsync(string login);
}
/// <summary>One page of the token's own repositories (owner affiliation).</summary>
Task<IReadOnlyList<GitHubRepo>> GetOwnRepositoriesPageAsync(int page);
/// <summary>
/// One <see cref="OctokitGateway"/> per token, shared by both listers for the life of
/// the process, so successive org lookups and repository loads ride one warm HTTP
/// connection instead of paying a DNS + TCP + TLS handshake to api.github.com on
/// every interactive action (#30). Octokit clients are thread-safe for concurrent
/// requests with fixed credentials. Bounded: tokens are rotated rarely, so when the
/// cache grows past a handful of entries it is simply emptied.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Holds live Octokit clients; the offline suite injects fake gateways.")]
internal static class OctokitGatewayCache
{
private const int MaxEntries = 8;
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, OctokitGateway> Gateways =
new(StringComparer.Ordinal);
public static IGitHubGateway Get(string token)
{
if (Gateways.Count >= MaxEntries && !Gateways.ContainsKey(token))
{
Gateways.Clear();
}
return Gateways.GetOrAdd(token, static t => new OctokitGateway(t));
}
}
/// <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;
/// <summary>One page of another user account's visible repositories.</summary>
Task<IReadOnlyList<GitHubRepo>> GetUserRepositoriesPageAsync(string user, int page);
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));
}
/// <summary>The login of the account the token authenticates as.</summary>
Task<string> GetCurrentUserLoginAsync();
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();
}
/// <summary>Logins of the organizations visible to the token.</summary>
Task<IReadOnlyList<string>> GetOrganizationLoginsAsync();
public async Task<GitHubAccountKind> GetAccountKindAsync(string login)
{
try
{
var account = await _client.User.Get(login).ConfigureAwait(false);
return account.Type == AccountType.Organization ? GitHubAccountKind.Organization : GitHubAccountKind.User;
}
catch (NotFoundException)
{
return GitHubAccountKind.NotFound;
}
}
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();
/// <summary>Whether <paramref name="login"/> is a user, an organization, or unknown to the token.</summary>
Task<GitHubAccountKind> GetAccountKindAsync(string login);
}
+13 -7
View File
@@ -1,13 +1,19 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>Lists the GitHub organizations a token can see.</summary>
public interface IOrganizationLister
{
/// <summary>
/// Returns the login names of organizations the token's user belongs to.
/// May legitimately be empty for tokens without organization read access
/// (fine-grained PATs, classic PATs missing read:org) — callers should still
/// allow manual organization entry.
/// </summary>
Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default);
/// <summary>
/// Returns the login names of organizations the token's user belongs to.
/// May legitimately be empty for tokens without organization read access
/// (fine-grained PATs, classic PATs missing read:org) — callers should still
/// allow manual organization entry.
/// </summary>
Task<IReadOnlyList<string>> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default);
}
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// Runs an external process to completion. The seam that keeps <see cref="WslCloner"/>
/// testable without a WSL installation: the offline suite scripts results through a fake.
/// </summary>
public interface IProcessRunner
{
/// <param name="fileName">Executable path.</param>
/// <param name="arguments">Argument vector (quoted by the runner, never by the caller).</param>
/// <param name="environment">Extra environment variables for the child; the parent's are inherited.</param>
/// <param name="standardInput">Text written to the child's stdin, then closed; null leaves stdin closed.</param>
/// <param name="cancellationToken">Kills the child and throws <see cref="OperationCanceledException"/>.</param>
Task<ProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment,
string? standardInput,
CancellationToken cancellationToken);
}
+9 -3
View File
@@ -1,9 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>Lists every repository in a GitHub organization.</summary>
public interface IRepositoryLister
{
/// <summary>Returns all repositories in the organization, following pagination to the end.</summary>
Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default);
/// <summary>Returns all repositories in the organization, following pagination to the end.</summary>
Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default);
}
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// The "Clone in WSL instead" recovery for repositories whose trees contain
/// Windows-invalid paths (#8): a Linux file system has no such rule, so the
/// repository is cloned by the distribution's own git into
/// <c>~/gclo/&lt;organization&gt;/&lt;repository&gt;</c> and left there.
/// </summary>
public interface IWslCloner
{
/// <summary>Checks, without side effects, whether a clone into WSL can be attempted.</summary>
Task<WslAvailability> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Clones <paramref name="url"/> inside the default WSL distribution, or fast-forwards
/// it when that folder is already a repository. Throws <see cref="WslCloneException"/>
/// when git or wsl.exe fail.
/// </summary>
/// <param name="url">HTTPS clone URL.</param>
/// <param name="organization">Organization (or user) name; the parent folder under ~/gclo.</param>
/// <param name="repositoryName">Repository name; the clone's folder name.</param>
/// <param name="token">GitHub token; handed to git through the environment only, never on a command line.</param>
/// <param name="cancellationToken">Kills wsl.exe (and the clone) when canceled.</param>
Task<WslCloneResult> CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken);
}
+6
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>One repository path that cannot be created on a Windows file system.</summary>
/// <param name="RepoPath">Path inside the repository, forward-slash separated.</param>
/// <param name="Reason">Human-readable reason the path is invalid on Windows.</param>
+26 -16
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// Thrown instead of a raw libgit2 checkout error when a repository contains paths
/// that are legal in git but cannot be created on Windows (invalid characters,
@@ -7,22 +13,26 @@ namespace gclo.Engine;
/// </summary>
public sealed class InvalidRepositoryPathsException : Exception
{
/// <summary>The offending paths, one entry per invalid path.</summary>
public IReadOnlyList<InvalidPathInfo> Paths { get; }
/// <summary>The offending paths, one entry per invalid path.</summary>
public IReadOnlyList<InvalidPathInfo> Paths { get; }
/// <summary>Creates the exception from the validator's findings.</summary>
public InvalidRepositoryPathsException(IReadOnlyList<InvalidPathInfo> paths)
: base(BuildMessage(paths))
{
Paths = paths;
}
private static string BuildMessage(IReadOnlyList<InvalidPathInfo> paths)
{
var examples = string.Join("; ", paths.Take(3).Select(p => $"'{p.RepoPath}' ({p.Reason})"));
var suffix = paths.Count > 3 ? $" and {paths.Count - 3} more" : "";
string subject = paths.Count == 1 ? "1 path" : $"{paths.Count} paths";
return $"{subject} in this repository cannot be created on Windows: {examples}{suffix}. " +
"Nothing was checked out.";
}
/// <summary>Creates the exception from the validator's findings.</summary>
public InvalidRepositoryPathsException(IReadOnlyList<InvalidPathInfo> paths)
: base(BuildMessage(paths))
{
Paths = paths;
}
private static string BuildMessage(IReadOnlyList<InvalidPathInfo> paths)
{
string examples = string.Join("; ", paths.Take(3).Select(p => $"'{p.RepoPath}' ({p.Reason})"));
string suffix = paths.Count > 3 ? $" and {paths.Count - 3} more" : "";
string subject = paths.Count == 1 ? "1 path" : $"{paths.Count} paths";
return $"{subject} in this repository cannot be created on Windows: {examples}{suffix}. " +
"Nothing was checked out.";
}
}
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>An <see cref="IActivityLog"/> that discards everything.</summary>
public sealed class NullActivityLog : IActivityLog
{
/// <inheritdoc/>
public string LogDirectory => "";
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message)
{
}
/// <inheritdoc/>
public void Error(string message, Exception? exception = null)
{
}
}
+89
View File
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using Octokit;
namespace gclo.Engine;
/// <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()
{
IReadOnlyList<Organization> organizations = await _client.Organization
.GetAllForCurrent(new ApiOptions { PageSize = IGitHubGateway.PageSize })
.ConfigureAwait(false);
return organizations.Select(o => o.Login).ToList();
}
public async Task<GitHubAccountKind> GetAccountKindAsync(string login)
{
try
{
User account = await _client.User.Get(login).ConfigureAwait(false);
return account.Type == AccountType.Organization ? GitHubAccountKind.Organization : GitHubAccountKind.User;
}
catch(NotFoundException)
{
return GitHubAccountKind.NotFound;
}
}
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();
}
+38
View File
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
namespace gclo.Engine;
/// <summary>
/// One <see cref="OctokitGateway"/> per token, shared by both listers for the life of
/// the process, so successive org lookups and repository loads ride one warm HTTP
/// connection instead of paying a DNS + TCP + TLS handshake to api.github.com on
/// every interactive action (#30). Octokit clients are thread-safe for concurrent
/// requests with fixed credentials. Bounded: tokens are rotated rarely, so when the
/// cache grows past a handful of entries it is simply emptied.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Holds live Octokit clients; the offline suite injects fake gateways.")]
internal static class OctokitGatewayCache
{
private const int MaxEntries = 8;
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, OctokitGateway> s_gateways =
new(StringComparer.Ordinal);
public static IGitHubGateway Get(string token)
{
if(s_gateways.Count >= MaxEntries && !s_gateways.ContainsKey(token))
{
s_gateways.Clear();
}
return s_gateways.GetOrAdd(token, static t => new OctokitGateway(t));
}
}
+158 -144
View File
@@ -1,7 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
namespace gclo.Engine;
/// <summary>
/// Clones or updates every repository in a GitHub organization with bounded parallelism.
/// One repository failing never aborts the others; cancellation stops scheduling and
@@ -9,167 +16,174 @@ namespace gclo.Engine;
/// </summary>
public sealed class OrgSyncEngine
{
private readonly IRepositoryLister _lister;
private readonly IGitClient _git;
private readonly IRepositoryLister _lister;
/// <summary>
/// Creates an engine that discovers repositories through <paramref name="lister"/>
/// and runs git operations through <paramref name="git"/>.
/// </summary>
public OrgSyncEngine(IRepositoryLister lister, IGitClient git)
{
_lister = lister ?? throw new ArgumentNullException(nameof(lister));
_git = git ?? throw new ArgumentNullException(nameof(git));
}
private readonly IGitClient _git;
/// <summary>Lists the organization's repositories, then syncs all of them.</summary>
public async Task<SyncSummary> SyncAsync(
SyncRequest request,
IProgress<RepoProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Organization);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Token);
ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot);
var repositories = await _lister
.ListOrganizationRepositoriesAsync(request.Organization, request.Token, cancellationToken)
.ConfigureAwait(false);
return await SyncAsync(request, repositories, progress, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates an engine that discovers repositories through <paramref name="lister"/>
/// and runs git operations through <paramref name="git"/>.
/// </summary>
public OrgSyncEngine(IRepositoryLister lister, IGitClient git)
{
_lister = lister ?? throw new ArgumentNullException(nameof(lister));
_git = git ?? throw new ArgumentNullException(nameof(git));
}
/// <summary>
/// Syncs a caller-supplied repository list without hitting the GitHub API.
/// Use this when the repositories were already listed (and possibly filtered
/// down to a selection) by the caller; semantics are otherwise identical to
/// <see cref="SyncAsync(SyncRequest, IProgress{RepoProgress}?, CancellationToken)"/>.
/// </summary>
public async Task<SyncSummary> SyncAsync(
SyncRequest request,
IReadOnlyList<RepoDescriptor> repositories,
IProgress<RepoProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(repositories);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Token);
ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot);
var repos = repositories
// Names key the pending dictionary and the target folders; a duplicate
// (possible from pagination shifts) must not crash the run or race two
// git operations into the same directory.
.DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
// Resolve every target folder before anything is created or reported: this
// overload accepts caller-supplied names, and a name that is not one safe
// segment under TargetRoot (rooted, traversal, separators) fails the whole
// call here rather than cloning somewhere the caller never asked for.
var targets = repos
.Select(r => (Repo: r, Path: RepositoryPathResolver.Resolve(request.TargetRoot, r.Name)))
.ToList();
/// <summary>Lists the organization's repositories, then syncs all of them.</summary>
public async Task<SyncSummary> SyncAsync(
SyncRequest request,
IProgress<RepoProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Organization, nameof(request));
ArgumentException.ThrowIfNullOrWhiteSpace(request.Token, nameof(request));
ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot, nameof(request));
// Create the root before anything is reported as Queued: if the path is
// invalid, the caller gets one exception instead of repos stranded mid-state.
Directory.CreateDirectory(request.TargetRoot);
IReadOnlyList<RepoDescriptor> repositories = await _lister
.ListOrganizationRepositoriesAsync(request.Organization, request.Token, cancellationToken)
.ConfigureAwait(false);
foreach (var repo in repos)
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Queued));
}
return await SyncAsync(request, repositories, progress, cancellationToken).ConfigureAwait(false);
}
if (repos.Count == 0)
{
return new SyncSummary(0, 0, 0, 0, 0, WasCanceled: false);
}
int cloned = 0, updated = 0, failed = 0, canceled = 0;
var pending = new ConcurrentDictionary<string, RepoDescriptor>(
repos.Select(r => new KeyValuePair<string, RepoDescriptor>(r.Name, r)));
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, request.MaxConcurrency),
CancellationToken = cancellationToken,
};
/// <summary>
/// Syncs a caller-supplied repository list without hitting the GitHub API.
/// Use this when the repositories were already listed (and possibly filtered
/// down to a selection) by the caller; semantics are otherwise identical to
/// <see cref="SyncAsync(SyncRequest, IProgress{RepoProgress}?, CancellationToken)"/>.
/// </summary>
public async Task<SyncSummary> SyncAsync(
SyncRequest request,
IReadOnlyList<RepoDescriptor> repositories,
IProgress<RepoProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(repositories);
ArgumentException.ThrowIfNullOrWhiteSpace(request.Token, nameof(request));
ArgumentException.ThrowIfNullOrWhiteSpace(request.TargetRoot, nameof(request));
try
{
// Parallel.ForEachAsync stops scheduling as soon as any body throws, so the
// body must swallow every per-repo failure; only cancellation may escape.
await Parallel.ForEachAsync(targets, parallelOptions, async (target, token) =>
var repos = repositories
// Names key the pending dictionary and the target folders; a duplicate
// (possible from pagination shifts) must not crash the run or race two
// git operations into the same directory.
.DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
// Resolve every target folder before anything is created or reported: this
// overload accepts caller-supplied names, and a name that is not one safe
// segment under TargetRoot (rooted, traversal, separators) fails the whole
// call here rather than cloning somewhere the caller never asked for.
var targets = repos
.Select(r => (Repo: r, Path: RepositoryPathResolver.Resolve(request.TargetRoot, r.Name)))
.ToList();
// Create the root before anything is reported as Queued: if the path is
// invalid, the caller gets one exception instead of repos stranded mid-state.
Directory.CreateDirectory(request.TargetRoot);
foreach(RepoDescriptor? repo in repos)
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Queued));
}
if(repos.Count == 0)
{
return new SyncSummary(0, 0, 0, 0, 0, WasCanceled: false);
}
int cloned = 0, updated = 0, failed = 0, canceled = 0;
var pending = new ConcurrentDictionary<string, RepoDescriptor>(
repos.Select(r => new KeyValuePair<string, RepoDescriptor>(r.Name, r)), StringComparer.Ordinal);
var parallelOptions = new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, request.MaxConcurrency),
CancellationToken = cancellationToken,
};
try
{
// Parallel.ForEachAsync stops scheduling as soon as any body throws, so the
// body must swallow every per-repo failure; only cancellation may escape.
await Parallel.ForEachAsync(targets, parallelOptions, async (target, token) =>
{
(RepoDescriptor? repo, string? path) = target;
try
{
var (repo, path) = target;
try
{
token.ThrowIfCancellationRequested();
token.ThrowIfCancellationRequested();
// A name that is one safe segment (RepositoryPathResolver) can still be
// impossible as a Windows folder: GitHub allows repositories called
// 'aux' or 'con'. Fail the row with a plain reason instead of an opaque
// OS error from the clone (#31).
if (OperatingSystem.IsWindows()
&& WindowsPathValidator.ValidatePaths(new[] { repo.Name }) is { Count: > 0 } invalidName)
{
throw new InvalidOperationException(
$"Repository name '{repo.Name}' cannot be a folder on Windows ({invalidName[0].Reason}). "
+ "Rename the repository on GitHub, or sync it on another platform.");
}
// A name that is one safe segment (RepositoryPathResolver) can still be
// impossible as a Windows folder: GitHub allows repositories called
// 'aux' or 'con'. Fail the row with a plain reason instead of an opaque
// OS error from the clone (#31).
if(OperatingSystem.IsWindows()
&& WindowsPathValidator.ValidatePaths(new[] { repo.Name }) is { Count: > 0 } invalidName)
{
throw new InvalidOperationException(
$"Repository name '{repo.Name}' cannot be a folder on Windows ({invalidName[0].Reason}). "
+ "Rename the repository on GitHub, or sync it on another platform.");
}
// WaitAsync: a git operation wedged in native transport code (DNS,
// TLS, a black-holed connection) observes no callback and therefore
// no cancellation. Abandoning the await keeps cancellation prompt;
// the orphaned operation finishes (or times out) on its own thread
// and its own cleanup still runs (#31).
if (_git.IsValidRepository(path))
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Pulling));
await _git.FetchAndPullAsync(path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref updated);
}
else
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Cloning));
await _git.CloneAsync(
repo.CloneUrl, path, request.Token,
pct => progress?.Report(new RepoProgress(repo.Name, SyncStatus.Cloning, Percent: pct)),
token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref cloned);
}
// WaitAsync: a git operation wedged in native transport code (DNS,
// TLS, a black-holed connection) observes no callback and therefore
// no cancellation. Abandoning the await keeps cancellation prompt;
// the orphaned operation finishes (or times out) on its own thread
// and its own cleanup still runs (#31).
if(_git.IsValidRepository(path))
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Pulling));
await _git.FetchAndPullAsync(path, request.Token, token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref updated);
}
else
{
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Cloning));
await _git.CloneAsync(
repo.CloneUrl, path, request.Token,
pct => progress?.Report(new RepoProgress(repo.Name, SyncStatus.Cloning, Percent: pct)),
token).WaitAsync(token).ConfigureAwait(false);
Interlocked.Increment(ref cloned);
}
pending.TryRemove(repo.Name, out _);
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Done));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
Interlocked.Increment(ref canceled);
pending.TryRemove(repo.Name, out _);
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Canceled));
throw; // stop the loop; unstarted repos are marked Canceled below
}
catch (Exception ex)
{
Interlocked.Increment(ref failed);
pending.TryRemove(repo.Name, out _);
// Windows-invalid path failures carry the offending paths so
// consumers can offer recovery (rename/skip) without re-syncing.
var invalidPaths = (ex as InvalidRepositoryPathsException)?.Paths;
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Failed, ex.Message, InvalidPaths: invalidPaths));
}
}).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
foreach (var repo in pending.Values)
{
canceled++;
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Canceled));
pending.TryRemove(repo.Name, out _);
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Done));
}
}
catch(OperationCanceledException) when(token.IsCancellationRequested)
{
Interlocked.Increment(ref canceled);
pending.TryRemove(repo.Name, out _);
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Canceled));
throw; // stop the loop; unstarted repos are marked Canceled below
}
catch(Exception ex)
{
Interlocked.Increment(ref failed);
pending.TryRemove(repo.Name, out _);
// Windows-invalid path failures carry the offending paths so
// consumers can offer recovery (rename/skip) without re-syncing.
IReadOnlyList<InvalidPathInfo>? invalidPaths = (ex as InvalidRepositoryPathsException)?.Paths;
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Failed, ex.Message, InvalidPaths: invalidPaths));
}
}).ConfigureAwait(false);
}
catch(OperationCanceledException)
{
foreach(RepoDescriptor repo in pending.Values)
{
canceled++;
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Canceled));
}
}
return new SyncSummary(repos.Count, cloned, updated, failed, canceled, cancellationToken.IsCancellationRequested);
}
return new SyncSummary(repos.Count, cloned, updated, failed, canceled, cancellationToken.IsCancellationRequested);
}
}
+6
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// A user-approved plan for materializing a repository whose tree contains
/// Windows-invalid paths: rename some paths, omit others. Applied by
+10
View File
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>Outcome of one external process: exit code plus captured output.</summary>
public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError);
+85
View File
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
namespace gclo.Engine;
/// <summary>
/// The real <see cref="IProcessRunner"/>. Output is read as bytes and decoded as UTF-16
/// when it carries NULs (wsl.exe's own messages, e.g. "no distribution installed", are
/// UTF-16) and as UTF-8 otherwise (everything a Linux process prints).
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Spawns real processes; WslCloner is covered through a fake runner.")]
public sealed class ProcessRunner : IProcessRunner
{
/// <inheritdoc />
public async Task<ProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string>? environment,
string? standardInput,
CancellationToken cancellationToken)
{
var start = new ProcessStartInfo(fileName)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach(string argument in arguments)
{
start.ArgumentList.Add(argument);
}
if(environment is not null)
{
foreach(KeyValuePair<string, string> pair in environment)
{
start.Environment[pair.Key] = pair.Value;
}
}
using var process = new Process { StartInfo = start };
process.Start();
using var stdout = new MemoryStream();
using var stderr = new MemoryStream();
Task copyOut = process.StandardOutput.BaseStream.CopyToAsync(stdout, cancellationToken);
Task copyErr = process.StandardError.BaseStream.CopyToAsync(stderr, cancellationToken);
if(standardInput is not null)
{
await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken).ConfigureAwait(false);
}
process.StandardInput.Close();
try
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
await Task.WhenAll(copyOut, copyErr).ConfigureAwait(false);
}
catch(OperationCanceledException)
{
try
{ process.Kill(entireProcessTree: true); }
catch(InvalidOperationException) { /* already exited */ }
throw;
}
return new ProcessResult(process.ExitCode, Decode(stdout.ToArray()), Decode(stderr.ToArray()));
}
private static string Decode(byte[] bytes)
=> bytes.Length >= 2 && Array.IndexOf(bytes, (byte)0) >= 0
? Encoding.Unicode.GetString(bytes).Replace("\0", "")
: Encoding.UTF8.GetString(bytes);
}
+6
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>A repository discovered in the GitHub organization.</summary>
/// <param name="Name">Repository name (unique within the org; used as the local folder name).</param>
/// <param name="CloneUrl">HTTPS clone URL.</param>
+6
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>A progress snapshot for one repository.</summary>
/// <param name="RepoName">Repository name.</param>
/// <param name="Status">Current lifecycle state.</param>
+88 -78
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine;
/// <summary>
/// Maps a repository name onto its folder under a sync target root, and refuses any
/// name that is not exactly one safe path segment. <see cref="OrgSyncEngine"/> runs
@@ -10,95 +16,99 @@ namespace gclo.Engine;
/// </summary>
public static class RepositoryPathResolver
{
private static readonly char[] SeparatorChars = ['/', '\\'];
private static readonly char[] s_separatorChars = ['/', '\\'];
private static readonly char[] InvalidNameChars = Path.GetInvalidFileNameChars();
private static readonly char[] s_invalidNameChars = Path.GetInvalidFileNameChars();
// Windows paths compare case-insensitively; everywhere else the file system is
// (by default) case-sensitive and so is the containment check.
private static readonly StringComparison PathComparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
// Windows paths compare case-insensitively; everywhere else the file system is
// (by default) case-sensitive and so is the containment check.
private static readonly StringComparison s_pathComparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
/// <summary>
/// Returns the normalized absolute folder for <paramref name="repoName"/> under
/// <paramref name="targetRoot"/>, after proving the name is a single safe segment
/// and the resolved folder is strictly inside the root.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="targetRoot"/> is blank, or <paramref name="repoName"/> is not a
/// valid leaf folder name, or the resolved path escapes the root.
/// </exception>
public static string Resolve(string targetRoot, string repoName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetRoot);
if (!TryValidateName(repoName, out string? reason))
{
throw new ArgumentException(
$"Repository name '{repoName}' cannot be used as a folder name: {reason}.", nameof(repoName));
}
// Normalize both sides the same way, then require the candidate to sit one
// level below the root. GetFullPath collapses '.'/'..' segments and, on
// Windows, trims trailing dots and spaces — so a name that survived the
// textual checks but still lands on or outside the root is caught here.
string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetRoot)) + Path.DirectorySeparatorChar;
string candidate = Path.GetFullPath(Path.Combine(root, repoName));
/// <summary>
/// Returns the normalized absolute folder for <paramref name="repoName"/> under
/// <paramref name="targetRoot"/>, after proving the name is a single safe segment
/// and the resolved folder is strictly inside the root.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="targetRoot"/> is blank, or <paramref name="repoName"/> is not a
/// valid leaf folder name, or the resolved path escapes the root.
/// </exception>
public static string Resolve(string targetRoot, string repoName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetRoot);
if (!candidate.StartsWith(root, PathComparison) || candidate.Length <= root.Length)
{
throw new ArgumentException(
$"Repository name '{repoName}' resolves to '{candidate}', which is not inside the target root '{root}'.",
nameof(repoName));
}
if(!TryValidateName(repoName, out string? reason))
{
throw new ArgumentException(
$"Repository name '{repoName}' cannot be used as a folder name: {reason}.", nameof(repoName));
}
return candidate;
}
// Normalize both sides the same way, then require the candidate to sit one
// level below the root. GetFullPath collapses '.'/'..' segments and, on
// Windows, trims trailing dots and spaces — so a name that survived the
// textual checks but still lands on or outside the root is caught here.
string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetRoot)) + Path.DirectorySeparatorChar;
string candidate = Path.GetFullPath(Path.Combine(root, repoName));
/// <summary>
/// True when <paramref name="repoName"/> is a single, safe folder-name segment:
/// not blank, not <c>.</c> or <c>..</c>, no directory separators (so never rooted
/// or UNC), no drive or stream colon, and no characters a file system rejects in a name.
/// </summary>
/// <param name="repoName">The candidate name.</param>
/// <param name="reason">Why the name was rejected; null when it is valid.</param>
public static bool TryValidateName(string? repoName, out string? reason)
{
if (string.IsNullOrWhiteSpace(repoName))
{
reason = "it is empty";
return false;
}
if(!candidate.StartsWith(root, s_pathComparison) || candidate.Length <= root.Length)
{
throw new ArgumentException(
$"Repository name '{repoName}' resolves to '{candidate}', which is not inside the target root '{root}'.",
nameof(repoName));
}
if (repoName is "." or "..")
{
reason = "it refers to the current or parent directory";
return false;
}
return candidate;
}
if (repoName.IndexOfAny(SeparatorChars) >= 0)
{
reason = "it contains a directory separator";
return false;
}
// ':' is a drive or alternate-data-stream delimiter on Windows ('C:' and
// 'C:repo' are drive-relative paths). Reject it on every platform so a name's
// validity does not depend on where it is checked. With separators and the
// colon gone, no remaining string is rooted on any supported platform.
if (repoName.Contains(':'))
{
reason = "it contains ':'";
return false;
}
if (repoName.IndexOfAny(InvalidNameChars) >= 0 || repoName.Any(char.IsControl))
{
reason = "it contains characters that are not allowed in a folder name";
return false;
}
/// <summary>
/// True when <paramref name="repoName"/> is a single, safe folder-name segment:
/// not blank, not <c>.</c> or <c>..</c>, no directory separators (so never rooted
/// or UNC), no drive or stream colon, and no characters a file system rejects in a name.
/// </summary>
/// <param name="repoName">The candidate name.</param>
/// <param name="reason">Why the name was rejected; null when it is valid.</param>
public static bool TryValidateName(string? repoName, out string? reason)
{
if(string.IsNullOrWhiteSpace(repoName))
{
reason = "it is empty";
return false;
}
reason = null;
return true;
}
if(repoName is "." or "..")
{
reason = "it refers to the current or parent directory";
return false;
}
if(repoName.IndexOfAny(s_separatorChars) >= 0)
{
reason = "it contains a directory separator";
return false;
}
// ':' is a drive or alternate-data-stream delimiter on Windows ('C:' and
// 'C:repo' are drive-relative paths). Reject it on every platform so a name's
// validity does not depend on where it is checked. With separators and the
// colon gone, no remaining string is rooted on any supported platform.
if(repoName.Contains(':'))
{
reason = "it contains ':'";
return false;
}
if(repoName.IndexOfAny(s_invalidNameChars) >= 0 || repoName.Any(char.IsControl))
{
reason = "it contains characters that are not allowed in a folder name";
return false;
}
reason = null;
return true;
}
}
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more