style: adopt the Kingsrook C# layout and the standard .NET quality gate #70
No files matched your search
+221
-25
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => "";
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
+563
-464
File diff suppressed because it is too large.
Load diff
+153
-123
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<id>').
|
||||
/// </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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:<id>').
|
||||
/// </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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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 <value>' 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
@@ -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": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 => "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 '<org>-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 '<org>-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-<name>' 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-<name>' 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);
|
||||
}
|
||||
}
|
||||
@@ -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!));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 "+<metadata>" 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 "+<metadata>" 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})";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/<organization>/<repository></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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
+790
-737
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user