diff --git a/.editorconfig b/.editorconfig index 42cd04b..48779bc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes index b8c94f8..141fdb0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/scripts/Update-NuGetPackages.ps1 b/.github/scripts/Update-NuGetPackages.ps1 index cf86ab1..7661f64 100644 --- a/.github/scripts/Update-NuGetPackages.ps1 +++ b/.github/scripts/Update-NuGetPackages.ps1 @@ -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=` 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=` 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 = '( $($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" } diff --git a/.github/scripts/Update-PackageManifests.ps1 b/.github/scripts/Update-PackageManifests.ps1 index 8aacc7d..c6016c6 100644 --- a/.github/scripts/Update-PackageManifests.ps1 +++ b/.github/scripts/Update-PackageManifests.ps1 @@ -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" $nuspec = $nuspec -replace '[^<]+', "https://github.com/KofTwentyTwo/gclo/releases/tag/v$Version" -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" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f670a6c..e59cc00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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.' diff --git a/Directory.Build.props b/Directory.Build.props index 0c05c02..2a58e4c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,20 +1,53 @@ - + + enable + enable + latest + + true + true latest-recommended true + + true + + true - + true + true + true + snupkg + + + true + all + low true + true + + + true + true + + + + + $(NoWarn);CS1591 + + + + + + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gclo.Cli.Tests/AccountsCommandTests.cs b/gclo.Cli.Tests/AccountsCommandTests.cs index f3a95ca..56a3010 100644 --- a/gclo.Cli.Tests/AccountsCommandTests.cs +++ b/gclo.Cli.Tests/AccountsCommandTests.cs @@ -1,353 +1,432 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; using gclo.ViewModels; + namespace gclo.Cli.Tests; + /// Covers 'gclo accounts': parsing, empty/populated listings, and JSON output. 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 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(() => 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 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(() => AccountsCommand.Run(["frobnicate"], Open, new NullLog())); - // ---------------------------------------------------------------- add - private const string EnvVar = "GCLO_ACCOUNTS_TEST_TOKEN"; + [Fact] + public void UnknownOption_Throws() + => Assert.Throws(() => 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(() => 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( - () => 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( - () => 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(() => 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( + () => 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( + () => 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(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog())); - [Fact] - public void Edit_WithNothingToChange_IsAUsageError() - { - Seed("work", "acme", @"C:\a"); - var ex = Assert.Throws(() => AccountsCommand.Run(["edit", "--name", "work"], Open, new NullLog())); - Assert.Contains("Nothing to change", ex.Message); - } - [Fact] - public void Edit_MissingName_IsAUsageError() - => Assert.Throws(() => AccountsCommand.Run(["edit", "--org", "x"], Open, new NullLog())); - [Fact] - public void Edit_UnknownAccount_ListsTheOthers() - { - Seed("work", "acme", @"C:\a"); - var ex = Assert.Throws( - () => 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( - () => AccountsCommand.Run(["edit", "--name", "home", "--rename", "work"], Open, new NullLog())); - Assert.Contains("already exists", ex.Message); - } - [Fact] - public void Edit_UnknownOption_Throws() - => Assert.Throws(() => 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(() => AccountsCommand.Run(["remove", "--name", "ghost"], Open, new NullLog())); - Assert.Contains("No accounts exist yet", ex.Message); - } - [Fact] - public void Remove_MissingName_IsAUsageError() - => Assert.Throws(() => 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(() => 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 failing = - _ => throw new CliErrorException("Accounts require Windows credential storage; ..."); - Assert.Throws(() => AccountsCommand.Run( - ["add", "--name", "w", "--org", "o", "--target", "t", "--token-env", EnvVar], failing, new NullLog())); - Assert.Throws(() => AccountsCommand.Run(["edit", "--name", "w", "--org", "o"], failing, new NullLog())); - Assert.Throws(() => AccountsCommand.Run(["remove", "--name", "w"], failing, new NullLog())); - } - [Fact] - public void OpenThrows_Propagates() - { - Func failing = - _ => throw new CliErrorException("Accounts require Windows credential storage; ..."); + [Fact] + public void Edit_WithNothingToChange_IsAUsageError() + { + Seed("work", "acme", @"C:\a"); + CliUsageException ex = Assert.Throws(() => AccountsCommand.Run(["edit", "--name", "work"], Open, new NullLog())); + Assert.Contains("Nothing to change", ex.Message); + } - Assert.Throws(() => AccountsCommand.Run([], failing, new NullLog())); - } + + + [Fact] + public void Edit_MissingName_IsAUsageError() + => Assert.Throws(() => AccountsCommand.Run(["edit", "--org", "x"], Open, new NullLog())); + + + + [Fact] + public void Edit_UnknownAccount_ListsTheOthers() + { + Seed("work", "acme", @"C:\a"); + CliErrorException ex = Assert.Throws( + () => 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( + () => AccountsCommand.Run(["edit", "--name", "home", "--rename", "work"], Open, new NullLog())); + Assert.Contains("already exists", ex.Message); + } + + + + [Fact] + public void Edit_UnknownOption_Throws() + => Assert.Throws(() => 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(() => AccountsCommand.Run(["remove", "--name", "ghost"], Open, new NullLog())); + Assert.Contains("No accounts exist yet", ex.Message); + } + + + + [Fact] + public void Remove_MissingName_IsAUsageError() + => Assert.Throws(() => AccountsCommand.Run(["remove"], Open, new NullLog())); + + + + [Fact] + public void Remove_UnknownOption_Throws() + => Assert.Throws(() => 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 failing = + _ => throw new CliErrorException("Accounts require Windows credential storage; ..."); + + Assert.Throws(() => AccountsCommand.Run( + ["add", "--name", "w", "--org", "o", "--target", "t", "--token-env", EnvVar], failing, new NullLog())); + Assert.Throws(() => AccountsCommand.Run(["edit", "--name", "w", "--org", "o"], failing, new NullLog())); + Assert.Throws(() => AccountsCommand.Run(["remove", "--name", "w"], failing, new NullLog())); + } + + + + [Fact] + public void OpenThrows_Propagates() + { + Func failing = + _ => throw new CliErrorException("Accounts require Windows credential storage; ..."); + + Assert.Throws(() => AccountsCommand.Run([], failing, new NullLog())); + } } diff --git a/gclo.Cli.Tests/AssemblyInfo.cs b/gclo.Cli.Tests/AssemblyInfo.cs index cf566e0..f759d73 100644 --- a/gclo.Cli.Tests/AssemblyInfo.cs +++ b/gclo.Cli.Tests/AssemblyInfo.cs @@ -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. diff --git a/gclo.Cli.Tests/CliTestSupport.cs b/gclo.Cli.Tests/CliTestSupport.cs index b666f5c..2736ef9 100644 --- a/gclo.Cli.Tests/CliTestSupport.cs +++ b/gclo.Cli.Tests/CliTestSupport.cs @@ -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; + /// Captures Console.Out and Console.Error for the duration of a block. 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); + } } + + /// Redirects Console.In to a fixed string for the duration of a block. 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); } + + /// An organization lister returning a fixed list or throwing a fixed exception. internal sealed class FakeOrgLister : IOrganizationLister { - public IReadOnlyList Result { get; set; } = []; - public Exception? Throw { get; set; } + public IReadOnlyList Result { get; set; } = []; - public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Throw is not null - ? Task.FromException>(Throw) - : Task.FromResult(Result); - } + public Exception? Throw { get; set; } + + + + public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Throw is not null + ? Task.FromException>(Throw) + : Task.FromResult(Result); + } } + + /// A repository lister returning a fixed list or throwing a fixed exception. internal sealed class FakeRepoLister : IRepositoryLister { - public IReadOnlyList Result { get; set; } = []; - public Exception? Throw { get; set; } + public IReadOnlyList Result { get; set; } = []; - public Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - return Throw is not null - ? Task.FromException>(Throw) - : Task.FromResult(Result); - } + public Exception? Throw { get; set; } + + + + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Throw is not null + ? Task.FromException>(Throw) + : Task.FromResult(Result); + } } + + /// A git client configured with delegates; records clone/pull/recovery calls. internal sealed class FakeGit : IGitClient { - private readonly ConcurrentQueue _clones = new(); - private readonly ConcurrentQueue _recoveries = new(); + private readonly ConcurrentQueue _clones = new(); - public Func IsValid { get; set; } = _ => false; - public Func?, CancellationToken, Task> OnClone { get; set; } - = (_, _, _, _, _) => Task.CompletedTask; - public Func OnPull { get; set; } = (_, _, _) => Task.CompletedTask; - public Func OnApplyRecovery { get; set; } = (_, _, _) => Task.CompletedTask; + private readonly ConcurrentQueue _recoveries = new(); - public IReadOnlyList ClonedRepoNames => _clones.Select(Path.GetFileName).ToArray()!; - public IReadOnlyList RecoveredRepoNames => _recoveries.Select(Path.GetFileName).ToArray()!; + public Func IsValid { get; set; } = _ => false; - public bool IsValidRepository(string path) => IsValid(path); + public Func?, CancellationToken, Task> OnClone { get; set; } + = (_, _, _, _, _) => Task.CompletedTask; - public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken) - { - _clones.Enqueue(path); - return OnClone(url, path, token, onProgress, cancellationToken); - } + public Func OnPull { get; set; } = (_, _, _) => Task.CompletedTask; - public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken) - => OnPull(path, token, cancellationToken); + public Func OnApplyRecovery { get; set; } = (_, _, _) => Task.CompletedTask; - public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) - { - _recoveries.Enqueue(path); - return OnApplyRecovery(path, recovery, cancellationToken); - } + + + public IReadOnlyList ClonedRepoNames => _clones.Select(Path.GetFileName).ToArray()!; + + + + public IReadOnlyList RecoveredRepoNames => _recoveries.Select(Path.GetFileName).ToArray()!; + + + + public bool IsValidRepository(string path) => IsValid(path); + + + + public Task CloneAsync(string url, string path, string token, Action? 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); + } } + + /// Discards every activity-log entry. 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 => ""; } diff --git a/gclo.Cli.Tests/OptionReaderTests.cs b/gclo.Cli.Tests/OptionReaderTests.cs index bbf16b0..43253b3 100644 --- a/gclo.Cli.Tests/OptionReaderTests.cs +++ b/gclo.Cli.Tests/OptionReaderTests.cs @@ -1,86 +1,108 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Cli.Tests; + /// Covers the shared argument reader: both value forms and the flag/value guards. 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(() => reader.RequireValue()); - } + Assert.False(reader.MoveNext()); + } - [Fact] - public void RequireValue_AtEnd_Throws() - { - var reader = new OptionReader(["--org"]); - reader.MoveNext(); - Assert.Throws(() => 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(() => reader.RequireValue()); - } + Assert.Equal("acme", reader.RequireValue()); + } - [Fact] - public void RejectValue_InlineValueOnAFlag_Throws() - { - var reader = new OptionReader(["--json=true"]); - reader.MoveNext(); - Assert.Throws(() => 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(() => reader.RequireValue()); + } + + + + [Fact] + public void RequireValue_AtEnd_Throws() + { + var reader = new OptionReader(["--org"]); + reader.MoveNext(); + + Assert.Throws(() => reader.RequireValue()); + } + + + + [Fact] + public void RequireValue_FollowedByAnotherOption_Throws() + { + var reader = new OptionReader(["--org", "--target"]); + reader.MoveNext(); + + Assert.Throws(() => reader.RequireValue()); + } + + + + [Fact] + public void RejectValue_InlineValueOnAFlag_Throws() + { + var reader = new OptionReader(["--json=true"]); + reader.MoveNext(); + + Assert.Throws(() => reader.RejectValue()); + } + + + + [Fact] + public void RejectValue_NoInlineValue_IsFine() + { + var reader = new OptionReader(["--json"]); + reader.MoveNext(); + + reader.RejectValue(); // must not throw + } } diff --git a/gclo.Cli.Tests/OrgsCommandTests.cs b/gclo.Cli.Tests/OrgsCommandTests.cs index 001bdac..f16b2b6 100644 --- a/gclo.Cli.Tests/OrgsCommandTests.cs +++ b/gclo.Cli.Tests/OrgsCommandTests.cs @@ -1,118 +1,147 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.Cli.Tests; + /// Covers 'gclo orgs': parsing, plain and JSON output, and error translation. public sealed class OrgsCommandTests { - private const string EnvVar = "GCLO_ORGS_TEST_TOKEN"; + private const string EnvVar = "GCLO_ORGS_TEST_TOKEN"; - private static Task 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 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(() => 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(() => 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(() => 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(() => 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(() => 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( - () => 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(() => 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(() => 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(() => 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(() => 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(() => 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( + () => OrgsCommand.RunAsync(["--token-env", EnvVar], lister, new NullLog(), cts.Token)); + } } diff --git a/gclo.Cli.Tests/PackagingManifestTests.cs b/gclo.Cli.Tests/PackagingManifestTests.cs index ba52a1b..64d2cc9 100644 --- a/gclo.Cli.Tests/PackagingManifestTests.cs +++ b/gclo.Cli.Tests/PackagingManifestTests.cs @@ -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; + /// /// 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; /// 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*'(?[^']+)'", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)).Groups["value"].Value; + string hash = Regex.Match(install, @"checksum64\s*=\s*'(?[^']+)'", 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); + } } diff --git a/gclo.Cli.Tests/ProgressPrinterTests.cs b/gclo.Cli.Tests/ProgressPrinterTests.cs index 283871a..d95aa2f 100644 --- a/gclo.Cli.Tests/ProgressPrinterTests.cs +++ b/gclo.Cli.Tests/ProgressPrinterTests.cs @@ -1,100 +1,121 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.Cli.Tests; + /// /// Covers : transition deduplication, which stream each /// output mode writes to, and the failure/sanitized records the summary is built from. /// 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(); } diff --git a/gclo.Cli.Tests/RepoFilterSpecTests.cs b/gclo.Cli.Tests/RepoFilterSpecTests.cs index 628a599..0087850 100644 --- a/gclo.Cli.Tests/RepoFilterSpecTests.cs +++ b/gclo.Cli.Tests/RepoFilterSpecTests.cs @@ -1,76 +1,93 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.Cli.Tests; + /// Pins the '--include' / '--exclude' / '--skip-archived' selection rules. 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(() => spec.Include(glob)); - Assert.Contains("--include expects", ex.Message); - Assert.Throws(() => 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(() => spec.Include(glob)); + Assert.Contains("--include expects", ex.Message); + Assert.Throws(() => spec.Exclude(glob)); + } } diff --git a/gclo.Cli.Tests/ReposCommandTests.cs b/gclo.Cli.Tests/ReposCommandTests.cs index 648a831..5a5f2e2 100644 --- a/gclo.Cli.Tests/ReposCommandTests.cs +++ b/gclo.Cli.Tests/ReposCommandTests.cs @@ -1,136 +1,169 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.Cli.Tests; + /// Covers 'gclo repos': parsing, filters, plain and JSON output, and error translation. public sealed class ReposCommandTests { - private const string EnvVar = "GCLO_REPOS_TEST_TOKEN"; + private const string EnvVar = "GCLO_REPOS_TEST_TOKEN"; - private static Task 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 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(() => Run(new FakeRepoLister())); - [Fact] - public async Task UnknownOption_Throws() - => await Assert.ThrowsAsync(() => 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(() => Run(new FakeRepoLister())); - var ex = await Assert.ThrowsAsync(() => 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(() => Run(new FakeRepoLister(), "--org", "acme")); - } - finally - { - Environment.SetEnvironmentVariable(TokenOptions.DefaultVariable, original); - } - } + [Fact] + public async Task UnknownOption_Throws() + => await Assert.ThrowsAsync(() => 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( - () => 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(() => 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(() => 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( + () => ReposCommand.RunAsync(["--org", "acme", "--token-env", EnvVar], new FakeRepoLister(), new NullLog(), cts.Token)); + } } diff --git a/gclo.Cli.Tests/SyncCommandTests.cs b/gclo.Cli.Tests/SyncCommandTests.cs index e134a51..60539b6 100644 --- a/gclo.Cli.Tests/SyncCommandTests.cs +++ b/gclo.Cli.Tests/SyncCommandTests.cs @@ -1,8 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; using gclo.ViewModels; + namespace gclo.Cli.Tests; + /// /// Covers 'gclo sync' end to end over fake collaborators: argument and account /// resolution, token resolution, the sync run and its exit codes, JSON/quiet @@ -10,572 +17,664 @@ namespace gclo.Cli.Tests; /// public sealed class SyncCommandTests : IDisposable { - private const string EnvVar = "GCLO_SYNC_TEST_TOKEN"; + private const string EnvVar = "GCLO_SYNC_TEST_TOKEN"; - private readonly string _dir = - Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N")); - private readonly InMemoryVault _vault = new(); - private AccountsStore? _store; + private readonly string _dir = + Path.Combine(Path.GetTempPath(), "gclo-cli-tests", Guid.NewGuid().ToString("N")); - public SyncCommandTests() - { - Environment.SetEnvironmentVariable(EnvVar, "ghp_test"); - Directory.CreateDirectory(_dir); - } + private readonly InMemoryVault _vault = new(); - public void Dispose() - { - Environment.SetEnvironmentVariable(EnvVar, null); - try - { - Directory.Delete(_dir, recursive: true); - } - catch (IOException) - { - } - } + private AccountsStore? _store; - private string Target => Path.Combine(_dir, "target"); - private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog()); - private Func Open => _ => (Store, _vault); - private static RepoDescriptor Repo(string name) => new(name, $"https://x/{name}.git", "main", false); - private Task Run(FakeRepoLister lister, FakeGit git, params string[] args) - => SyncCommand.RunAsync(args, lister, git, Open, new NullLog(), CancellationToken.None); + public SyncCommandTests() + { + Environment.SetEnvironmentVariable(EnvVar, "ghp_test"); + Directory.CreateDirectory(_dir); + } - private Account Seed(string name, string org, bool orgSubfolder = false, int parallel = 8) - { - Store.Save( - new Account - { - Id = Guid.NewGuid(), - Name = name, - Organization = org, - TargetRoot = Target, - CreateOrgSubfolder = orgSubfolder, - MaxConcurrency = parallel, - }, - "tok-" + name); - return Store.FindByName(name)!; - } - // ---------------------------------------------------------------- parsing - [Fact] - public async Task Help_PrintsUsage() - { - using var console = new ConsoleCapture(); + public void Dispose() + { + Environment.SetEnvironmentVariable(EnvVar, null); + try + { + Directory.Delete(_dir, recursive: true); + } + catch(IOException) + { + } + } - int code = await Run(new FakeRepoLister(), new FakeGit(), "--help"); - Assert.Equal(0, code); - Assert.Contains("Usage: gclo sync", console.Out); - } - [Fact] - public async Task MissingOrg_Throws() - => await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--target", Target)); + private string Target => Path.Combine(_dir, "target"); - [Fact] - public async Task MissingTarget_Throws() - => await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme")); - [Theory] - [InlineData("0")] - [InlineData("65")] - [InlineData("abc")] - public async Task ParallelOutsideTheSupportedRange_IsRejected(string value) - { - var ex = await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value)); - Assert.Contains("between 1 and 64", ex.Message); - } - [Fact] - public async Task JsonAndJsonLines_AreMutuallyExclusive() - { - var ex = await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--json", "--json-lines")); - Assert.Contains("not both", ex.Message); - } + private AccountsStore Store => _store ??= new AccountsStore(_vault, _dir, new NullLog()); - [Theory] - [InlineData("zero")] - [InlineData("0")] - [InlineData("-3")] - public async Task BadParallel_Throws(string value) - => await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value)); - [Fact] - public async Task UnknownOption_Throws() - => await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--nope")); - // ---------------------------------------------------------------- sync runs + private Func Open => _ => (Store, _vault); - [Fact] - public async Task AllRepositoriesClone_ReturnsZero_AndPrintsSummary() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] }; - var git = new FakeGit(); // clone succeeds by default - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); - Assert.Equal(0, code); - Assert.Equal(["a", "b"], git.ClonedRepoNames.OrderBy(n => n)); - Assert.Contains("Finished:", console.Out); - Assert.Contains("2 cloned", console.Out); - } + private static RepoDescriptor Repo(string name) => new(name, $"https://x/{name}.git", "main", false); - [Fact] - public async Task SomeRepositoriesFail_ReturnsOne() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] }; - var git = new FakeGit - { - OnClone = (_, path, _, _, _) => Path.GetFileName(path) == "b" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask, - }; - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); - Assert.Equal(1, code); - Assert.Contains("1 failed", console.Out); - Assert.Contains("b Failed boom", console.Error); - } + private Task Run(FakeRepoLister lister, FakeGit git, params string[] args) + => SyncCommand.RunAsync(args, lister, git, Open, new NullLog(), CancellationToken.None); - [Fact] - public async Task Quiet_SuppressesTransitionsButKeepsSummaryAndFailures() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--quiet"); - Assert.Equal(0, code); - Assert.DoesNotContain("a Cloning", console.Out); - Assert.Contains("Finished:", console.Out); - } + private Account Seed(string name, string org, bool orgSubfolder = false, int parallel = 8) + { + Store.Save( + new Account + { + Id = Guid.NewGuid(), + Name = name, + Organization = org, + TargetRoot = Target, + CreateOrgSubfolder = orgSubfolder, + MaxConcurrency = parallel, + }, + "tok-" + name); + return Store.FindByName(name)!; + } - [Fact] - public async Task Json_PrintsMachineReadableSummary() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json"); - Assert.Equal(0, code); - Assert.Contains("\"total\":1", console.Out); - Assert.Contains("\"cloned\":1", console.Out); - Assert.Contains("\"failures\":[]", console.Out); - } + // ---------------------------------------------------------------- parsing - [Fact] - public async Task ValidParallel_IsAccepted() - { - var lister = new FakeRepoLister { Result = [Repo("a")] }; + [Fact] + public async Task Help_PrintsUsage() + { + using var console = new ConsoleCapture(); - int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--parallel", "4"); + int code = await Run(new FakeRepoLister(), new FakeGit(), "--help"); - Assert.Equal(0, code); - } + Assert.Equal(0, code); + Assert.Contains("Usage: gclo sync", console.Out); + } - [Fact] - public async Task TargetFolderUnusable_BecomesCliError() - { - // A file sits where the target directory should be, so the engine's - // Directory.CreateDirectory throws — surfaced as a target-folder CliError. - string target = Path.Combine(_dir, "target-as-file"); - File.WriteAllText(target, "not a directory"); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var ex = await Assert.ThrowsAsync( - () => Run(lister, new FakeGit(), "--org", "acme", "--target", target, "--token-env", EnvVar)); - Assert.Contains("Cannot use target folder", ex.Message); - } - [Fact] - public async Task Account_RecordResultFails_WarnsButStillSucceeds() - { - using var console = new ConsoleCapture(); - Seed("work", "acme"); - // Block the store's atomic write: a directory where its temp file must go. - Directory.CreateDirectory(Path.Combine(_dir, "accounts.json.tmp")); - var lister = new FakeRepoLister { Result = [Repo("a")] }; + [Fact] + public async Task MissingOrg_Throws() + => await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--target", Target)); - int code = await Run(lister, new FakeGit(), "--account", "work"); - Assert.Equal(0, code); // the repos are on disk; a bookkeeping failure must not change that - Assert.Contains("could not record the sync result", console.Error); - } - [Fact] - public async Task SanitizePaths_CanceledBeforeRecovery_LeavesRepoFailed() - { - using var console = new ConsoleCapture(); - using var cts = new CancellationTokenSource(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var git = new FakeGit - { - OnClone = (_, _, _, _, _) => - { - cts.Cancel(); // cancellation lands during the run, before sanitization - return Task.FromException(InvalidPaths()); - }, - }; + [Fact] + public async Task MissingTarget_Throws() + => await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme")); - int code = await SyncCommand.RunAsync( - ["--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"], - lister, git, Open, new NullLog(), cts.Token); - Assert.Equal(1, code); - Assert.Empty(git.RecoveredRepoNames); // recovery was never attempted - } - [Fact] - public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy() - { - var unauthorized = new FakeRepoLister - { - Throw = new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401)."), - }; - var ex = await Assert.ThrowsAsync( - () => Run(unauthorized, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar)); - Assert.Equal(ExitCodes.Auth, ex.ExitCode); + [Theory] + [InlineData("0")] + [InlineData("65")] + [InlineData("abc")] + public async Task ParallelOutsideTheSupportedRange_IsRejected(string value) + { + CliUsageException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value)); + Assert.Contains("between 1 and 64", ex.Message); + } - var forbidden = new FakeRepoLister - { - Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "'acme' is an organization, but this token cannot see it."), - }; - ex = await Assert.ThrowsAsync( - () => Run(forbidden, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar)); - Assert.Equal(ExitCodes.Auth, ex.ExitCode); - var throttled = new FakeRepoLister - { - Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "rate limit exceeded"), - }; - ex = await Assert.ThrowsAsync( - () => Run(throttled, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar)); - Assert.Equal(ExitCodes.Transient, ex.ExitCode); - var missing = new FakeRepoLister - { - Throw = new GitHubAccessException(GitHubAccessKind.NotFound, "'ghost' was found neither as an organization nor as a user account (404)."), - }; - ex = await Assert.ThrowsAsync( - () => Run(missing, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar)); - Assert.Equal(ExitCodes.Fatal, ex.ExitCode); - } + [Fact] + public async Task JsonAndJsonLines_AreMutuallyExclusive() + { + CliUsageException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--json", "--json-lines")); + Assert.Contains("not both", ex.Message); + } - // ---------------------------------------------------------------- selection - [Fact] - public async Task IncludeExcludeAndSkipArchived_NarrowTheSelection() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister - { - Result = - [ - Repo("platform-api"), Repo("platform-web"), Repo("platform-legacy"), Repo("docs"), + + [Theory] + [InlineData("zero")] + [InlineData("0")] + [InlineData("-3")] + public async Task BadParallel_Throws(string value) + => await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--parallel", value)); + + + + [Fact] + public async Task UnknownOption_Throws() + => await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--nope")); + + + + // ---------------------------------------------------------------- sync runs + + [Fact] + public async Task AllRepositoriesClone_ReturnsZero_AndPrintsSummary() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] }; + var git = new FakeGit(); // clone succeeds by default + + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); + + Assert.Equal(0, code); + Assert.Equal(["a", "b"], git.ClonedRepoNames.OrderBy(n => n, StringComparer.Ordinal), StringComparer.Ordinal); + Assert.Contains("Finished:", console.Out); + Assert.Contains("2 cloned", console.Out); + } + + + + [Fact] + public async Task SomeRepositoriesFail_ReturnsOne() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] }; + var git = new FakeGit + { + OnClone = (_, path, _, _, _) => string.Equals(Path.GetFileName(path), "b" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask, + }; + + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); + + Assert.Equal(1, code); + Assert.Contains("1 failed", console.Out); + Assert.Contains("b Failed boom", console.Error); + } + + + + [Fact] + public async Task Quiet_SuppressesTransitionsButKeepsSummaryAndFailures() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--quiet"); + + Assert.Equal(0, code); + Assert.DoesNotContain("a Cloning", console.Out); + Assert.Contains("Finished:", console.Out); + } + + + + [Fact] + public async Task Json_PrintsMachineReadableSummary() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json"); + + Assert.Equal(0, code); + Assert.Contains("\"total\":1", console.Out); + Assert.Contains("\"cloned\":1", console.Out); + Assert.Contains("\"failures\":[]", console.Out); + } + + + + [Fact] + public async Task ValidParallel_IsAccepted() + { + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--parallel", "4"); + + Assert.Equal(0, code); + } + + + + [Fact] + public async Task TargetFolderUnusable_BecomesCliError() + { + // A file sits where the target directory should be, so the engine's + // Directory.CreateDirectory throws — surfaced as a target-folder CliError. + string target = Path.Combine(_dir, "target-as-file"); + File.WriteAllText(target, "not a directory"); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(lister, new FakeGit(), "--org", "acme", "--target", target, "--token-env", EnvVar)); + Assert.Contains("Cannot use target folder", ex.Message); + } + + + + [Fact] + public async Task Account_RecordResultFails_WarnsButStillSucceeds() + { + using var console = new ConsoleCapture(); + Seed("work", "acme"); + // Block the store's atomic write: a directory where its temp file must go. + Directory.CreateDirectory(Path.Combine(_dir, "accounts.json.tmp")); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--account", "work"); + + Assert.Equal(0, code); // the repos are on disk; a bookkeeping failure must not change that + Assert.Contains("could not record the sync result", console.Error); + } + + + + [Fact] + public async Task SanitizePaths_CanceledBeforeRecovery_LeavesRepoFailed() + { + using var console = new ConsoleCapture(); + using var cts = new CancellationTokenSource(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + var git = new FakeGit + { + OnClone = (_, _, _, _, _) => + { + cts.Cancel(); // cancellation lands during the run, before sanitization + return Task.FromException(InvalidPaths()); + }, + }; + + int code = await SyncCommand.RunAsync( + ["--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"], + lister, git, Open, new NullLog(), cts.Token); + + Assert.Equal(1, code); + Assert.Empty(git.RecoveredRepoNames); // recovery was never attempted + } + + + + [Fact] + public async Task ListerAccessRefusal_MapsToTheExitCodeTaxonomy() + { + var unauthorized = new FakeRepoLister + { + Throw = new GitHubAccessException(GitHubAccessKind.Unauthorized, "GitHub rejected the token (401)."), + }; + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(unauthorized, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar)); + Assert.Equal(ExitCodes.Auth, ex.ExitCode); + + var forbidden = new FakeRepoLister + { + Throw = new GitHubAccessException(GitHubAccessKind.Forbidden, "'acme' is an organization, but this token cannot see it."), + }; + ex = await Assert.ThrowsAsync( + () => Run(forbidden, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar)); + Assert.Equal(ExitCodes.Auth, ex.ExitCode); + + var throttled = new FakeRepoLister + { + Throw = new GitHubAccessException(GitHubAccessKind.RateLimited, "rate limit exceeded"), + }; + ex = await Assert.ThrowsAsync( + () => Run(throttled, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar)); + Assert.Equal(ExitCodes.Transient, ex.ExitCode); + + var missing = new FakeRepoLister + { + Throw = new GitHubAccessException(GitHubAccessKind.NotFound, "'ghost' was found neither as an organization nor as a user account (404)."), + }; + ex = await Assert.ThrowsAsync( + () => Run(missing, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar)); + Assert.Equal(ExitCodes.Fatal, ex.ExitCode); + } + + + + // ---------------------------------------------------------------- selection + + [Fact] + public async Task IncludeExcludeAndSkipArchived_NarrowTheSelection() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister + { + Result = + [ + Repo("platform-api"), Repo("platform-web"), Repo("platform-legacy"), Repo("docs"), new RepoDescriptor("platform-old", "https://x/platform-old.git", "main", IsArchived: true), ], - }; - var git = new FakeGit(); + }; + var git = new FakeGit(); - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, - "--include", "platform-*", "--exclude", "*-legacy", "--skip-archived"); + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, + "--include", "platform-*", "--exclude", "*-legacy", "--skip-archived"); - Assert.Equal(0, code); - Assert.Equal(["platform-api", "platform-web"], git.ClonedRepoNames.OrderBy(n => n)); - Assert.Contains("2 cloned", console.Out); - Assert.Contains("of 2.", console.Out); // the summary counts the selection, not the listing - } + Assert.Equal(0, code); + Assert.Equal(["platform-api", "platform-web"], git.ClonedRepoNames.OrderBy(n => n, StringComparer.Ordinal), StringComparer.Ordinal); + Assert.Contains("2 cloned", console.Out); + Assert.Contains("of 2.", console.Out); // the summary counts the selection, not the listing + } - [Fact] - public async Task EmptyIncludePattern_IsAUsageError() - { - var ex = await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--include", " ")); - Assert.Contains("--include expects", ex.Message); - } - [Fact] - public async Task DryRun_ListsActionsAndTouchesNothing() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("new-one"), Repo("existing")] }; - var git = new FakeGit { IsValid = path => Path.GetFileName(path) == "existing" }; - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run"); + [Fact] + public async Task EmptyIncludePattern_IsAUsageError() + { + CliUsageException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--org", "acme", "--target", Target, "--include", " ")); + Assert.Contains("--include expects", ex.Message); + } - Assert.Equal(0, code); - Assert.Empty(git.ClonedRepoNames); - Assert.Contains("new-one would clone", console.Out); - Assert.Contains("existing would update", console.Out); - Assert.Contains("Dry run: 1 would clone, 1 would update of 2.", console.Out); - } - [Fact] - public async Task DryRun_Json_PrintsAnArrayOfActions() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("new-one")] }; - int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run", "--json"); + [Fact] + public async Task DryRun_ListsActionsAndTouchesNothing() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("new-one"), Repo("existing")] }; + var git = new FakeGit { IsValid = path => string.Equals(Path.GetFileName(path), "existing", StringComparison.Ordinal) }; - Assert.Equal(0, code); - Assert.Equal("[{\"repo\":\"new-one\",\"action\":\"clone\"}]", console.Out.Trim()); - } + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run"); - // ---------------------------------------------------------------- machine-readable output + Assert.Equal(0, code); + Assert.Empty(git.ClonedRepoNames); + Assert.Contains("new-one would clone", console.Out); + Assert.Contains("existing would update", console.Out); + Assert.Contains("Dry run: 1 would clone, 1 would update of 2.", console.Out); + } - [Fact] - public async Task JsonLines_PrintsOneObjectPerTransition_ThenTheSummary() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] }; - var git = new FakeGit - { - OnClone = (_, path, _, _, _) => Path.GetFileName(path) == "b" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask, - }; - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json-lines", "--parallel", "1"); - Assert.Equal(1, code); - string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray(); - Assert.All(lines, l => Assert.StartsWith("{", l)); - Assert.Contains("{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Queued\",\"error\":null}", lines); - Assert.Contains("{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Done\",\"error\":null}", lines); - Assert.Contains("{\"type\":\"progress\",\"repo\":\"b\",\"status\":\"Failed\",\"error\":\"boom\"}", lines); - Assert.StartsWith("{\"type\":\"summary\",\"total\":2,\"cloned\":1,\"updated\":0,\"failed\":1", lines[^1]); - Assert.Equal("", console.Error); // failures live in the stream, not on stderr - } + [Fact] + public async Task DryRun_Json_PrintsAnArrayOfActions() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("new-one")] }; - [Fact] - public async Task Json_CarriesSanitizedRepos_AndFullInvalidPathLists_WithNoStderrNoise() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("fixable"), Repo("stuck")] }; - var git = new FakeGit - { - OnClone = (_, path, _, _, _) => Task.FromException(InvalidPaths()), - OnApplyRecovery = (path, _, _) => Path.GetFileName(path) == "stuck" - ? Task.FromException(new IOException("disk full")) - : Task.CompletedTask, - }; + int code = await Run(lister, new FakeGit(), "--org", "acme", "--target", Target, "--token-env", EnvVar, "--dry-run", "--json"); - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json", "--sanitize-paths", "--parallel", "1"); + Assert.Equal(0, code); + Assert.Equal("[{\"repo\":\"new-one\",\"action\":\"clone\"}]", console.Out.Trim()); + } - Assert.Equal(1, code); - Assert.Equal("", console.Error); // the 'Sanitized' note and path details stay out of stderr in JSON mode - string json = console.Out.Trim(); - Assert.StartsWith("{\"type\":\"summary\"", json); - Assert.Contains("\"sanitized\":[{\"repo\":\"fixable\",\"renamed\":1,\"skipped\":1,\"skippedPaths\":[\"dup\"]}]", json); - Assert.Contains("\"repo\":\"stuck\"", json); - Assert.Contains("path sanitization failed: disk full", json); - Assert.Contains("\"invalidPaths\":null", json); // a non-path failure has no list - } - [Fact] - public async Task Json_FailureFromInvalidPaths_CarriesEveryOffendingPath() - { - using var console = new ConsoleCapture(); - var many = Enumerable.Range(0, 12) - .Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt")) - .ToList(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)) }; - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json"); + // ---------------------------------------------------------------- machine-readable output - Assert.Equal(1, code); - Assert.Contains("\"path\":\"bad11:x.txt\",\"reason\":\"invalid on Windows\",\"suggestedName\":\"bad11_x.txt\"", console.Out); - } + [Fact] + public async Task JsonLines_PrintsOneObjectPerTransition_ThenTheSummary() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a"), Repo("b")] }; + var git = new FakeGit + { + OnClone = (_, path, _, _, _) => string.Equals(Path.GetFileName(path), "b" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask, + }; - [Fact] - public async Task ListerInvalidOperation_BecomesCliError() - { - var lister = new FakeRepoLister { Throw = new InvalidOperationException("org not found (404)") }; + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json-lines", "--parallel", "1"); - var ex = await Assert.ThrowsAsync( - () => Run(lister, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar)); - Assert.Contains("404", ex.Message); - } + Assert.Equal(1, code); + string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray(); + Assert.All(lines, l => Assert.StartsWith("{", l)); + Assert.Contains("{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Queued\",\"error\":null}", lines, StringComparer.Ordinal); + Assert.Contains("{\"type\":\"progress\",\"repo\":\"a\",\"status\":\"Done\",\"error\":null}", lines, StringComparer.Ordinal); + Assert.Contains("{\"type\":\"progress\",\"repo\":\"b\",\"status\":\"Failed\",\"error\":\"boom\"}", lines, StringComparer.Ordinal); + Assert.StartsWith("{\"type\":\"summary\",\"total\":2,\"cloned\":1,\"updated\":0,\"failed\":1", lines[^1]); + Assert.Equal("", console.Error); // failures live in the stream, not on stderr + } - [Fact] - public async Task Canceled_ReturnsOne() - { - var lister = new FakeRepoLister { Throw = new OperationCanceledException() }; - using var cts = new CancellationTokenSource(); - cts.Cancel(); - int code = await SyncCommand.RunAsync( - ["--org", "acme", "--target", Target, "--token-env", EnvVar], - lister, new FakeGit(), Open, new NullLog(), cts.Token); - Assert.Equal(1, code); - } + [Fact] + public async Task Json_CarriesSanitizedRepos_AndFullInvalidPathLists_WithNoStderrNoise() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("fixable"), Repo("stuck")] }; + var git = new FakeGit + { + OnClone = (_, path, _, _, _) => Task.FromException(InvalidPaths()), + OnApplyRecovery = (path, _, _) => string.Equals(Path.GetFileName(path), "stuck" +, StringComparison.Ordinal) ? Task.FromException(new IOException("disk full")) + : Task.CompletedTask, + }; - // ---------------------------------------------------------------- accounts + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json", "--sanitize-paths", "--parallel", "1"); - [Fact] - public async Task Account_SeedsOrgTargetAndParallel() - { - using var console = new ConsoleCapture(); - Seed("work", "acme", orgSubfolder: false, parallel: 3); - var lister = new FakeRepoLister { Result = [Repo("a")] }; + Assert.Equal(1, code); + Assert.Equal("", console.Error); // the 'Sanitized' note and path details stay out of stderr in JSON mode + string json = console.Out.Trim(); + Assert.StartsWith("{\"type\":\"summary\"", json); + Assert.Contains("\"sanitized\":[{\"repo\":\"fixable\",\"renamed\":1,\"skipped\":1,\"skippedPaths\":[\"dup\"]}]", json); + Assert.Contains("\"repo\":\"stuck\"", json); + Assert.Contains("path sanitization failed: disk full", json); + Assert.Contains("\"invalidPaths\":null", json); // a non-path failure has no list + } - int code = await Run(lister, new FakeGit(), "--account", "work"); - Assert.Equal(0, code); - Assert.Contains("Finished:", console.Out); - // The account's stored token was used (no --token option given). - Assert.NotNull(Store.FindByName("work")!.LastSyncSummary); - } - [Fact] - public async Task Account_WithOrgSubfolder_NestsTheTarget() - { - Seed("work", "acme", orgSubfolder: true); - var git = new FakeGit(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; + [Fact] + public async Task Json_FailureFromInvalidPaths_CarriesEveryOffendingPath() + { + using var console = new ConsoleCapture(); + var many = Enumerable.Range(0, 12) + .Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt")) + .ToList(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)) }; - int code = await Run(lister, git, "--account", "work"); + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--json"); - Assert.Equal(0, code); - // Target became \acme\a. - Assert.Contains("a", git.ClonedRepoNames); - Assert.True(Directory.Exists(Path.Combine(Target, "acme"))); - } + Assert.Equal(1, code); + Assert.Contains("\"path\":\"bad11:x.txt\",\"reason\":\"invalid on Windows\",\"suggestedName\":\"bad11_x.txt\"", console.Out); + } - [Fact] - public async Task UnknownAccount_NoAccounts_Throws() - { - var ex = await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost")); - Assert.Contains("No accounts exist yet", ex.Message); - } - [Fact] - public async Task UnknownAccount_WithOtherAccounts_ListsThem() - { - Seed("work", "acme"); - var ex = await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost")); - Assert.Contains("Available accounts: work", ex.Message); - } + [Fact] + public async Task ListerInvalidOperation_BecomesCliError() + { + var lister = new FakeRepoLister { Throw = new InvalidOperationException("org not found (404)") }; - [Fact] - public async Task Account_MissingVaultToken_Throws() - { - // Save metadata, then delete the vault entry so no token is retrievable. - Account account = Seed("work", "acme"); - _vault.Delete(account.Id); + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(lister, new FakeGit(), "--org", "ghost", "--target", Target, "--token-env", EnvVar)); + Assert.Contains("404", ex.Message); + } - var ex = await Assert.ThrowsAsync( - () => Run(new FakeRepoLister(), new FakeGit(), "--account", "work")); - Assert.Contains("no token", ex.Message); - } - [Fact] - public async Task Account_ExplicitTokenOption_OverridesVault() - { - Account account = Seed("work", "acme"); - _vault.Delete(account.Id); // vault empty, but --token-env supplies one - var lister = new FakeRepoLister { Result = [Repo("a")] }; - int code = await Run(lister, new FakeGit(), "--account", "work", "--token-env", EnvVar); + [Fact] + public async Task Canceled_ReturnsOne() + { + var lister = new FakeRepoLister { Throw = new OperationCanceledException() }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); - Assert.Equal(0, code); // did not fall through to the missing-vault-token error - } + int code = await SyncCommand.RunAsync( + ["--org", "acme", "--target", Target, "--token-env", EnvVar], + lister, new FakeGit(), Open, new NullLog(), cts.Token); - // ---------------------------------------------------------------- sanitize-paths + Assert.Equal(1, code); + } - private static InvalidRepositoryPathsException InvalidPaths() => new( - [ - new InvalidPathInfo("bad:name.txt", "contains a character that is invalid on Windows", "bad_name.txt"), + + + // ---------------------------------------------------------------- accounts + + [Fact] + public async Task Account_SeedsOrgTargetAndParallel() + { + using var console = new ConsoleCapture(); + Seed("work", "acme", orgSubfolder: false, parallel: 3); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--account", "work"); + + Assert.Equal(0, code); + Assert.Contains("Finished:", console.Out); + // The account's stored token was used (no --token option given). + Assert.NotNull(Store.FindByName("work")!.LastSyncSummary); + } + + + + [Fact] + public async Task Account_WithOrgSubfolder_NestsTheTarget() + { + Seed("work", "acme", orgSubfolder: true); + var git = new FakeGit(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, git, "--account", "work"); + + Assert.Equal(0, code); + // Target became \acme\a. + Assert.Contains("a", git.ClonedRepoNames, StringComparer.Ordinal); + Assert.True(Directory.Exists(Path.Combine(Target, "acme"))); + } + + + + [Fact] + public async Task UnknownAccount_NoAccounts_Throws() + { + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost")); + Assert.Contains("No accounts exist yet", ex.Message); + } + + + + [Fact] + public async Task UnknownAccount_WithOtherAccounts_ListsThem() + { + Seed("work", "acme"); + + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--account", "ghost")); + Assert.Contains("Available accounts: work", ex.Message); + } + + + + [Fact] + public async Task Account_MissingVaultToken_Throws() + { + // Save metadata, then delete the vault entry so no token is retrievable. + Account account = Seed("work", "acme"); + _vault.Delete(account.Id); + + CliErrorException ex = await Assert.ThrowsAsync( + () => Run(new FakeRepoLister(), new FakeGit(), "--account", "work")); + Assert.Contains("no token", ex.Message); + } + + + + [Fact] + public async Task Account_ExplicitTokenOption_OverridesVault() + { + Account account = Seed("work", "acme"); + _vault.Delete(account.Id); // vault empty, but --token-env supplies one + var lister = new FakeRepoLister { Result = [Repo("a")] }; + + int code = await Run(lister, new FakeGit(), "--account", "work", "--token-env", EnvVar); + + Assert.Equal(0, code); // did not fall through to the missing-vault-token error + } + + + + // ---------------------------------------------------------------- sanitize-paths + + private static InvalidRepositoryPathsException InvalidPaths() => new( + [ + new InvalidPathInfo("bad:name.txt", "contains a character that is invalid on Windows", "bad_name.txt"), new InvalidPathInfo("dup", "differs only by case from 'DUP'", null), // no suggestion => skipped ]); - [Fact] - public async Task SanitizePaths_RecoversAnInvalidPathRepo_AndCountsItCloned() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var git = new FakeGit - { - OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()), - OnApplyRecovery = (_, _, _) => Task.CompletedTask, // recovery succeeds - }; - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"); - Assert.Equal(0, code); - Assert.Contains("a", git.RecoveredRepoNames); - Assert.Contains("with sanitized paths", console.Out); - Assert.Contains("Sanitized", console.Error); - } + [Fact] + public async Task SanitizePaths_RecoversAnInvalidPathRepo_AndCountsItCloned() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + var git = new FakeGit + { + OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()), + OnApplyRecovery = (_, _, _) => Task.CompletedTask, // recovery succeeds + }; - [Fact] - public async Task SanitizePaths_RecoveryFails_LeavesRepoFailed() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var git = new FakeGit - { - OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()), - OnApplyRecovery = (_, _, _) => Task.FromException(new IOException("disk full")), - }; + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"); - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"); + Assert.Equal(0, code); + Assert.Contains("a", git.RecoveredRepoNames, StringComparer.Ordinal); + Assert.Contains("with sanitized paths", console.Out); + Assert.Contains("Sanitized", console.Error); + } - Assert.Equal(1, code); - Assert.Contains("path sanitization failed", console.Error); - } - [Fact] - public async Task InvalidPathFailure_WithoutSanitize_PrintsOffendingPaths() - { - using var console = new ConsoleCapture(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()) }; - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); + [Fact] + public async Task SanitizePaths_RecoveryFails_LeavesRepoFailed() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + var git = new FakeGit + { + OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()), + OnApplyRecovery = (_, _, _) => Task.FromException(new IOException("disk full")), + }; - Assert.Equal(1, code); - Assert.Contains("bad:name.txt", console.Error); - Assert.Contains("invalid on Windows", console.Error); - } + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths"); - [Fact] - public async Task InvalidPathFailure_ManyPaths_TruncatesTheListing() - { - using var console = new ConsoleCapture(); - var many = Enumerable.Range(0, 15) - .Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt")) - .ToList(); - var lister = new FakeRepoLister { Result = [Repo("a")] }; - var git = new FakeGit - { - OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)), - }; + Assert.Equal(1, code); + Assert.Contains("path sanitization failed", console.Error); + } - int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); - Assert.Equal(1, code); - Assert.Contains("and 5 more invalid paths", console.Error); - } + + [Fact] + public async Task InvalidPathFailure_WithoutSanitize_PrintsOffendingPaths() + { + using var console = new ConsoleCapture(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + var git = new FakeGit { OnClone = (_, _, _, _, _) => Task.FromException(InvalidPaths()) }; + + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); + + Assert.Equal(1, code); + Assert.Contains("bad:name.txt", console.Error); + Assert.Contains("invalid on Windows", console.Error); + } + + + + [Fact] + public async Task InvalidPathFailure_ManyPaths_TruncatesTheListing() + { + using var console = new ConsoleCapture(); + var many = Enumerable.Range(0, 15) + .Select(i => new InvalidPathInfo($"bad{i}:x.txt", "invalid on Windows", $"bad{i}_x.txt")) + .ToList(); + var lister = new FakeRepoLister { Result = [Repo("a")] }; + var git = new FakeGit + { + OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(many)), + }; + + int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar); + + Assert.Equal(1, code); + Assert.Contains("and 5 more invalid paths", console.Error); + } } diff --git a/gclo.Cli.Tests/TokenOptionsTests.cs b/gclo.Cli.Tests/TokenOptionsTests.cs index 4e4b8ce..7739880 100644 --- a/gclo.Cli.Tests/TokenOptionsTests.cs +++ b/gclo.Cli.Tests/TokenOptionsTests.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Cli.Tests; + /// /// 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; /// 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(() => 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( - () => 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(() => 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(() => 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(() => options.Resolve()); + } - var ex = Assert.Throws(() => 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(() => 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( + () => Parse("--token-env", "GCLO_DEFINITELY_UNSET_VAR").Resolve()); + Assert.Contains("not set", ex.Message); + } - Assert.Throws(() => 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(() => 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(() => 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(() => 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(() => 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(() => new TokenOptions().TryConsume(reader)); + } } diff --git a/gclo.Cli.Tests/gclo.Cli.Tests.csproj b/gclo.Cli.Tests/gclo.Cli.Tests.csproj index c82c882..d29bdc4 100644 --- a/gclo.Cli.Tests/gclo.Cli.Tests.csproj +++ b/gclo.Cli.Tests/gclo.Cli.Tests.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/gclo.Cli.Tests/packages.lock.json b/gclo.Cli.Tests/packages.lock.json index 93d102c..20370e1 100644 --- a/gclo.Cli.Tests/packages.lock.json +++ b/gclo.Cli.Tests/packages.lock.json @@ -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==" } } } diff --git a/gclo.Cli/AccountSummary.cs b/gclo.Cli/AccountSummary.cs new file mode 100644 index 0000000..23f58e9 --- /dev/null +++ b/gclo.Cli/AccountSummary.cs @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// +/// One saved account in the JSON listing of 'gclo accounts --json'. +/// is the UTC time of the last completed sync, or null +/// when the account has never synced; is the key of its +/// Credential Manager entry ('gclo:account:<id>'). +/// +internal sealed record AccountSummary( + string Id, + string Name, + string Description, + string Organization, + string TargetRoot, + bool CreateOrgSubfolder, + int MaxConcurrency, + DateTimeOffset? LastSync, + string? LastSyncSummary); diff --git a/gclo.Cli/AccountsCommand.cs b/gclo.Cli/AccountsCommand.cs index 19b36b7..9483d15 100644 --- a/gclo.Cli/AccountsCommand.cs +++ b/gclo.Cli/AccountsCommand.cs @@ -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; + /// /// '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; /// internal static class AccountsCommand { - private const string HelpText = """ + private const string HelpText = """ Usage: gclo accounts [list] [--json] gclo accounts add --name --org --target [options] gclo accounts edit --name [options] [token option] @@ -64,374 +71,394 @@ internal static class AccountsCommand or not running on Windows """; - /// Composition root: wires the real Credential Manager store and file log. - [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 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)."), - }; - } + /// Composition root: wires the real Credential Manager store and file log. + [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 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 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 accounts = store.GetAll(); - log.Info($"accounts finished: {accounts.Count} account(s) listed."); - if (json) - { - IReadOnlyList 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 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 accounts = store.GetAll(); + log.Info($"accounts finished: {accounts.Count} account(s) listed."); + + if(json) + { + IReadOnlyList 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 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 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 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 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 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(); - /// A duplicate name is the one validation the store itself enforces; everything else is fatal as is. - 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 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."); + } - /// - /// 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. - /// - /// The current OS is not Windows. - [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; + } + } - /// 'never', or the local time of the last completed sync at minute precision. - 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 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 + + /// A duplicate name is the one validation the store itself enforces; everything else is fatal as is. + 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 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}"); + } + + + + /// + /// 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. + /// + /// The current OS is not Windows. + [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); + } + + + + /// 'never', or the local time of the last completed sync at minute precision. + private static string FormatLastSync(DateTimeOffset? lastSyncUtc) + => lastSyncUtc is null + ? "never" + : lastSyncUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); } diff --git a/gclo.Cli/CliErrorException.cs b/gclo.Cli/CliErrorException.cs new file mode 100644 index 0000000..c3cd9cb --- /dev/null +++ b/gclo.Cli/CliErrorException.cs @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.Cli; + + +/// +/// 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 — 2 unless the failure is an auth (3) or +/// transient (4) refusal from GitHub. +/// +internal sealed class CliErrorException : Exception +{ + public CliErrorException(string message, Exception? inner = null, int exitCode = ExitCodes.Fatal) + : base(message, inner) + { + ExitCode = exitCode; + } + + + + /// The process exit code this error maps to. + public int ExitCode { get; } + + + + /// + /// Wraps an engine failure: the listers translate GitHub refusals into + /// , whose kind picks the exit code; any other + /// stays fatal (2). + /// + public static CliErrorException FromEngine(InvalidOperationException ex) + => new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal); +} diff --git a/gclo.Cli/CliJson.cs b/gclo.Cli/CliJson.cs deleted file mode 100644 index 7608af2..0000000 --- a/gclo.Cli/CliJson.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Text.Json.Serialization; - -namespace gclo.Cli; - -/// One Windows-invalid path behind a failure, as the validator reported it. -internal sealed record SyncInvalidPath(string Path, string Reason, string? SuggestedName); - -/// -/// One failed repository in the JSON summary. is present -/// (non-null) only when the failure was Windows path validation — the full list, not -/// the three-path excerpt the message carries. -/// -internal sealed record SyncFailure(string Repo, string Error, IReadOnlyList? InvalidPaths = null); - -/// -/// One repository that '--sanitize-paths' checked out with renames and skips: it -/// counts as cloned, but its working tree is incomplete by -/// paths, which a mirroring pipeline needs to know. -/// -internal sealed record SyncSanitized(string Repo, int Renamed, int Skipped, IReadOnlyList SkippedPaths); - -/// -/// The single-line JSON document printed by 'gclo sync --json', and the last line of -/// 'gclo sync --json-lines'. is always "summary" so a line reader -/// can tell it from progress lines. -/// -internal sealed record SyncJsonResult( - string Type, - int Total, - int Cloned, - int Updated, - int Failed, - int Canceled, - bool WasCanceled, - IReadOnlyList Failures, - IReadOnlyList Sanitized); - -/// One status transition in 'gclo sync --json-lines'; is always "progress". -internal sealed record SyncProgressLine(string Type, string Repo, string Status, string? Error); - -/// One repository of 'gclo sync --dry-run --json': what the run would do with it. -internal sealed record DryRunEntry(string Repo, string Action); - -/// One repository in the JSON listing of 'gclo repos --json'. -internal sealed record RepoSummary(string Name, string? DefaultBranch, bool Archived, string CloneUrl); - -/// -/// One saved account in the JSON listing of 'gclo accounts --json'. -/// is the UTC time of the last completed sync, or null -/// when the account has never synced; is the key of its -/// Credential Manager entry ('gclo:account:<id>'). -/// -internal sealed record AccountSummary( - string Id, - string Name, - string Description, - string Organization, - string TargetRoot, - bool CreateOrgSubfolder, - int MaxConcurrency, - DateTimeOffset? LastSync, - string? LastSyncSummary); - -/// -/// Source-generated System.Text.Json serialization: no runtime reflection, so the -/// project stays free of trim/AOT warnings. -/// -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(SyncJsonResult))] -[JsonSerializable(typeof(SyncProgressLine))] -[JsonSerializable(typeof(IReadOnlyList))] -[JsonSerializable(typeof(IReadOnlyList))] -[JsonSerializable(typeof(IReadOnlyList))] -[JsonSerializable(typeof(IReadOnlyList))] -internal sealed partial class CliJsonContext : JsonSerializerContext; diff --git a/gclo.Cli/CliJsonContext.cs b/gclo.Cli/CliJsonContext.cs new file mode 100644 index 0000000..df7d8c3 --- /dev/null +++ b/gclo.Cli/CliJsonContext.cs @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.Cli; + + +/// +/// Source-generated System.Text.Json serialization: no runtime reflection, so the +/// project stays free of trim/AOT warnings. +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(SyncJsonResult))] +[JsonSerializable(typeof(SyncProgressLine))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(IReadOnlyList))] +internal sealed partial class CliJsonContext : JsonSerializerContext; diff --git a/gclo.Cli/CliUsageException.cs b/gclo.Cli/CliUsageException.cs new file mode 100644 index 0000000..7a0fc20 --- /dev/null +++ b/gclo.Cli/CliUsageException.cs @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// +/// A command-line usage error. The message is printed to stderr followed by a +/// help hint, and the process exits with code 2. +/// +internal sealed class CliUsageException : Exception +{ + public CliUsageException(string message) : base(message) + { + } +} diff --git a/gclo.Cli/CommandLine.cs b/gclo.Cli/CommandLine.cs deleted file mode 100644 index 86a0d7f..0000000 --- a/gclo.Cli/CommandLine.cs +++ /dev/null @@ -1,163 +0,0 @@ -using gclo.Engine; - -namespace gclo.Cli; - -/// -/// 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. -/// -internal static class ExitCodes -{ - /// Every repository synced (or the listing completed). - public const int Success = 0; - - /// The run completed, but some repositories failed or it was canceled. - public const int Partial = 1; - - /// Fatal: bad arguments, missing token, unknown organization or account, unusable target. - public const int Fatal = 2; - - /// GitHub rejected the token or refused access (401/403): fix the token; never retry as is. - public const int Auth = 3; - - /// A rate limit or temporary throttle: retry later. - public const int Transient = 4; - - /// An unexpected exception inside gclo; details are in the activity log. - public const int Unexpected = 70; - - /// The exit code for an engine access refusal of the given kind. - public static int ForAccess(GitHubAccessKind kind) => kind switch - { - GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth, - GitHubAccessKind.RateLimited => Transient, - _ => Fatal, - }; -} - -/// -/// A command-line usage error. The message is printed to stderr followed by a -/// help hint, and the process exits with code 2. -/// -internal sealed class CliUsageException : Exception -{ - public CliUsageException(string message) : base(message) - { - } -} - -/// -/// 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 — 2 unless the failure is an auth (3) or -/// transient (4) refusal from GitHub. -/// -internal sealed class CliErrorException : Exception -{ - public CliErrorException(string message, Exception? inner = null, int exitCode = ExitCodes.Fatal) - : base(message, inner) - { - ExitCode = exitCode; - } - - /// The process exit code this error maps to. - public int ExitCode { get; } - - /// - /// Wraps an engine failure: the listers translate GitHub refusals into - /// , whose kind picks the exit code; any other - /// stays fatal (2). - /// - public static CliErrorException FromEngine(InvalidOperationException ex) - => new(ex.Message, ex, ex is GitHubAccessException access ? ExitCodes.ForAccess(access.Kind) : ExitCodes.Fatal); -} - -/// -/// Sequential reader over a command's arguments supporting both -/// '--name value' and '--name=value' forms. -/// -internal sealed class OptionReader -{ - private readonly string[] _args; - private int _index; - private string? _inlineValue; - - public OptionReader(string[] args) => _args = args; - - /// The current token, without any inline '=value' part. - 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; - } - - /// - /// The value for : 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). - /// - 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++]; - } - - /// Rejects '--name=value' written for a flag that takes no value. - public void RejectValue() - { - if (_inlineValue is not null) - { - throw new CliUsageException($"Option '{Current}' does not take a value."); - } - } - - /// - /// 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). - /// - 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; - } -} diff --git a/gclo.Cli/DryRunEntry.cs b/gclo.Cli/DryRunEntry.cs new file mode 100644 index 0000000..edc67d4 --- /dev/null +++ b/gclo.Cli/DryRunEntry.cs @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// One repository of 'gclo sync --dry-run --json': what the run would do with it. +internal sealed record DryRunEntry(string Repo, string Action); diff --git a/gclo.Cli/ExitCodes.cs b/gclo.Cli/ExitCodes.cs new file mode 100644 index 0000000..138343f --- /dev/null +++ b/gclo.Cli/ExitCodes.cs @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.Cli; + + +/// +/// 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. +/// +internal static class ExitCodes +{ + /// Every repository synced (or the listing completed). + public const int Success = 0; + + /// The run completed, but some repositories failed or it was canceled. + public const int Partial = 1; + + /// Fatal: bad arguments, missing token, unknown organization or account, unusable target. + public const int Fatal = 2; + + /// GitHub rejected the token or refused access (401/403): fix the token; never retry as is. + public const int Auth = 3; + + /// A rate limit or temporary throttle: retry later. + public const int Transient = 4; + + /// An unexpected exception inside gclo; details are in the activity log. + public const int Unexpected = 70; + + + + /// The exit code for an engine access refusal of the given kind. + public static int ForAccess(GitHubAccessKind kind) => kind switch + { + GitHubAccessKind.Unauthorized or GitHubAccessKind.Forbidden => Auth, + GitHubAccessKind.RateLimited => Transient, + _ => Fatal, + }; +} diff --git a/gclo.Cli/OptionReader.cs b/gclo.Cli/OptionReader.cs new file mode 100644 index 0000000..215a0a1 --- /dev/null +++ b/gclo.Cli/OptionReader.cs @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// +/// Sequential reader over a command's arguments supporting both +/// '--name value' and '--name=value' forms. +/// +internal sealed class OptionReader +{ + private readonly string[] _args; + + private int _index; + + private string? _inlineValue; + + + + public OptionReader(string[] args) => _args = args; + + + + /// The current token, without any inline '=value' part. + 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; + } + + + + /// + /// The value for : 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). + /// + 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++]; + } + + + + /// Rejects '--name=value' written for a flag that takes no value. + public void RejectValue() + { + if(_inlineValue is not null) + { + throw new CliUsageException($"Option '{Current}' does not take a value."); + } + } + + + + /// + /// 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). + /// + 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; + } +} diff --git a/gclo.Cli/OrgsCommand.cs b/gclo.Cli/OrgsCommand.cs index 0fd90e2..087be62 100644 --- a/gclo.Cli/OrgsCommand.cs +++ b/gclo.Cli/OrgsCommand.cs @@ -1,12 +1,19 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.Json; using gclo.Engine; + namespace gclo.Cli; + /// 'gclo orgs': list the account and organization logins a token can see. 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. """; - /// Composition root: wires the real GitHub lister and file log. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( - Justification = "Wires the real network lister and file log; delegates to the covered core.")] - public static Task RunAsync(string[] args, CancellationToken cancellationToken) - => RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(), cancellationToken); - internal static async Task 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)) + /// Composition root: wires the real GitHub lister and file log. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( + Justification = "Wires the real network lister and file log; delegates to the covered core.")] + public static Task RunAsync(string[] args, CancellationToken cancellationToken) + => RunAsync(args, new GitHubOrganizationLister(), new FileActivityLog(), cancellationToken); + + + + internal static async Task 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 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 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; + } + } } diff --git a/gclo.Cli/OutputMode.cs b/gclo.Cli/OutputMode.cs new file mode 100644 index 0000000..363329a --- /dev/null +++ b/gclo.Cli/OutputMode.cs @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// How 'gclo sync' reports progress and the result. +internal enum OutputMode +{ + /// Transitions on stdout, failures on stderr, a summary line at the end. + Text, + + /// Failures on stderr and the summary line only. + Quiet, + + /// Nothing until the end, then one JSON document. + Json, + + /// One JSON object per transition on stdout (NDJSON), then the summary object. + JsonLines, +} diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs index 855f140..4248207 100644 --- a/gclo.Cli/Program.cs +++ b/gclo.Cli/Program.cs @@ -1,8 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Diagnostics.CodeAnalysis; using gclo.Engine; + namespace gclo.Cli; + /// /// 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 Main(string[] args) - { - using var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => - { - if (!cts.IsCancellationRequested) + + + public static async Task 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); + } + } + + + + /// + /// 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. + /// + 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 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); - } - } - /// - /// 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. - /// - 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 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 ()" 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 ()" for local builds. + => BuildVersion.Describe(typeof(Program).Assembly); } diff --git a/gclo.Cli/ProgressPrinter.cs b/gclo.Cli/ProgressPrinter.cs index 18bc0f7..8b1c532 100644 --- a/gclo.Cli/ProgressPrinter.cs +++ b/gclo.Cli/ProgressPrinter.cs @@ -1,23 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.Json; using gclo.Engine; + namespace gclo.Cli; -/// How 'gclo sync' reports progress and the result. -internal enum OutputMode -{ - /// Transitions on stdout, failures on stderr, a summary line at the end. - Text, - - /// Failures on stderr and the summary line only. - Quiet, - - /// Nothing until the end, then one JSON document. - Json, - - /// One JSON object per transition on stdout (NDJSON), then the summary object. - JsonLines, -} /// /// Prints one line per repository status transition and records failures (and @@ -29,98 +20,116 @@ internal enum OutputMode /// internal sealed class ProgressPrinter : IProgress { - private readonly object _gate = new(); - private readonly Dictionary _lastStatus = new(StringComparer.OrdinalIgnoreCase); - private readonly List _failures = new(); - private readonly List _sanitized = new(); - private readonly OutputMode _mode; + private readonly Lock _gate = new(); - public ProgressPrinter(OutputMode mode) => _mode = mode; + private readonly Dictionary _lastStatus = new(StringComparer.OrdinalIgnoreCase); - /// Failed repositories in the order they failed. - public IReadOnlyList Failures - { - get - { - lock (_gate) + private readonly List _failures = new(); + + private readonly List _sanitized = new(); + + private readonly OutputMode _mode; + + + + public ProgressPrinter(OutputMode mode) => _mode = mode; + + + + /// Failed repositories in the order they failed. + public IReadOnlyList Failures + { + get + { + lock(_gate) + { + return _failures.ToArray(); + } + } + } + + + + /// Repositories checked out with sanitized paths, in order. + public IReadOnlyList Sanitized + { + get + { + lock(_gate) + { + return _sanitized.ToArray(); + } + } + } + + + + /// Records a '--sanitize-paths' outcome for the summary document. + 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; + } - /// Repositories checked out with sanitized paths, in order. - public IReadOnlyList 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 + } + } + } - /// Records a '--sanitize-paths' outcome for the summary document. - 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)); - /// The full offending-path list for the document; null when the failure was not path validation. - internal static IReadOnlyList? MapInvalidPaths(IReadOnlyList? paths) - => paths is { Count: > 0 } - ? paths.Select(p => new SyncInvalidPath(p.RepoPath, p.Reason, p.SuggestedName)).ToList() - : null; + /// The full offending-path list for the document; null when the failure was not path validation. + internal static IReadOnlyList? MapInvalidPaths(IReadOnlyList? paths) + => paths is { Count: > 0 } + ? paths.Select(p => new SyncInvalidPath(p.RepoPath, p.Reason, p.SuggestedName)).ToList() + : null; } diff --git a/gclo.Cli/RepoFilterSpec.cs b/gclo.Cli/RepoFilterSpec.cs index 246529a..e322cda 100644 --- a/gclo.Cli/RepoFilterSpec.cs +++ b/gclo.Cli/RepoFilterSpec.cs @@ -1,8 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.RegularExpressions; using gclo.Engine; + namespace gclo.Cli; + /// /// 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; /// internal sealed class RepoFilterSpec { - private readonly List _include = new(); - private readonly List _exclude = new(); + private readonly List _include = new(); - /// When true, archived repositories are dropped from the selection. - public bool SkipArchived { get; set; } + private readonly List _exclude = new(); - /// True when no filter was given: every listed repository is selected. - public bool IsEmpty => _include.Count == 0 && _exclude.Count == 0 && !SkipArchived; + /// When true, archived repositories are dropped from the selection. + public bool SkipArchived { get; set; } - /// Adds an '--include' glob. - public void Include(string glob) => _include.Add(GlobToRegex(glob, "--include")); - /// Adds an '--exclude' glob. - public void Exclude(string glob) => _exclude.Add(GlobToRegex(glob, "--exclude")); - /// Whether is part of the selection. - 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)); + /// True when no filter was given: every listed repository is selected. + public bool IsEmpty => _include.Count == 0 && _exclude.Count == 0 && !SkipArchived; - /// Human-readable description for the activity log. - public override string ToString() - { - var parts = new List(); - 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); - } + + /// Adds an '--include' glob. + public void Include(string glob) => _include.Add(GlobToRegex(glob, "--include")); + + + + /// Adds an '--exclude' glob. + public void Exclude(string glob) => _exclude.Add(GlobToRegex(glob, "--exclude")); + + + + /// Whether is part of the selection. + 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)); + + + + /// Human-readable description for the activity log. + public override string ToString() + { + var parts = new List(); + 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)); + } } diff --git a/gclo.Cli/RepoSummary.cs b/gclo.Cli/RepoSummary.cs new file mode 100644 index 0000000..b548b36 --- /dev/null +++ b/gclo.Cli/RepoSummary.cs @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// One repository in the JSON listing of 'gclo repos --json'. +internal sealed record RepoSummary(string Name, string? DefaultBranch, bool Archived, string CloneUrl); diff --git a/gclo.Cli/ReposCommand.cs b/gclo.Cli/ReposCommand.cs index 278e860..f480461 100644 --- a/gclo.Cli/ReposCommand.cs +++ b/gclo.Cli/ReposCommand.cs @@ -1,12 +1,19 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.Json; using gclo.Engine; + namespace gclo.Cli; + /// 'gclo repos': list the repositories a sync would see, with the same filters. internal static class ReposCommand { - private const string HelpText = """ + private const string HelpText = """ Usage: gclo repos --org [options] Lists the repositories of that 'gclo sync' would process — one per @@ -38,120 +45,124 @@ internal static class ReposCommand 4 rate limited; retry later """; - /// Composition root: wires the real GitHub lister and file log. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( - Justification = "Wires the real network lister and file log; delegates to the covered core.")] - public static Task RunAsync(string[] args, CancellationToken cancellationToken) - => RunAsync(args, new GitHubRepositoryLister(), new FileActivityLog(), cancellationToken); - internal static async Task 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'."); - } - } + /// Composition root: wires the real GitHub lister and file log. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( + Justification = "Wires the real network lister and file log; delegates to the covered core.")] + public static Task 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 RunAsync( + string[] args, IRepositoryLister lister, IActivityLog log, CancellationToken cancellationToken) + { + string? org = null; + bool json = false; + var filter = new RepoFilterSpec(); + var tokenOptions = new TokenOptions(); - IReadOnlyList 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 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 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 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; + } + } } diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index f402e52..1da124f 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -1,13 +1,20 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.Json; using gclo.Engine; using gclo.ViewModels; + namespace gclo.Cli; + /// 'gclo sync': clone or fast-forward every repository of an organization. internal static class SyncCommand { - private const string HelpText = """ + private const string HelpText = """ Usage: gclo sync --org --target [options] gclo sync --account [options] @@ -95,516 +102,613 @@ internal static class SyncCommand argument would leak. Use --token-env, --token-file, or --token-stdin. """; - /// Composition root: wires the real GitHub lister, LibGit2 client, and file log. - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( - Justification = "Wires the real network/native collaborators; delegates to the covered core.")] - public static Task RunAsync(string[] args, CancellationToken cancellationToken) - => RunAsync( - args, - new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(), - cancellationToken); - internal static async Task RunAsync( - string[] args, - IRepositoryLister lister, - IGitClient git, - Func openAccounts, - IActivityLog log, - CancellationToken cancellationToken) - { - string? org = null; - string? target = null; - string? accountName = null; - int? parallel = null; - bool json = false; - bool jsonLines = false; - bool quiet = false; - bool sanitizePaths = false; - bool dryRun = 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 "--target": - target = reader.RequireValue(); - break; - case "--account": - accountName = 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 "--dry-run": - reader.RejectValue(); - dryRun = true; - break; - case "--parallel": - parallel = OptionReader.ParseParallel(reader.RequireValue()); - break; - case "--json": - reader.RejectValue(); - json = true; - break; - case "--json-lines": - reader.RejectValue(); - jsonLines = true; - break; - case "--quiet": - reader.RejectValue(); - quiet = true; - break; - case "--sanitize-paths": - reader.RejectValue(); - sanitizePaths = true; - break; - default: - throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo sync'."); - } - } + /// Everything `gclo sync` reads from its command line, before any work starts. + private sealed class SyncOptions + { + public string? Org { get; set; } - if (json && jsonLines) - { - throw new CliUsageException("Use --json or --json-lines, not both."); - } - bool machineReadable = json || jsonLines; + public string? Target { get; set; } - try - { - // --account: seed org/target/parallel from the saved account. Explicit - // options given alongside win because ??= only fills what is still unset. - Account? account = null; - AccountsStore? store = null; - ITokenVault? vault = null; - if (accountName is not null) - { - (store, vault) = openAccounts(log); - account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store); - org ??= account.Organization; - target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot; - parallel ??= account.MaxConcurrency; - } + public string? AccountName { get; set; } - if (string.IsNullOrWhiteSpace(org)) - { - throw new CliUsageException("--org is required (or use --account)."); - } - if (string.IsNullOrWhiteSpace(target)) - { - throw new CliUsageException("--target is required (or use --account)."); - } - int effectiveParallel = parallel ?? AppSettings.DefaultConcurrency; - string organization = org.Trim(); - string targetRoot = target.Trim(); + public int? Parallel { get; set; } - log.Info($"sync started: org='{organization}', target='{targetRoot}', parallel={effectiveParallel}, " - + $"filters={filter}, dryRun={dryRun}, sanitizePaths={sanitizePaths}, json={json}, jsonLines={jsonLines}, quiet={quiet}" - + (account is null ? "" : $", account='{account.Name}'")); + public bool Json { get; set; } - string token = ResolveToken(tokenOptions, account, vault); + public bool JsonLines { get; set; } - // List first (the desktop app's list-select-sync shape): filters and - // --dry-run need the descriptors before anything runs, and the engine's - // subset overload takes exactly that list. - IReadOnlyList repositories; - bool canceledWhileListing = false; + public bool Quiet { get; set; } + + public bool SanitizePaths { get; set; } + + public bool DryRun { get; set; } + + public RepoFilterSpec Filter { get; } = new(); + + public TokenOptions TokenOptions { get; } = new(); + } + + + + /// + /// Parses the sync options; null when --help was asked for (the help text has + /// been printed). Usage mistakes throw . + /// + private static SyncOptions? ParseOptions(string[] args) + { + var options = new SyncOptions(); + var reader = new OptionReader(args); + while(reader.MoveNext()) + { + if(options.TokenOptions.TryConsume(reader)) + { + continue; + } + switch(reader.Current) + { + case "--help" or "-h": + Console.Out.WriteLine(HelpText); + return null; + case "--org": + options.Org = reader.RequireValue(); + break; + case "--target": + options.Target = reader.RequireValue(); + break; + case "--account": + options.AccountName = reader.RequireValue(); + break; + case "--include": + options.Filter.Include(reader.RequireValue()); + break; + case "--exclude": + options.Filter.Exclude(reader.RequireValue()); + break; + case "--skip-archived": + reader.RejectValue(); + options.Filter.SkipArchived = true; + break; + case "--dry-run": + reader.RejectValue(); + options.DryRun = true; + break; + case "--parallel": + options.Parallel = OptionReader.ParseParallel(reader.RequireValue()); + break; + case "--json": + reader.RejectValue(); + options.Json = true; + break; + case "--json-lines": + reader.RejectValue(); + options.JsonLines = true; + break; + case "--quiet": + reader.RejectValue(); + options.Quiet = true; + break; + case "--sanitize-paths": + reader.RejectValue(); + options.SanitizePaths = true; + break; + default: + throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo sync'."); + } + } + + if(options.Json && options.JsonLines) + { + throw new CliUsageException("Use --json or --json-lines, not both."); + } + return options; + } + + + + /// + /// Prints the run's summary (one JSON document, or the human line) and records it + /// on the saved account when the run came from one. + /// + private static void ReportSummary( + SyncSummary summary, + int sanitized, + ProgressPrinter printer, + bool machineReadable, + AccountsStore? store, + Account? account, + IActivityLog log) + { + string verb = summary.WasCanceled ? "Canceled" : "Finished"; + string clonedText = sanitized > 0 + ? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)" + : $"{summary.Cloned} cloned"; + string summaryLine = $"{verb}: {clonedText}, {summary.Updated} updated, " + + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; + + if(machineReadable) + { + var result = new SyncJsonResult( + "summary", summary.Total, summary.Cloned, summary.Updated, summary.Failed, + summary.Canceled, summary.WasCanceled, printer.Failures, printer.Sanitized); + Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); + } + else + { + Console.Out.WriteLine(summaryLine); + } + + if(account is not null) + { + // The run completed (exit 0 or 1): remember when and how it went, + // so 'gclo accounts' and the desktop app can show it. + TryRecordSyncResult(store!, account, summaryLine, log); + } + } + + + + /// Composition root: wires the real GitHub lister, LibGit2 client, and file log. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( + Justification = "Wires the real network/native collaborators; delegates to the covered core.")] + public static Task RunAsync(string[] args, CancellationToken cancellationToken) + => RunAsync( + args, + new GitHubRepositoryLister(), new LibGit2GitClient(), AccountsCommand.Open, new FileActivityLog(), + cancellationToken); + + + + internal static async Task RunAsync( + string[] args, + IRepositoryLister lister, + IGitClient git, + Func openAccounts, + IActivityLog log, + CancellationToken cancellationToken) + { + SyncOptions? options = ParseOptions(args); + if(options is null) + { + return ExitCodes.Success; // --help was printed + } + string? org = options.Org; + string? target = options.Target; + string? accountName = options.AccountName; + int? parallel = options.Parallel; + bool json = options.Json; + bool jsonLines = options.JsonLines; + bool quiet = options.Quiet; + bool sanitizePaths = options.SanitizePaths; + bool dryRun = options.DryRun; + RepoFilterSpec filter = options.Filter; + TokenOptions tokenOptions = options.TokenOptions; + bool machineReadable = json || jsonLines; + + try + { + // --account: seed org/target/parallel from the saved account. Explicit + // options given alongside win because ??= only fills what is still unset. + Account? account = null; + AccountsStore? store = null; + ITokenVault? vault = null; + if(accountName is not null) + { + (store, vault) = openAccounts(log); + account = store.FindByName(accountName) ?? throw UnknownAccount(accountName, store); + org ??= account.Organization; + target ??= account.CreateOrgSubfolder ? Path.Combine(account.TargetRoot, org) : account.TargetRoot; + parallel ??= account.MaxConcurrency; + } + + if(string.IsNullOrWhiteSpace(org)) + { + throw new CliUsageException("--org is required (or use --account)."); + } + if(string.IsNullOrWhiteSpace(target)) + { + throw new CliUsageException("--target is required (or use --account)."); + } + int effectiveParallel = parallel ?? AppSettings.DefaultConcurrency; + string organization = org.Trim(); + string targetRoot = target.Trim(); + + log.Info($"sync started: org='{organization}', target='{targetRoot}', parallel={effectiveParallel}, " + + $"filters={filter}, dryRun={dryRun}, sanitizePaths={sanitizePaths}, json={json}, jsonLines={jsonLines}, quiet={quiet}" + + (account is null ? "" : $", account='{account.Name}'")); + + string token = ResolveToken(tokenOptions, account, vault); + + // List first (the desktop app's list-select-sync shape): filters and + // --dry-run need the descriptors before anything runs, and the engine's + // subset overload takes exactly that list. + IReadOnlyList repositories; + bool canceledWhileListing = false; + try + { + repositories = await lister + .ListOrganizationRepositoriesAsync(organization, token, cancellationToken) + .ConfigureAwait(false); + } + catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested) + { + repositories = []; + canceledWhileListing = true; + } + catch(InvalidOperationException ex) + { + // The listers translate auth failures, rate limiting, and unknown + // organizations; the kind decides the exit code (2/3/4). + throw CliErrorException.FromEngine(ex); + } + + int listed = repositories.Count; + if(!filter.IsEmpty) + { + repositories = repositories.Where(filter.Matches).ToList(); + } + log.Info($"sync: {listed} repositories listed, {repositories.Count} selected."); + + if(dryRun) + { + return PrintDryRun(repositories, git, targetRoot, machineReadable, log); + } + + OutputMode mode = jsonLines ? OutputMode.JsonLines + : json ? OutputMode.Json + : quiet ? OutputMode.Quiet + : OutputMode.Text; + var printer = new ProgressPrinter(mode); + var engine = new OrgSyncEngine(lister, git); + var request = new SyncRequest(organization, token, targetRoot, effectiveParallel); + var progress = new SyncProgressHandler(printer, log, printDetails: !machineReadable, collectForSanitize: sanitizePaths); + + SyncSummary summary; + if(canceledWhileListing) + { + // Canceled while still listing repositories: nothing was processed yet. + summary = new SyncSummary(0, 0, 0, 0, 0, WasCanceled: true); + } + else + { try { - repositories = await lister - .ListOrganizationRepositoriesAsync(organization, token, cancellationToken) - .ConfigureAwait(false); + summary = await engine + .SyncAsync(request, repositories, progress, cancellationToken) + .ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + // The engine folds cancellation into the summary itself (remaining + // repositories are marked Canceled), so only disk failures surface here. + catch(Exception ex) when(ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { - repositories = []; - canceledWhileListing = true; + throw new CliErrorException($"Cannot use target folder '{targetRoot}': {ex.Message}", ex); } - catch (InvalidOperationException ex) + } + + // --sanitize-paths: repositories whose only problem was Windows-invalid + // paths were held back by the progress handler; retry each with an + // automatic recovery. A recovered repository counts as cloned. + int sanitized = 0; + foreach(PendingRecovery pending in progress.DrainPendingRecoveries()) + { + if(await TrySanitizeAsync(git, progress, printer, log, targetRoot, pending, announce: !machineReadable, cancellationToken) + .ConfigureAwait(false)) { - // The listers translate auth failures, rate limiting, and unknown - // organizations; the kind decides the exit code (2/3/4). - throw CliErrorException.FromEngine(ex); + sanitized++; } + } + if(sanitized > 0) + { + summary = summary with { Cloned = summary.Cloned + sanitized, Failed = summary.Failed - sanitized }; + } - int listed = repositories.Count; - if (!filter.IsEmpty) - { - repositories = repositories.Where(filter.Matches).ToList(); - } - log.Info($"sync: {listed} repositories listed, {repositories.Count} selected."); + log.Info( + $"sync finished: total={summary.Total}, cloned={summary.Cloned}, updated={summary.Updated}, " + + $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}" + + (sanitizePaths ? $", sanitizedPaths={sanitized}" : "")); - if (dryRun) - { - return PrintDryRun(repositories, git, targetRoot, machineReadable, log); - } + ReportSummary(summary, sanitized, printer, machineReadable, store, account, log); - OutputMode mode = jsonLines ? OutputMode.JsonLines - : json ? OutputMode.Json - : quiet ? OutputMode.Quiet - : OutputMode.Text; - var printer = new ProgressPrinter(mode); - var engine = new OrgSyncEngine(lister, git); - var request = new SyncRequest(organization, token, targetRoot, effectiveParallel); - var progress = new SyncProgressHandler(printer, log, printDetails: !machineReadable, collectForSanitize: sanitizePaths); + return summary.Failed > 0 || summary.WasCanceled ? ExitCodes.Partial : ExitCodes.Success; + } + catch(Exception ex) + { + // Fatal path: missing or rejected token, unusable target folder, + // unknown organization. Program prints the message; the log keeps it. + log.Error($"sync failed: {ex.Message}", ex); + throw; + } + } - SyncSummary summary; - if (canceledWhileListing) - { - // Canceled while still listing repositories: nothing was processed yet. - summary = new SyncSummary(0, 0, 0, 0, 0, WasCanceled: true); - } - else - { - try - { - summary = await engine - .SyncAsync(request, repositories, progress, cancellationToken) - .ConfigureAwait(false); - } - // The engine folds cancellation into the summary itself (remaining - // repositories are marked Canceled), so only disk failures surface here. - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - throw new CliErrorException($"Cannot use target folder '{targetRoot}': {ex.Message}", ex); - } - } - // --sanitize-paths: repositories whose only problem was Windows-invalid - // paths were held back by the progress handler; retry each with an - // automatic recovery. A recovered repository counts as cloned. - int sanitized = 0; - foreach (PendingRecovery pending in progress.DrainPendingRecoveries()) - { - if (await TrySanitizeAsync(git, progress, printer, log, targetRoot, pending, announce: !machineReadable, cancellationToken) - .ConfigureAwait(false)) - { - sanitized++; - } - } - if (sanitized > 0) - { - summary = summary with { Cloned = summary.Cloned + sanitized, Failed = summary.Failed - sanitized }; - } - log.Info( - $"sync finished: total={summary.Total}, cloned={summary.Cloned}, updated={summary.Updated}, " - + $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}" - + (sanitizePaths ? $", sanitizedPaths={sanitized}" : "")); + /// + /// '--dry-run': says what the run would do with each selected repository and + /// touches nothing. Exit 0 even for an empty selection. + /// + private static int PrintDryRun( + IReadOnlyList repositories, IGitClient git, string targetRoot, bool json, IActivityLog log) + { + var entries = repositories + .Select(r => new DryRunEntry( + r.Name, + git.IsValidRepository(Path.Combine(targetRoot, r.Name)) ? "update" : "clone")) + .ToList(); + int clones = entries.Count(e => string.Equals(e.Action, "clone", StringComparison.Ordinal)); + log.Info($"sync dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}."); - string verb = summary.WasCanceled ? "Canceled" : "Finished"; - string clonedText = sanitized > 0 - ? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)" - : $"{summary.Cloned} cloned"; - string summaryLine = $"{verb}: {clonedText}, {summary.Updated} updated, " - + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; + if(json) + { + Console.Out.WriteLine(JsonSerializer.Serialize( + (IReadOnlyList)entries, CliJsonContext.Default.IReadOnlyListDryRunEntry)); + return ExitCodes.Success; + } - if (machineReadable) - { - var result = new SyncJsonResult( - "summary", summary.Total, summary.Cloned, summary.Updated, summary.Failed, - summary.Canceled, summary.WasCanceled, printer.Failures, printer.Sanitized); - Console.Out.WriteLine(JsonSerializer.Serialize(result, CliJsonContext.Default.SyncJsonResult)); - } - else - { - Console.Out.WriteLine(summaryLine); - } + foreach(DryRunEntry entry in entries) + { + Console.Out.WriteLine($"{entry.Repo} would {entry.Action}"); + } + Console.Out.WriteLine($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}."); + return ExitCodes.Success; + } - if (account is not null) - { - // The run completed (exit 0 or 1): remember when and how it went, - // so 'gclo accounts' and the desktop app can show it. - TryRecordSyncResult(store!, account, summaryLine, log); - } - return summary.Failed > 0 || summary.WasCanceled ? ExitCodes.Partial : ExitCodes.Success; - } - catch (Exception ex) - { - // Fatal path: missing or rejected token, unusable target folder, - // unknown organization. Program prints the message; the log keeps it. - log.Error($"sync failed: {ex.Message}", ex); - throw; - } - } - /// - /// '--dry-run': says what the run would do with each selected repository and - /// touches nothing. Exit 0 even for an empty selection. - /// - private static int PrintDryRun( - IReadOnlyList repositories, IGitClient git, string targetRoot, bool json, IActivityLog log) - { - var entries = repositories - .Select(r => new DryRunEntry( - r.Name, - git.IsValidRepository(Path.Combine(targetRoot, r.Name)) ? "update" : "clone")) - .ToList(); - int clones = entries.Count(e => e.Action == "clone"); - log.Info($"sync dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}."); + /// + /// The token for this run. Any explicit token option wins; otherwise a saved + /// account's token comes from Windows Credential Manager; plain runs fall back + /// to the default environment variable. + /// + private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault) + { + if(account is null || tokenOptions.HasExplicitSource) + { + return tokenOptions.Resolve(); + } - if (json) - { - Console.Out.WriteLine(JsonSerializer.Serialize( - (IReadOnlyList)entries, CliJsonContext.Default.IReadOnlyListDryRunEntry)); - return ExitCodes.Success; - } + // vault is non-null whenever account is: both come from AccountsCommand.Open. + string? token = vault!.TryRetrieve(account.Id); + if(string.IsNullOrEmpty(token)) + { + throw new CliErrorException( + $"Account '{account.Name}' has no token in Windows Credential Manager " + + $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with " + + "'gclo accounts edit --name --token-stdin' or in the desktop app's account wizard, " + + "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin."); + } + return token; + } - foreach (DryRunEntry entry in entries) - { - Console.Out.WriteLine($"{entry.Repo} would {entry.Action}"); - } - Console.Out.WriteLine($"Dry run: {clones} would clone, {entries.Count - clones} would update of {entries.Count}."); - return ExitCodes.Success; - } - /// - /// The token for this run. Any explicit token option wins; otherwise a saved - /// account's token comes from Windows Credential Manager; plain runs fall back - /// to the default environment variable. - /// - private static string ResolveToken(TokenOptions tokenOptions, Account? account, ITokenVault? vault) - { - if (account is null || tokenOptions.HasExplicitSource) - { - return tokenOptions.Resolve(); - } - // vault is non-null whenever account is: both come from AccountsCommand.Open. - string? token = vault!.TryRetrieve(account.Id); - if (string.IsNullOrEmpty(token)) - { - throw new CliErrorException( - $"Account '{account.Name}' has no token in Windows Credential Manager " - + $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with " - + "'gclo accounts edit --name --token-stdin' or in the desktop app's account wizard, " - + "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin."); - } - return token; - } + /// + /// Records the completed run's time and summary on the account. A persistence + /// failure must not change the sync's exit code — the repositories are already + /// on disk — so it is reported as a warning instead of propagating. + /// + private static void TryRecordSyncResult(AccountsStore store, Account account, string summaryLine, IActivityLog log) + { + try + { + store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, summaryLine); + } + catch(Exception ex) + { + log.Error($"could not record the sync result on account '{account.Name}': {ex.Message}", ex); + Console.Error.WriteLine( + $"Warning: could not record the sync result on account '{account.Name}': {ex.Message}"); + } + } - /// - /// Records the completed run's time and summary on the account. A persistence - /// failure must not change the sync's exit code — the repositories are already - /// on disk — so it is reported as a warning instead of propagating. - /// - private static void TryRecordSyncResult(AccountsStore store, Account account, string summaryLine, IActivityLog log) - { - try - { - store.RecordSyncResult(account.Id, DateTimeOffset.UtcNow, summaryLine); - } - catch (Exception ex) - { - log.Error($"could not record the sync result on account '{account.Name}': {ex.Message}", ex); + + + /// Unknown --account name: tell the user what does exist (exit code 2). + private static CliErrorException UnknownAccount(string name, AccountsStore store) + { + IReadOnlyList all = store.GetAll(); + string available = all.Count == 0 + ? "No accounts exist yet; create one with 'gclo accounts add' or in the gclo desktop app." + : "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + "."; + return new CliErrorException($"No account named '{name}'. {available}"); + } + + + + /// + /// Applies an automatic (rename to the suggested name, + /// skip what has none) to one repository that failed with Windows-invalid paths. + /// Reports the real outcome — Done or Failed — through , + /// which by now forwards to the console again, and records the sanitized shape + /// for the JSON document. The human-readable note is only printed in the text + /// modes (); the document carries it otherwise. + /// + private static async Task TrySanitizeAsync( + IGitClient git, + SyncProgressHandler progress, + ProgressPrinter printer, + IActivityLog log, + string targetRoot, + PendingRecovery pending, + bool announce, + CancellationToken cancellationToken) + { + string path = Path.Combine(targetRoot, pending.Repo); + try + { + cancellationToken.ThrowIfCancellationRequested(); + PathRecovery recovery = BuildRecovery(pending.Paths); + await git.ApplyRecoveryAsync(path, recovery, cancellationToken).ConfigureAwait(false); + + string detail = DescribeRecovery(recovery); + if(announce) + { Console.Error.WriteLine( - $"Warning: could not record the sync result on account '{account.Name}': {ex.Message}"); - } - } + $"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, " + + $"{recovery.SkippedPaths.Count} skipped: {detail}"); + } + log.Info($"{pending.Repo}: cloned with sanitized paths: {detail}"); + printer.RecordSanitized(new SyncSanitized( + pending.Repo, + recovery.SegmentRenames.Count, + recovery.SkippedPaths.Count, + recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList())); + progress.Report(new RepoProgress(pending.Repo, SyncStatus.Done)); + return true; + } + catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested) + { + // Canceled before this repo could be sanitized: surface the original failure. + progress.Report(new RepoProgress( + pending.Repo, SyncStatus.Failed, pending.Error, InvalidPaths: pending.Paths)); + return false; + } + catch(Exception ex) + { + progress.Report(new RepoProgress( + pending.Repo, SyncStatus.Failed, $"path sanitization failed: {ex.Message}", + InvalidPaths: (ex as InvalidRepositoryPathsException)?.Paths)); + return false; + } + } - /// Unknown --account name: tell the user what does exist (exit code 2). - private static CliErrorException UnknownAccount(string name, AccountsStore store) - { - IReadOnlyList all = store.GetAll(); - string available = all.Count == 0 - ? "No accounts exist yet; create one with 'gclo accounts add' or in the gclo desktop app." - : "Available accounts: " + string.Join(", ", all.Select(a => a.Name)) + "."; - return new CliErrorException($"No account named '{name}'. {available}"); - } - /// - /// Applies an automatic (rename to the suggested name, - /// skip what has none) to one repository that failed with Windows-invalid paths. - /// Reports the real outcome — Done or Failed — through , - /// which by now forwards to the console again, and records the sanitized shape - /// for the JSON document. The human-readable note is only printed in the text - /// modes (); the document carries it otherwise. - /// - private static async Task TrySanitizeAsync( - IGitClient git, - SyncProgressHandler progress, - ProgressPrinter printer, - IActivityLog log, - string targetRoot, - PendingRecovery pending, - bool announce, - CancellationToken cancellationToken) - { - string path = Path.Combine(targetRoot, pending.Repo); - try - { - cancellationToken.ThrowIfCancellationRequested(); - PathRecovery recovery = BuildRecovery(pending.Paths); - await git.ApplyRecoveryAsync(path, recovery, cancellationToken).ConfigureAwait(false); - string detail = DescribeRecovery(recovery); - if (announce) + /// + /// Builds the automatic recovery: each offending path is renamed to its suggested + /// name; paths without one (e.g. case-only collisions, which a rename to the same + /// suggestion could not resolve) are skipped entirely. + /// + private static PathRecovery BuildRecovery(IReadOnlyList paths) + { + // Skip wins: a path that also has a suggestion-less problem is not materialized. + var skipped = new HashSet( + paths.Where(p => string.IsNullOrEmpty(p.SuggestedName)).Select(p => p.RepoPath), + StringComparer.Ordinal); + + var renames = new Dictionary(StringComparer.Ordinal); + foreach(InvalidPathInfo info in paths) + { + if(info.SuggestedName is not { Length: > 0 } suggestion || skipped.Contains(info.RepoPath)) + { + continue; + } + // The suggestion replaces the last segment — the validator reports each + // offending segment as a repo path ending in that segment. + int slash = info.RepoPath.LastIndexOf('/'); + renames[info.RepoPath] = slash < 0 ? suggestion : info.RepoPath[..(slash + 1)] + suggestion; + } + return new PathRecovery(renames, skipped); + } + + + + private static string DescribeRecovery(PathRecovery recovery) + => string.Join("; ", recovery.SegmentRenames + .Select(r => $"'{r.Key}' -> '{r.Value}'") + .Concat(recovery.SkippedPaths.Select(s => $"skipped '{s}'"))); + + + + /// One repository held back from failure reporting for a sanitization attempt. + private sealed record PendingRecovery(string Repo, string Error, IReadOnlyList Paths); + + + + /// + /// Forwards every engine report to the console printer unchanged and mirrors + /// failures into the activity log, so console output stays exactly what the + /// printer alone would have produced — with two invalid-path additions: failure + /// details (the offending paths, up to 10) follow the failure line on stderr, + /// and with --sanitize-paths such failures are collected for a recovery attempt + /// instead of being reported at all. + /// + private sealed class SyncProgressHandler : IProgress + { + private const int MaxListedPaths = 10; + + private readonly Lock _gate = new(); + + private readonly ProgressPrinter _printer; + + private readonly IActivityLog _log; + + private readonly bool _printDetails; + + private readonly List _pending = new(); + + private bool _collectForSanitize; + + + + /// + + /// /// Write offending-path lines to stderr (off for the JSON modes, whose document carries the full list). + /// Hold invalid-path failures back for --sanitize-paths instead of reporting them. + public SyncProgressHandler(ProgressPrinter printer, IActivityLog log, bool printDetails, bool collectForSanitize) + { + _printer = printer; + _log = log; + _printDetails = printDetails; + _collectForSanitize = collectForSanitize; + } + + + + /// + /// Returns the failures held back for sanitization and stops collecting: + /// from here on Failed reports (the recovery attempts' outcomes) print normally. + /// + public IReadOnlyList DrainPendingRecoveries() + { + lock(_gate) + { + _collectForSanitize = false; + PendingRecovery[] drained = _pending.ToArray(); + _pending.Clear(); + return drained; + } + } + + + + // The engine reports from worker threads; the lock keeps a failure line and + // its detail lines together. + public void Report(RepoProgress value) + { + lock(_gate) + { + if(value.Status != SyncStatus.Failed) { - Console.Error.WriteLine( - $"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, " - + $"{recovery.SkippedPaths.Count} skipped: {detail}"); + _printer.Report(value); + return; } - log.Info($"{pending.Repo}: cloned with sanitized paths: {detail}"); - printer.RecordSanitized(new SyncSanitized( - pending.Repo, - recovery.SegmentRenames.Count, - recovery.SkippedPaths.Count, - recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList())); - progress.Report(new RepoProgress(pending.Repo, SyncStatus.Done)); - return true; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Canceled before this repo could be sanitized: surface the original failure. - progress.Report(new RepoProgress( - pending.Repo, SyncStatus.Failed, pending.Error, InvalidPaths: pending.Paths)); - return false; - } - catch (Exception ex) - { - progress.Report(new RepoProgress( - pending.Repo, SyncStatus.Failed, $"path sanitization failed: {ex.Message}", - InvalidPaths: (ex as InvalidRepositoryPathsException)?.Paths)); - return false; - } - } - /// - /// Builds the automatic recovery: each offending path is renamed to its suggested - /// name; paths without one (e.g. case-only collisions, which a rename to the same - /// suggestion could not resolve) are skipped entirely. - /// - private static PathRecovery BuildRecovery(IReadOnlyList paths) - { - // Skip wins: a path that also has a suggestion-less problem is not materialized. - var skipped = new HashSet( - paths.Where(p => string.IsNullOrEmpty(p.SuggestedName)).Select(p => p.RepoPath), - StringComparer.Ordinal); - - var renames = new Dictionary(StringComparer.Ordinal); - foreach (InvalidPathInfo info in paths) - { - if (info.SuggestedName is not { Length: > 0 } suggestion || skipped.Contains(info.RepoPath)) + if(_collectForSanitize && value.InvalidPaths is { Count: > 0 }) { - continue; + _pending.Add(new PendingRecovery( + value.RepoName, value.Error ?? "invalid paths", value.InvalidPaths)); + return; } - // The suggestion replaces the last segment — the validator reports each - // offending segment as a repo path ending in that segment. - int slash = info.RepoPath.LastIndexOf('/'); - renames[info.RepoPath] = slash < 0 ? suggestion : info.RepoPath[..(slash + 1)] + suggestion; - } - return new PathRecovery(renames, skipped); - } - private static string DescribeRecovery(PathRecovery recovery) - => string.Join("; ", recovery.SegmentRenames - .Select(r => $"'{r.Key}' -> '{r.Value}'") - .Concat(recovery.SkippedPaths.Select(s => $"skipped '{s}'"))); + _log.Error($"{value.RepoName} failed: {value.Error ?? "unknown error"}"); + _printer.Report(value); - /// One repository held back from failure reporting for a sanitization attempt. - private sealed record PendingRecovery(string Repo, string Error, IReadOnlyList Paths); - - /// - /// Forwards every engine report to the console printer unchanged and mirrors - /// failures into the activity log, so console output stays exactly what the - /// printer alone would have produced — with two invalid-path additions: failure - /// details (the offending paths, up to 10) follow the failure line on stderr, - /// and with --sanitize-paths such failures are collected for a recovery attempt - /// instead of being reported at all. - /// - private sealed class SyncProgressHandler : IProgress - { - private const int MaxListedPaths = 10; - - private readonly object _gate = new(); - private readonly ProgressPrinter _printer; - private readonly IActivityLog _log; - private readonly bool _printDetails; - private readonly List _pending = new(); - private bool _collectForSanitize; - - /// Write offending-path lines to stderr (off for the JSON modes, whose document carries the full list). - /// Hold invalid-path failures back for --sanitize-paths instead of reporting them. - public SyncProgressHandler(ProgressPrinter printer, IActivityLog log, bool printDetails, bool collectForSanitize) - { - _printer = printer; - _log = log; - _printDetails = printDetails; - _collectForSanitize = collectForSanitize; - } - - /// - /// Returns the failures held back for sanitization and stops collecting: - /// from here on Failed reports (the recovery attempts' outcomes) print normally. - /// - public IReadOnlyList DrainPendingRecoveries() - { - lock (_gate) + if(_printDetails && value.InvalidPaths is { Count: > 0 }) { - _collectForSanitize = false; - PendingRecovery[] drained = _pending.ToArray(); - _pending.Clear(); - return drained; + // The WHY, for scripts: the failure message itself only carries + // the first three offending paths. + foreach(InvalidPathInfo info in value.InvalidPaths.Take(MaxListedPaths)) + { + Console.Error.WriteLine($"{value.RepoName} {info.RepoPath} ({info.Reason})"); + } + if(value.InvalidPaths.Count > MaxListedPaths) + { + Console.Error.WriteLine( + $"{value.RepoName} ... and {value.InvalidPaths.Count - MaxListedPaths} more invalid paths"); + } } - } - - // The engine reports from worker threads; the lock keeps a failure line and - // its detail lines together. - public void Report(RepoProgress value) - { - lock (_gate) - { - if (value.Status != SyncStatus.Failed) - { - _printer.Report(value); - return; - } - - if (_collectForSanitize && value.InvalidPaths is { Count: > 0 }) - { - _pending.Add(new PendingRecovery( - value.RepoName, value.Error ?? "invalid paths", value.InvalidPaths)); - return; - } - - _log.Error($"{value.RepoName} failed: {value.Error ?? "unknown error"}"); - _printer.Report(value); - - if (_printDetails && value.InvalidPaths is { Count: > 0 }) - { - // The WHY, for scripts: the failure message itself only carries - // the first three offending paths. - foreach (InvalidPathInfo info in value.InvalidPaths.Take(MaxListedPaths)) - { - Console.Error.WriteLine($"{value.RepoName} {info.RepoPath} ({info.Reason})"); - } - if (value.InvalidPaths.Count > MaxListedPaths) - { - Console.Error.WriteLine( - $"{value.RepoName} ... and {value.InvalidPaths.Count - MaxListedPaths} more invalid paths"); - } - } - } - } - } + } + } + } } diff --git a/gclo.Cli/SyncFailure.cs b/gclo.Cli/SyncFailure.cs new file mode 100644 index 0000000..4f5002d --- /dev/null +++ b/gclo.Cli/SyncFailure.cs @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// +/// One failed repository in the JSON summary. is present +/// (non-null) only when the failure was Windows path validation — the full list, not +/// the three-path excerpt the message carries. +/// +internal sealed record SyncFailure(string Repo, string Error, IReadOnlyList? InvalidPaths = null); diff --git a/gclo.Cli/SyncInvalidPath.cs b/gclo.Cli/SyncInvalidPath.cs new file mode 100644 index 0000000..bd8e262 --- /dev/null +++ b/gclo.Cli/SyncInvalidPath.cs @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// One Windows-invalid path behind a failure, as the validator reported it. +internal sealed record SyncInvalidPath(string Path, string Reason, string? SuggestedName); diff --git a/gclo.Cli/SyncJsonResult.cs b/gclo.Cli/SyncJsonResult.cs new file mode 100644 index 0000000..1029b1e --- /dev/null +++ b/gclo.Cli/SyncJsonResult.cs @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// +/// The single-line JSON document printed by 'gclo sync --json', and the last line of +/// 'gclo sync --json-lines'. is always "summary" so a line reader +/// can tell it from progress lines. +/// +internal sealed record SyncJsonResult( + string Type, + int Total, + int Cloned, + int Updated, + int Failed, + int Canceled, + bool WasCanceled, + IReadOnlyList Failures, + IReadOnlyList Sanitized); diff --git a/gclo.Cli/SyncProgressLine.cs b/gclo.Cli/SyncProgressLine.cs new file mode 100644 index 0000000..0f22a6e --- /dev/null +++ b/gclo.Cli/SyncProgressLine.cs @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// One status transition in 'gclo sync --json-lines'; is always "progress". +internal sealed record SyncProgressLine(string Type, string Repo, string Status, string? Error); diff --git a/gclo.Cli/SyncSanitized.cs b/gclo.Cli/SyncSanitized.cs new file mode 100644 index 0000000..3d7fa2f --- /dev/null +++ b/gclo.Cli/SyncSanitized.cs @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Cli; + + +/// +/// One repository that '--sanitize-paths' checked out with renames and skips: it +/// counts as cloned, but its working tree is incomplete by +/// paths, which a mirroring pipeline needs to know. +/// +internal sealed record SyncSanitized(string Repo, int Renamed, int Skipped, IReadOnlyList SkippedPaths); diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs index 50a930b..e641e61 100644 --- a/gclo.Cli/TokenOptions.cs +++ b/gclo.Cli/TokenOptions.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Cli; + /// /// 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; /// internal sealed class TokenOptions { - /// Environment variable consulted when no token option is given. - public const string DefaultVariable = "GITHUB_TOKEN"; + /// Environment variable consulted when no token option is given. + public const string DefaultVariable = "GITHUB_TOKEN"; - private string? _envVariable; - private string? _filePath; - private bool _useStdin; + private string? _envVariable; - /// - /// 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. - /// - public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin; + private string? _filePath; - /// Consumes the reader's current option when it is a token option. - 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; - /// - /// Resolves the token from the single configured source, or from the - /// environment variable when none was given. - /// Throws with an actionable message when the token is missing or empty. - /// - 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; - } + /// + /// 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. + /// + 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(); + /// Consumes the reader's current option when it is a token option. + 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) + /// + /// Resolves the token from the single configured source, or from the + /// environment variable when none was given. + /// Throws with an actionable message when the token is missing or empty. + /// + 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); + } + } + } } diff --git a/gclo.Cli/packages.lock.json b/gclo.Cli/packages.lock.json index 314288b..7465a57 100644 --- a/gclo.Cli/packages.lock.json +++ b/gclo.Cli/packages.lock.json @@ -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": { diff --git a/gclo.Engine.Tests/AccountWizardViewModelTests.cs b/gclo.Engine.Tests/AccountWizardViewModelTests.cs index a0ca8cd..9ee962d 100644 --- a/gclo.Engine.Tests/AccountWizardViewModelTests.cs +++ b/gclo.Engine.Tests/AccountWizardViewModelTests.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.ViewModels; + namespace gclo.Engine.Tests; + /// /// Tests for : 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; /// 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", - }; - - /// Advances until the wizard sits on , asserting each hop. - 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); - } - - /// A new-account wizard walked to the last step with valid inputs everywhere. - private async Task 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>(["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>(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>( - TaskCreationOptions.RunContinuationsAsynchronously); - _orgs.Handler = (_, _) => gate.Task; - var wizard = NewWizard(); - wizard.Name = "Work"; - await AdvanceToStepAsync(wizard, 2); - wizard.Token = "ghp_slow"; - - Task 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(() => 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>(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", + }; + + + + /// Advances until the wizard sits on , asserting each hop. + 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); + } + + + + /// A new-account wizard walked to the last step with valid inputs everywhere. + private async Task 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>(["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>(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>( + TaskCreationOptions.RunContinuationsAsynchronously); + _orgs.Handler = (_, _) => gate.Task; + AccountWizardViewModel wizard = NewWizard(); + wizard.Name = "Work"; + await AdvanceToStepAsync(wizard, 2); + wizard.Token = "ghp_slow"; + + Task 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(() => 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>(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)); + } } diff --git a/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs b/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs index 2445d86..755a8a7 100644 --- a/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs +++ b/gclo.Engine.Tests/AccountsStoreConsistencyTests.cs @@ -1,8 +1,14 @@ -using gclo.Engine; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.ViewModels; + namespace gclo.Engine.Tests; + /// /// Contract tests for the two-store consistency rules of /// (see its class remarks): the order of vault and metadata writes, the compensating @@ -12,380 +18,451 @@ namespace gclo.Engine.Tests; /// 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", - }; - - /// - /// 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. - /// - 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 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(() => 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( - () => 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(() => 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(() => 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(() => 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(() => store.Save(account, NewToken)); - - Assert.IsType(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( - () => 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(() => 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(() => 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(() => 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(() => store.Delete(account.Id)); - - Assert.IsType(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(() => 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(() => 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(() => 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(() => 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); - } - - /// - /// An over 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. - /// - 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", + }; + + + + /// + /// 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. + /// + 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 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(() => 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( + () => 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(() => 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(() => 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(() => 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(() => store.Save(account, NewToken)); + + Assert.IsType(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( + () => 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(() => 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(() => 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(() => 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(() => store.Delete(account.Id)); + + Assert.IsType(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(() => 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(() => 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(() => 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(() => 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); + } + + + + /// + /// An over 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. + /// + 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 => ""; + } } diff --git a/gclo.Engine.Tests/AccountsStoreTests.cs b/gclo.Engine.Tests/AccountsStoreTests.cs index c05bcd1..cce8c3a 100644 --- a/gclo.Engine.Tests/AccountsStoreTests.cs +++ b/gclo.Engine.Tests/AccountsStoreTests.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.ViewModels; + namespace gclo.Engine.Tests; + /// /// Tests for : JSON round-trips across store instances, /// case-insensitive lookups and name uniqueness, vault coordination, and tolerance @@ -10,212 +17,245 @@ namespace gclo.Engine.Tests; /// 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(() => 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(() => 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); + } } diff --git a/gclo.Engine.Tests/ActionLoggingTests.cs b/gclo.Engine.Tests/ActionLoggingTests.cs index fdbbce3..af269af 100644 --- a/gclo.Engine.Tests/ActionLoggingTests.cs +++ b/gclo.Engine.Tests/ActionLoggingTests.cs @@ -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; + /// /// 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; /// 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 handler) : IProgress - { - 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>(["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 handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } - [Fact] - public async Task Workspace_OrganizationLookupFailure_IsLoggedAsAnError() - { - _orgs.Handler = (_, _) => Task.FromException>(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 { 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(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 { 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(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(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>(["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>(["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>(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>(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 { 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(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 { 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(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(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>(["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>(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); + } } diff --git a/gclo.Engine.Tests/ActivityLogTests.cs b/gclo.Engine.Tests/ActivityLogTests.cs index a16ed15..99c1f70 100644 --- a/gclo.Engine.Tests/ActivityLogTests.cs +++ b/gclo.Engine.Tests/ActivityLogTests.cs @@ -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; + /// /// Tests for (line format, daily file naming, lazy /// directory creation, swallowing IO failures, thread safety) and @@ -11,181 +17,203 @@ namespace gclo.Engine.Tests; /// public sealed class ActivityLogTests : IDisposable { - /// "yyyy-MM-dd HH:mm:ss.fff [LEVEL] " prefix every entry line must carry. - private static readonly Regex LinePattern = new( - @"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} \[(INFO|ERROR)\] ", RegexOptions.Compiled); + /// "yyyy-MM-dd HH:mm:ss.fff [LEVEL] " prefix every entry line must carry. + 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); + } } diff --git a/gclo.Engine.Tests/AppSettingsTests.cs b/gclo.Engine.Tests/AppSettingsTests.cs index 8c30ff6..db944c9 100644 --- a/gclo.Engine.Tests/AppSettingsTests.cs +++ b/gclo.Engine.Tests/AppSettingsTests.cs @@ -1,82 +1,93 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.ViewModels; + namespace gclo.Engine.Tests; + /// Pins defaults and load-time sanitizing of . 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) + { + } + } + } } diff --git a/gclo.Engine.Tests/BuildVersionTests.cs b/gclo.Engine.Tests/BuildVersionTests.cs index c6a846b..b29b5ac 100644 --- a/gclo.Engine.Tests/BuildVersionTests.cs +++ b/gclo.Engine.Tests/BuildVersionTests.cs @@ -1,30 +1,36 @@ -using gclo.Engine; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ namespace gclo.Engine.Tests; + /// Formatting of the version + git-hash build identity string. 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)); + } } diff --git a/gclo.Engine.Tests/CoverageResidueTests.cs b/gclo.Engine.Tests/CoverageResidueTests.cs index e14fb9d..2d9cc7a 100644 --- a/gclo.Engine.Tests/CoverageResidueTests.cs +++ b/gclo.Engine.Tests/CoverageResidueTests.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.ViewModels; + namespace gclo.Engine.Tests; + /// /// 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; /// 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 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 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 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 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 Errors { get; } = []; + + + + public void Info(string message) + { + } + + + + public void Error(string message, Exception? exception = null) => Errors.Add(message); + + + + public string LogDirectory => ""; + + + + public string CurrentLogFilePath => ""; + } } diff --git a/gclo.Engine.Tests/Fakes.cs b/gclo.Engine.Tests/Fakes.cs index 07c4305..576f12f 100644 --- a/gclo.Engine.Tests/Fakes.cs +++ b/gclo.Engine.Tests/Fakes.cs @@ -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; + /// An that returns a configurable list (or throws). 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 Repositories { get; set; } = Array.Empty(); + public IReadOnlyList Repositories { get; set; } = Array.Empty(); - /// When set, throws this instead of returning. - public Exception? ExceptionToThrow { get; set; } + /// When set, throws this instead of returning. + public Exception? ExceptionToThrow { get; set; } - public IReadOnlyList<(string Organization, string Token)> Calls => _calls.ToArray(); - public Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default) - { - _calls.Enqueue((organization, token)); - if (ExceptionToThrow is not null) - { - return Task.FromException>(ExceptionToThrow); - } - return Task.FromResult(Repositories); - } + + public IReadOnlyList<(string Organization, string Token)> Calls => _calls.ToArray(); + + + + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + { + _calls.Enqueue((organization, token)); + if(ExceptionToThrow is not null) + { + return Task.FromException>(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); + + /// /// An whose behavior is configured with delegates. /// All calls are recorded thread-safely; default handlers complete immediately. /// public sealed class FakeGitClient : IGitClient { - private readonly ConcurrentQueue _cloneCalls = new(); - private readonly ConcurrentQueue _pullCalls = new(); - private readonly ConcurrentQueue _applyRecoveryCalls = new(); - private readonly ConcurrentQueue _validityChecks = new(); + private readonly ConcurrentQueue _cloneCalls = new(); - /// Decides whether a local path counts as an existing valid repository. Default: never. - public Func IsValidRepositoryHandler { get; set; } = _ => false; + private readonly ConcurrentQueue _pullCalls = new(); - /// Body of (url, path, token, onProgress, ct). Default: completes immediately. - public Func?, CancellationToken, Task> CloneHandler { get; set; } - = (_, _, _, _, _) => Task.CompletedTask; + private readonly ConcurrentQueue _applyRecoveryCalls = new(); - /// Body of (path, token, ct). Default: completes immediately. - public Func FetchAndPullHandler { get; set; } - = (_, _, _) => Task.CompletedTask; + private readonly ConcurrentQueue _validityChecks = new(); - /// Body of (path, recovery, ct). Default: completes immediately. - public Func ApplyRecoveryHandler { get; set; } - = (_, _, _) => Task.CompletedTask; + /// Decides whether a local path counts as an existing valid repository. Default: never. + public Func IsValidRepositoryHandler { get; set; } = _ => false; - public IReadOnlyList CloneCalls => _cloneCalls.ToArray(); - public IReadOnlyList PullCalls => _pullCalls.ToArray(); - public IReadOnlyList ApplyRecoveryCalls => _applyRecoveryCalls.ToArray(); - public IReadOnlyList ValidityChecks => _validityChecks.ToArray(); + /// Body of (url, path, token, onProgress, ct). Default: completes immediately. + public Func?, CancellationToken, Task> CloneHandler { get; set; } + = (_, _, _, _, _) => Task.CompletedTask; - /// Repo folder names (last path segment) passed to . - public IReadOnlyList ClonedRepoNames - => _cloneCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray(); + /// Body of (path, token, ct). Default: completes immediately. + public Func FetchAndPullHandler { get; set; } + = (_, _, _) => Task.CompletedTask; - /// Repo folder names (last path segment) passed to . - public IReadOnlyList PulledRepoNames - => _pullCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray(); + /// Body of (path, recovery, ct). Default: completes immediately. + public Func 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? 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 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 PullCalls => _pullCalls.ToArray(); + + + + public IReadOnlyList ApplyRecoveryCalls => _applyRecoveryCalls.ToArray(); + + + + public IReadOnlyList ValidityChecks => _validityChecks.ToArray(); + + + + /// Repo folder names (last path segment) passed to . + public IReadOnlyList ClonedRepoNames + => _cloneCalls.Select(c => System.IO.Path.GetFileName(c.LocalPath)!).ToArray(); + + + + /// Repo folder names (last path segment) passed to . + public IReadOnlyList 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? 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); + } } + + /// /// An that keeps every entry, for asserting that actions /// are logged (#40) and that no entry ever carries a token. /// 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 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 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 => ""; } + + /// An whose behavior is configured with a delegate. public sealed class FakeOrganizationLister : IOrganizationLister { - private int _calls; + private int _calls; - public Func>> Handler { get; set; } - = (_, _) => Task.FromResult>(Array.Empty()); + public Func>> Handler { get; set; } + = (_, _) => Task.FromResult>(Array.Empty()); - public int Calls => _calls; - public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _calls); - return Handler(token, cancellationToken); - } + + public int Calls => _calls; + + + + public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _calls); + return Handler(token, cancellationToken); + } } + + /// /// Records every report synchronously into a thread-safe queue. /// Deliberately NOT , which posts callbacks to a sync context @@ -140,22 +208,32 @@ public sealed class FakeOrganizationLister : IOrganizationLister /// public sealed class RecordingProgress : IProgress { - private readonly ConcurrentQueue _reports = new(); + private readonly ConcurrentQueue _reports = new(); - public void Report(RepoProgress value) => _reports.Enqueue(value); - /// All reports in the order they were recorded. - public IReadOnlyList Reports => _reports.ToArray(); - /// Ordered statuses reported for one repository. - public IReadOnlyList StatusesFor(string repoName) - => _reports.Where(r => r.RepoName == repoName).Select(r => r.Status).ToArray(); + public void Report(RepoProgress value) => _reports.Enqueue(value); - /// The most recent report for one repository, or null if none. - public RepoProgress? LastFor(string repoName) - => _reports.LastOrDefault(r => r.RepoName == repoName); + + + /// All reports in the order they were recorded. + public IReadOnlyList Reports => _reports.ToArray(); + + + + /// Ordered statuses reported for one repository. + public IReadOnlyList StatusesFor(string repoName) + => _reports.Where(r => string.Equals(r.RepoName, repoName, StringComparison.Ordinal)).Select(r => r.Status).ToArray(); + + + + /// The most recent report for one repository, or null if none. + public RepoProgress? LastFor(string repoName) + => _reports.LastOrDefault(r => string.Equals(r.RepoName, repoName, StringComparison.Ordinal)); } + + /// One scripted invocation, as recorded by . public sealed record ProcessCall( string FileName, @@ -163,61 +241,81 @@ public sealed record ProcessCall( IReadOnlyDictionary? Environment, string? StandardInput); + + /// An that records calls and answers from a delegate. public sealed class FakeProcessRunner : IProcessRunner { - private readonly ConcurrentQueue _calls = new(); + private readonly ConcurrentQueue _calls = new(); - /// Answers each call; default: exit 0 with empty output. - public Func> Handler { get; set; } - = (_, _) => Task.FromResult(new ProcessResult(0, "", "")); + /// Answers each call; default: exit 0 with empty output. + public Func> Handler { get; set; } + = (_, _) => Task.FromResult(new ProcessResult(0, "", "")); - public IReadOnlyList Calls => _calls.ToArray(); - public Task RunAsync( - string fileName, - IReadOnlyList arguments, - IReadOnlyDictionary? environment, - string? standardInput, - CancellationToken cancellationToken) - { - var call = new ProcessCall(fileName, arguments, environment, standardInput); - _calls.Enqueue(call); - return Handler(call, cancellationToken); - } + + public IReadOnlyList Calls => _calls.ToArray(); + + + + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? 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); + + /// An with scripted availability and clone outcome. public sealed class FakeWslCloner : IWslCloner { - private readonly ConcurrentQueue _cloneCalls = new(); - private int _probes; + private readonly ConcurrentQueue _cloneCalls = new(); - /// What reports; default: not available. - public WslAvailability Availability { get; set; } = new(false, "wsl.exe is not installed (test default)"); + private int _probes; - /// Body of ; default: a successful clone into /home/me/gclo/org/repo. - public Func> 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}")); + /// What reports; default: not available. + public WslAvailability Availability { get; set; } = new(false, "wsl.exe is not installed (test default)"); - public int Probes => _probes; - public IReadOnlyList CloneCalls => _cloneCalls.ToArray(); + /// Body of ; default: a successful clone into /home/me/gclo/org/repo. + public Func> 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 ProbeAsync(CancellationToken cancellationToken) - { - Interlocked.Increment(ref _probes); - return Task.FromResult(Availability); - } - public Task 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 CloneCalls => _cloneCalls.ToArray(); + + + + public Task ProbeAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _probes); + return Task.FromResult(Availability); + } + + + + public Task 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); + } } diff --git a/gclo.Engine.Tests/GitHubListerTests.cs b/gclo.Engine.Tests/GitHubListerTests.cs index ea35b98..59fabc0 100644 --- a/gclo.Engine.Tests/GitHubListerTests.cs +++ b/gclo.Engine.Tests/GitHubListerTests.cs @@ -1,8 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Net; using Octokit; + namespace gclo.Engine.Tests; + /// /// Pins the GitHub listers' pagination, account-fallback, ordering, dedupe, and /// exception-translation logic over a fake — the @@ -10,364 +17,469 @@ namespace gclo.Engine.Tests; /// public sealed class GitHubListerTests { - private sealed class FakeGateway : IGitHubGateway - { - public Func>> OrgPages { get; set; } = - (_, _) => Task.FromResult>([]); - public Func>> OwnPages { get; set; } = - _ => Task.FromResult>([]); - public Func>> UserPages { get; set; } = - (_, _) => Task.FromResult>([]); - public Func> CurrentUser { get; set; } = () => Task.FromResult("me"); - public Func>> OrgLogins { get; set; } = - () => Task.FromResult>([]); - public Func> AccountKind { get; set; } = - _ => Task.FromResult(GitHubAccountKind.User); - - public Task> GetOrganizationRepositoriesPageAsync(string organization, int page) - => OrgPages(organization, page); - public Task> GetOwnRepositoriesPageAsync(int page) => OwnPages(page); - public Task> GetUserRepositoriesPageAsync(string user, int page) - => UserPages(user, page); - public Task GetCurrentUserLoginAsync() => CurrentUser(); - public Task> GetOrganizationLoginsAsync() => OrgLogins(); - public Task 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)); - - /// Octokit's Response type is internal; its IResponse is not. - private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null) : IResponse - { - public object? Body => ""; - public IReadOnlyDictionary Headers { get; } = new Dictionary(); - public ApiInfo ApiInfo { get; } = new( - new Dictionary(), [], [], "", 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>( - 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>( - [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>( - [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>([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>([Repo("public-only")]); }; - - var ex = await Assert.ThrowsAsync( - () => 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( - () => 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( - () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890")); - - Assert.Contains("secondary rate limit", ex.Message); - Assert.Contains("retry in", ex.Message); - Assert.IsType(ex.InnerException); - Assert.Equal(GitHubAccessKind.RateLimited, ex.Kind); - - _gateway.OrgPages = (_, _) => throw Abuse(); - var abuse = await Assert.ThrowsAsync( - () => 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>([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( - () => 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( - () => 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( - () => 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>(full); - }; - - await Assert.ThrowsAnyAsync( - () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token)); - } - - [Fact] - public async Task Repos_AlreadyCanceled_ThrowsBeforeAnyCall() - { - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - await Assert.ThrowsAnyAsync( - () => 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( - () => RepoLister().ListOrganizationRepositoriesAsync(organization, token)); - - // ---------------------------------------------------------------- organizations - - [Fact] - public async Task Orgs_OwnLoginFirst_ThenOrgsSorted_OwnLoginNeverDuplicated() - { - _gateway.CurrentUser = () => Task.FromResult("Kof"); - _gateway.OrgLogins = () => Task.FromResult>(["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( - () => OrgLister().ListOrganizationsAsync("tok-1234567890")); - - Assert.Contains("secondary rate limit", ex.Message); - - _gateway.OrgLogins = () => throw Abuse(); - var abuse = await Assert.ThrowsAsync( - () => 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( - () => 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( - () => 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( - () => 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( - () => OrgLister().ListOrganizationsAsync("tok-1234567890", cts.Token)); - Assert.Null(_tokenSeen); - } - - [Fact] - public async Task Orgs_BlankToken_Throws() - => await Assert.ThrowsAsync(() => OrgLister().ListOrganizationsAsync(" ")); - - [Fact] - public void Listers_NullGatewayFactory_Throw() - { - Assert.Throws(() => new GitHubRepositoryLister(null!)); - Assert.Throws(() => 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>> OrgPages { get; set; } = + (_, _) => Task.FromResult>([]); + + public Func>> OwnPages { get; set; } = + _ => Task.FromResult>([]); + + public Func>> UserPages { get; set; } = + (_, _) => Task.FromResult>([]); + + public Func> CurrentUser { get; set; } = () => Task.FromResult("me"); + + public Func>> OrgLogins { get; set; } = + () => Task.FromResult>([]); + + public Func> AccountKind { get; set; } = + _ => Task.FromResult(GitHubAccountKind.User); + + + + public Task> GetOrganizationRepositoriesPageAsync(string organization, int page) + => OrgPages(organization, page); + + + + public Task> GetOwnRepositoriesPageAsync(int page) => OwnPages(page); + + + + public Task> GetUserRepositoriesPageAsync(string user, int page) + => UserPages(user, page); + + + + public Task GetCurrentUserLoginAsync() => CurrentUser(); + + + + public Task> GetOrganizationLoginsAsync() => OrgLogins(); + + + + public Task 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)); + + + + /// Octokit's Response type is internal; its IResponse is not. + private sealed class FakeResponse(HttpStatusCode status, RateLimit? rateLimit = null) : IResponse + { + public object? Body => ""; + + + + public IReadOnlyDictionary Headers { get; } = new Dictionary(StringComparer.Ordinal); + + public ApiInfo ApiInfo { get; } = new( + new Dictionary(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>( + page == 1 ? first : [Repo("alpha")]); + + IReadOnlyList 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>( + [Repo("same"), new GitHubRepo("SAME", "https://x/dup.git", "dev", true)]); + + IReadOnlyList 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>( + [new GitHubRepo("r", "https://x/r.git", null, true)]); + + IReadOnlyList 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>([Repo("mine")]); + + IReadOnlyList 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>([Repo("public-only")]); }; + + GitHubAccessException ex = await Assert.ThrowsAsync( + () => 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( + () => 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( + () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890")); + + Assert.Contains("secondary rate limit", ex.Message); + Assert.Contains("retry in", ex.Message); + Assert.IsType(ex.InnerException); + Assert.Equal(GitHubAccessKind.RateLimited, ex.Kind); + + _gateway.OrgPages = (_, _) => throw Abuse(); + GitHubAccessException abuse = await Assert.ThrowsAsync( + () => 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>([Repo(user + "-repo")]); + + IReadOnlyList 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( + () => 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( + () => 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( + () => 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>(full); + }; + + await Assert.ThrowsAnyAsync( + () => RepoLister().ListOrganizationRepositoriesAsync("acme", "tok-1234567890", cts.Token)); + } + + + + [Fact] + public async Task Repos_AlreadyCanceled_ThrowsBeforeAnyCall() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => 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( + () => RepoLister().ListOrganizationRepositoriesAsync(organization, token)); + + + + // ---------------------------------------------------------------- organizations + + [Fact] + public async Task Orgs_OwnLoginFirst_ThenOrgsSorted_OwnLoginNeverDuplicated() + { + _gateway.CurrentUser = () => Task.FromResult("Kof"); + _gateway.OrgLogins = () => Task.FromResult>(["zeta", "acme", "KOF"]); + + IReadOnlyList 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 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( + () => OrgLister().ListOrganizationsAsync("tok-1234567890")); + + Assert.Contains("secondary rate limit", ex.Message); + + _gateway.OrgLogins = () => throw Abuse(); + GitHubAccessException abuse = await Assert.ThrowsAsync( + () => 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( + () => 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( + () => 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( + () => 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( + () => OrgLister().ListOrganizationsAsync("tok-1234567890", cts.Token)); + Assert.Null(_tokenSeen); + } + + + + [Fact] + public async Task Orgs_BlankToken_Throws() + => await Assert.ThrowsAsync(() => OrgLister().ListOrganizationsAsync(" ")); + + + + [Fact] + public void Listers_NullGatewayFactory_Throw() + { + Assert.Throws(() => new GitHubRepositoryLister(null!)); + Assert.Throws(() => 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()); + } } diff --git a/gclo.Engine.Tests/GitTestHelpers.cs b/gclo.Engine.Tests/GitTestHelpers.cs index dce2fc3..39ac731 100644 --- a/gclo.Engine.Tests/GitTestHelpers.cs +++ b/gclo.Engine.Tests/GitTestHelpers.cs @@ -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; + /// /// 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; /// internal static class GitTestHelpers { - /// Descriptor for a repository of the fake organization 'acme'. - public static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false) - => new(name, $"https://example.test/acme/{name}.git", branch, archived); + /// Descriptor for a repository of the fake organization 'acme'. + public static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false) + => new(name, $"https://example.test/acme/{name}.git", branch, archived); - /// Descriptors (default branch, not archived) for the given names. - public static RepoDescriptor[] Repos(params string[] names) - => names.Select(name => Repo(name)).ToArray(); - /// Standard signature for fixture commits. - public static Signature MakeSignature() - => new("tester", "tester@example.test", DateTimeOffset.Now); - /// Polls until is true; fails the test after a generous timeout. - public static async Task WaitUntilAsync(Func 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); - } - } + /// Descriptors (default branch, not archived) for the given names. + public static RepoDescriptor[] Repos(params string[] names) + => names.Select(name => Repo(name)).ToArray(); - /// SHA of the commit at HEAD's tip. - public static string HeadSha(string workdir) - { - using var repo = new Repository(workdir); - return repo.Head.Tip.Sha; - } - /// - /// Builds a repository in a fresh directory under whose single - /// commit holds the given forged entries with per-entry content; returns its path. - /// - 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; - } + /// Standard signature for fixture commits. + public static Signature MakeSignature() + => new("tester", "tester@example.test", DateTimeOffset.Now); - /// Convenience overload: every forged entry gets the same placeholder content. - public static string CreateForgedRepo(string root, params string[] entryNames) - => CreateForgedRepo(root, entryNames.Select(name => (name, "content")).ToArray()); - /// Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA. - 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; - } + /// Polls until is true; fails the test after a generous timeout. + public static async Task WaitUntilAsync(Func 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; - } - /// - /// 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. - /// - 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. - } - } + + /// SHA of the commit at HEAD's tip. + public static string HeadSha(string workdir) + { + using var repo = new Repository(workdir); + return repo.Head.Tip.Sha; + } + + + + /// + /// Builds a repository in a fresh directory under whose single + /// commit holds the given forged entries with per-entry content; returns its path. + /// + 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; + } + + + + /// Convenience overload: every forged entry gets the same placeholder content. + public static string CreateForgedRepo(string root, params string[] entryNames) + => CreateForgedRepo(root, entryNames.Select(name => (name, "content")).ToArray()); + + + + /// Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA. + 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; + } + + + + /// + /// 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. + /// + 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. + } + } } diff --git a/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs b/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs index 2861de6..6b94f86 100644 --- a/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs +++ b/gclo.Engine.Tests/InvalidRepositoryPathsExceptionTests.cs @@ -1,32 +1,40 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine.Tests; + /// Pins the user-facing message of . 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); + } } diff --git a/gclo.Engine.Tests/LibGit2GitClientTests.cs b/gclo.Engine.Tests/LibGit2GitClientTests.cs index d3213fe..8eb19f3 100644 --- a/gclo.Engine.Tests/LibGit2GitClientTests.cs +++ b/gclo.Engine.Tests/LibGit2GitClientTests.cs @@ -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; + /// /// Integration tests for against real repositories on the /// local file system. No network is involved: fixture repositories are created with @@ -12,496 +18,558 @@ namespace gclo.Engine.Tests; /// public sealed class LibGit2GitClientTests : IDisposable { - private const string Token = "test-token"; - - private readonly string _root = - Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - - private readonly LibGit2GitClient _client = new(); - - public LibGit2GitClientTests() - { - Directory.CreateDirectory(_root); - } - - public void Dispose() => TryDeleteDirectory(_root); - - // ---------------------------------------------------------------- clone - - [Fact] - public async Task Clone_LocalSource_PopulatesWorkingTreeAndReportsProgressInRange() - { - string source = CreateSourceRepo(); - string target = NewPath("clone-target"); - var progressValues = new List(); - - await _client.CloneAsync(source, target, Token, progressValues.Add, CancellationToken.None); - - Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); - Assert.True(_client.IsValidRepository(target)); - // Local-transport clones may fire few (or even zero) transfer callbacks, so do not - // require that any arrived — only that every value is a valid completion fraction. - Assert.All(progressValues, v => Assert.InRange(v, 0.0, 1.0)); - } - - [Fact] - public async Task Clone_Completed_LeavesCheckoutPendingMarkerCleared() - { - string source = CreateSourceRepo(); - string target = NewPath("clone-target"); - - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - - // The marker is set when the fetch lands and cleared only after checkout, so a - // finished clone must read as "not pending" — the next pull trusts its tree. - Assert.False(IsCheckoutPending(target)); - } - - [Fact] - public async Task Clone_EmptyOrigin_LeavesCheckoutPendingMarkerCleared() - { - string origin = NewPath("bare-origin"); - Repository.Init(origin, isBare: true); - string target = NewPath("unborn-clone"); - - await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); - - // Nothing to check out means nothing is pending; the unborn-HEAD pull path - // (not the pending path) must handle this repo once the remote gets commits. - Assert.False(IsCheckoutPending(target)); - } - - [Fact] - public async Task Clone_SourceMissing_ThrowsAndLeavesNoTargetDirectory() - { - string missingSource = NewPath("no-such-source"); - string target = NewPath("failed-clone"); - - await Assert.ThrowsAnyAsync( - () => _client.CloneAsync(missingSource, target, Token, null, CancellationToken.None)); - - // A failed clone into a directory that did not exist before must clean up after - // itself, or the next run would treat the leftovers as an existing local repo. - Assert.False(Directory.Exists(target)); - } - - [Fact] - public async Task Clone_WindowsInvalidPath_ThrowsTypedExceptionAndKeepsRepoWithPendingMarker() - { - // A tree entry whose name is invalid on Windows must surface as the structured - // InvalidRepositoryPathsException (not a raw libgit2 error). Unlike other clone - // failures, the fetched repository is KEPT — everything is already downloaded, - // so path recovery can run without hitting the network — and marked as - // checkout-pending so a later pull cannot mistake it for an up-to-date clone. - string source = CreateForgedRepo(_root, ("bad:name.txt", "unwritable on Windows")); - string target = NewPath("partial-clone"); - - var ex = await Assert.ThrowsAsync( - () => _client.CloneAsync(source, target, Token, null, CancellationToken.None)); - - var info = Assert.Single(ex.Paths); - Assert.Equal("bad:name.txt", info.RepoPath); - Assert.Contains("invalid on Windows", info.Reason); - Assert.Equal("bad_name.txt", info.SuggestedName); - Assert.True(_client.IsValidRepository(target)); - using var repo = new Repository(target); - Assert.True(repo.Config.Get("gclo.checkoutpending")?.Value); - } - - [Fact] - public async Task Clone_CanceledAfterTheFetch_ThrowsOperationCanceled_AndRemovesTheTargetDirectory() - { - // The real cancellation contract: the objects arrived, the token was observed - // before checkout, and the half-made directory does not survive to be mistaken - // for a repository on the next run. - string source = CreateSourceRepo(); - string target = NewPath("canceled-mid-clone"); - using var cts = new CancellationTokenSource(); - _client.AfterFetchForTesting = () => - { - Assert.True(Directory.Exists(Path.Combine(target, ".git")), "the fetch had completed"); - cts.Cancel(); - }; - - await Assert.ThrowsAnyAsync( - () => _client.CloneAsync(source, target, Token, null, cts.Token)); - - Assert.False(Directory.Exists(target)); - } - - [Fact] - public async Task FetchAndPull_CanceledAfterTheFetch_ThrowsOperationCanceled_AndMergesNothing() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - string tipBefore = HeadSha(target); - CommitFile(source, "update.txt", "new content", "second commit"); - using var cts = new CancellationTokenSource(); - _client.AfterFetchForTesting = cts.Cancel; - - await Assert.ThrowsAnyAsync( - () => _client.FetchAndPullAsync(target, Token, cts.Token)); - - Assert.Equal(tipBefore, HeadSha(target)); - Assert.False(File.Exists(Path.Combine(target, "update.txt"))); - - // The fetch itself landed: a later uncanceled pull is a plain fast-forward. - _client.AfterFetchForTesting = null; - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - Assert.True(File.Exists(Path.Combine(target, "update.txt"))); - } - - [Fact] - public async Task Clone_TokenAlreadyCanceled_ThrowsOperationCanceledAndCreatesNothing() - { - string source = CreateSourceRepo(); - string target = NewPath("canceled-clone"); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - await Assert.ThrowsAnyAsync( - () => _client.CloneAsync(source, target, Token, null, cts.Token)); - - Assert.False(Directory.Exists(target)); - } - - // ---------------------------------------------------------------- IsValidRepository - - [Fact] - public void IsValidRepository_MissingDirectory_ReturnsFalse() - { - Assert.False(_client.IsValidRepository(NewPath("does-not-exist"))); - } - - [Fact] - public void IsValidRepository_EmptyDirectory_ReturnsFalse() - { - string path = NewPath("empty"); - Directory.CreateDirectory(path); - - Assert.False(_client.IsValidRepository(path)); - } - - [Fact] - public void IsValidRepository_DirectoryWithUnrelatedFiles_ReturnsFalse() - { - string path = NewPath("not-a-repo"); - Directory.CreateDirectory(Path.Combine(path, "sub")); - File.WriteAllText(Path.Combine(path, "notes.txt"), "just a file"); - File.WriteAllText(Path.Combine(path, "sub", "data.bin"), "more bytes"); - - Assert.False(_client.IsValidRepository(path)); - } - - [Fact] - public void IsValidRepository_RealRepository_ReturnsTrue() - { - string source = CreateSourceRepo(); - - Assert.True(_client.IsValidRepository(source)); - } - - // ---------------------------------------------------------------- fetch & pull - - [Fact] - public async Task FetchAndPull_RemoteAhead_FastForwardsToRemoteTip() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - - string newSha = CommitFile(source, "update.txt", "new content", "second commit"); - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - Assert.Equal(newSha, HeadSha(target)); - Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt"))); - } - - [Fact] - public async Task FetchAndPull_AlreadyUpToDate_CompletesAndLeavesTipUnchanged() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - string tipBefore = HeadSha(target); - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - Assert.Equal(tipBefore, HeadSha(target)); - } - - [Fact] - public async Task FetchAndPull_IncompleteCloneLeftBehind_CompletesCheckoutInsteadOfReportingUpToDate() - { - // Simulates a clone whose checkout failed (or was killed) and whose cleanup - // could not delete the directory: .git is complete and valid, the marker is - // still set, and the working tree is partial. Commit tips are EQUAL, which is - // exactly the case that used to pass as "already up to date". - string source = CreateSourceRepo(); - string target = NewPath("half-clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - string tipBefore = HeadSha(target); - File.Delete(Path.Combine(target, "readme.txt")); - SetCheckoutPending(target, true); - Assert.True(_client.IsValidRepository(target)); - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); - Assert.Equal(tipBefore, HeadSha(target)); - Assert.False(IsCheckoutPending(target)); - } - - [Fact] - public async Task FetchAndPull_IncompleteCloneAndRemoteAhead_MaterializesTheNewTip() - { - string source = CreateSourceRepo(); - string target = NewPath("half-clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - File.Delete(Path.Combine(target, "readme.txt")); - SetCheckoutPending(target, true); - string newSha = CommitFile(source, "update.txt", "new content", "second commit"); - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - Assert.Equal(newSha, HeadSha(target)); - Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); - Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt"))); - Assert.False(IsCheckoutPending(target)); - } - - [Fact] - public async Task FetchAndPull_IncompleteCloneWhoseTipIsNowInvalidOnWindows_FailsTypedNotUpToDate() - { - // The pending path re-validates before checking out: an incomplete clone whose - // tree gained a Windows-invalid path must surface the structured failure, not - // a silent "Done" with a partial tree. (Forged entries cannot be created on a - // Windows working tree, which is why this uses the object-database fixture.) - string source = CreateSourceRepo(); - string target = NewPath("half-clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - SetCheckoutPending(target, true); - AppendForgedCommit(source, ("bad:name.txt", "unwritable on Windows")); - - if (!OperatingSystem.IsWindows()) - { - return; // the validator only runs on Windows - } - - var ex = await Assert.ThrowsAsync( - () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); - - Assert.Equal("bad:name.txt", Assert.Single(ex.Paths).RepoPath); - Assert.True(IsCheckoutPending(target), "still incomplete; the marker must survive the failure"); - } - - [Fact] - public async Task FetchAndPull_DivergedHistories_ThrowsNonFastForward() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - - // Both sides advance from the shared base: no fast-forward is possible, and a - // mirror tool must never manufacture a merge commit. - CommitFile(source, "from-remote.txt", "remote change", "remote commit"); - CommitFile(target, "from-local.txt", "local change", "local commit"); - - var ex = await Assert.ThrowsAsync( - () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); - - // Actionable, not libgit2's "cannot fast-forward": names the branch, says what - // gclo refuses to do, and gives the three ways out (#30). - Assert.Contains("local commits that origin does not have", ex.Message); - Assert.Contains("never merges", ex.Message); - Assert.Contains("re-clone", ex.Message); - Assert.IsType(ex.InnerException); - } - - [Fact] - public async Task FetchAndPull_DetachedHead_ThrowsInvalidOperationMentioningDetached() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - - using (var repo = new Repository(target)) - { - Commands.Checkout(repo, repo.Head.Tip.Sha); - Assert.True(repo.Info.IsHeadDetached); - } - - var ex = await Assert.ThrowsAsync( - () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); - - Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Check out a branch", ex.Message); - } - - [Fact] - public async Task FetchAndPull_UpstreamDefaultBranchRenamed_FollowsTheNewBranch() - { - // The routine master -> main migration: the tracked remote branch disappears, - // and a mirror must follow origin's new default instead of saying "up to - // date" forever against a pruned ref (#31). - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - string oldName; - using (var src = new Repository(source)) - { - oldName = src.Head.FriendlyName; - src.Branches.Rename(src.Head, "renamed-main"); - src.Refs.UpdateTarget("HEAD", "refs/heads/renamed-main"); - } - string newSha = CommitFile(source, "after-rename.txt", "after", "commit on the renamed branch"); - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - using var after = new Repository(target); - Assert.Equal("renamed-main", after.Head.FriendlyName); - Assert.Equal(newSha, after.Head.Tip.Sha); - Assert.Equal("origin/renamed-main", after.Head.TrackedBranch!.FriendlyName); - Assert.Equal("after", File.ReadAllText(Path.Combine(target, "after-rename.txt"))); - Assert.Null(after.Branches[oldName]); // the orphaned local branch is gone - Assert.Null(after.Branches["origin/" + oldName]); // and so is its pruned tracking ref - - // The next pull is an ordinary up-to-date pull on the new branch. - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - Assert.Equal(newSha, HeadSha(target)); - } - - [Fact] - public async Task FetchAndPull_UpstreamBranchRenamed_ButLocalHasOwnCommits_RefusesToDiscardThem() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - string localSha = CommitFile(target, "local.txt", "mine", "local work"); - using (var src = new Repository(source)) - { - src.Branches.Rename(src.Head, "renamed-main"); - src.Refs.UpdateTarget("HEAD", "refs/heads/renamed-main"); - } - - var ex = await Assert.ThrowsAsync( - () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); - - Assert.Contains("local commits", ex.Message); - Assert.Contains("never discards local work", ex.Message); - Assert.Equal(localSha, HeadSha(target)); // untouched - } - - [Fact] - public async Task FetchAndPull_BranchWithoutUpstream_FetchesButMergesNothing() - { - string source = CreateSourceRepo(); - string target = NewPath("clone"); - await _client.CloneAsync(source, target, Token, null, CancellationToken.None); - string tipBefore = HeadSha(target); - - string defaultBranch; - using (var repo = new Repository(target)) - { - defaultBranch = repo.Head.FriendlyName; - var localOnly = repo.CreateBranch("local-only"); - Commands.Checkout(repo, localOnly); - Assert.Null(repo.Head.TrackedBranch); - } - - string remoteSha = CommitFile(source, "later.txt", "later content", "remote commit"); - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - using var after = new Repository(target); - Assert.Equal("local-only", after.Head.FriendlyName); - Assert.Equal(tipBefore, after.Head.Tip.Sha); // nothing was merged... - Assert.Equal(remoteSha, after.Branches["origin/" + defaultBranch].Tip.Sha); // ...but the fetch ran - Assert.False(File.Exists(Path.Combine(target, "later.txt"))); - } - - [Fact] - public async Task FetchAndPull_UnbornHead_MaterializesBranchUpstreamAndWorkingTree() - { - // A clone of an empty origin leaves HEAD unborn: pointing at a branch with no commits. - string origin = NewPath("bare-origin"); - Repository.Init(origin, isBare: true); - - string target = NewPath("unborn-clone"); - await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); - using (var repo = new Repository(target)) - { - Assert.True(repo.Info.IsHeadUnborn); - } - - // Seed the origin through a third working copy — a local push needs no credentials. - string seeder = NewPath("seeder"); - await _client.CloneAsync(origin, seeder, Token, null, CancellationToken.None); - string seededSha = CommitFile(seeder, "seed.txt", "seeded content", "initial commit"); - using (var repo = new Repository(seeder)) - { - repo.Network.Push(repo.Network.Remotes["origin"], repo.Head.CanonicalName, new PushOptions()); - } - - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - using var after = new Repository(target); - Assert.False(after.Info.IsHeadUnborn); - Assert.Equal(seededSha, after.Head.Tip.Sha); - Assert.NotNull(after.Head.TrackedBranch); - Assert.Equal("seeded content", File.ReadAllText(Path.Combine(target, "seed.txt"))); - } - - [Fact] - public async Task FetchAndPull_UnbornHeadAndRemoteStillEmpty_ReturnsWithoutError() - { - string origin = NewPath("bare-origin"); - Repository.Init(origin, isBare: true); - - string target = NewPath("unborn-clone"); - await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); - - // Nothing to pull yet: there is no branch to materialize, and that is not an error. - await _client.FetchAndPullAsync(target, Token, CancellationToken.None); - - using var after = new Repository(target); - Assert.True(after.Info.IsHeadUnborn); - } - - // ---------------------------------------------------------------- fixture helpers - - private string NewPath(string name) => Path.Combine(_root, name); - - private static bool IsCheckoutPending(string workdir) - { - using var repo = new Repository(workdir); - return repo.Config.Get("gclo.checkoutpending")?.Value == true; - } - - private static void SetCheckoutPending(string workdir, bool value) - { - using var repo = new Repository(workdir); - repo.Config.Set("gclo.checkoutpending", value, ConfigurationLevel.Local); - } - - /// Initializes a non-bare repository under the test root with one commit. - private string CreateSourceRepo(string name = "source") - { - string path = NewPath(name); - Repository.Init(path); - CommitFile(path, "readme.txt", "hello from source", "initial commit"); - return path; - } - - /// Writes a file into the working tree, stages it, and commits; returns the new SHA. - private static string CommitFile(string workdir, string fileName, string content, string message) - { - using var repo = new Repository(workdir); - File.WriteAllText(Path.Combine(workdir, fileName), content); - Commands.Stage(repo, fileName); - var signature = MakeSignature(); - return repo.Commit(message, signature, signature).Sha; - } + private const string Token = "test-token"; + + private readonly string _root = + Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + private readonly LibGit2GitClient _client = new(); + + + + public LibGit2GitClientTests() + { + Directory.CreateDirectory(_root); + } + + + + public void Dispose() => TryDeleteDirectory(_root); + + + + // ---------------------------------------------------------------- clone + + [Fact] + public async Task Clone_LocalSource_PopulatesWorkingTreeAndReportsProgressInRange() + { + string source = CreateSourceRepo(); + string target = NewPath("clone-target"); + var progressValues = new List(); + + await _client.CloneAsync(source, target, Token, progressValues.Add, CancellationToken.None); + + Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); + Assert.True(_client.IsValidRepository(target)); + // Local-transport clones may fire few (or even zero) transfer callbacks, so do not + // require that any arrived — only that every value is a valid completion fraction. + Assert.All(progressValues, v => Assert.InRange(v, 0.0, 1.0)); + } + + + + [Fact] + public async Task Clone_Completed_LeavesCheckoutPendingMarkerCleared() + { + string source = CreateSourceRepo(); + string target = NewPath("clone-target"); + + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + // The marker is set when the fetch lands and cleared only after checkout, so a + // finished clone must read as "not pending" — the next pull trusts its tree. + Assert.False(IsCheckoutPending(target)); + } + + + + [Fact] + public async Task Clone_EmptyOrigin_LeavesCheckoutPendingMarkerCleared() + { + string origin = NewPath("bare-origin"); + Repository.Init(origin, isBare: true); + string target = NewPath("unborn-clone"); + + await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); + + // Nothing to check out means nothing is pending; the unborn-HEAD pull path + // (not the pending path) must handle this repo once the remote gets commits. + Assert.False(IsCheckoutPending(target)); + } + + + + [Fact] + public async Task Clone_SourceMissing_ThrowsAndLeavesNoTargetDirectory() + { + string missingSource = NewPath("no-such-source"); + string target = NewPath("failed-clone"); + + await Assert.ThrowsAnyAsync( + () => _client.CloneAsync(missingSource, target, Token, null, CancellationToken.None)); + + // A failed clone into a directory that did not exist before must clean up after + // itself, or the next run would treat the leftovers as an existing local repo. + Assert.False(Directory.Exists(target)); + } + + + + [Fact] + public async Task Clone_WindowsInvalidPath_ThrowsTypedExceptionAndKeepsRepoWithPendingMarker() + { + // A tree entry whose name is invalid on Windows must surface as the structured + // InvalidRepositoryPathsException (not a raw libgit2 error). Unlike other clone + // failures, the fetched repository is KEPT — everything is already downloaded, + // so path recovery can run without hitting the network — and marked as + // checkout-pending so a later pull cannot mistake it for an up-to-date clone. + string source = CreateForgedRepo(_root, ("bad:name.txt", "unwritable on Windows")); + string target = NewPath("partial-clone"); + + InvalidRepositoryPathsException ex = await Assert.ThrowsAsync( + () => _client.CloneAsync(source, target, Token, null, CancellationToken.None)); + + InvalidPathInfo info = Assert.Single(ex.Paths); + Assert.Equal("bad:name.txt", info.RepoPath); + Assert.Contains("invalid on Windows", info.Reason); + Assert.Equal("bad_name.txt", info.SuggestedName); + Assert.True(_client.IsValidRepository(target)); + using var repo = new Repository(target); + Assert.True(repo.Config.Get("gclo.checkoutpending")?.Value); + } + + + + [Fact] + public async Task Clone_CanceledAfterTheFetch_ThrowsOperationCanceled_AndRemovesTheTargetDirectory() + { + // The real cancellation contract: the objects arrived, the token was observed + // before checkout, and the half-made directory does not survive to be mistaken + // for a repository on the next run. + string source = CreateSourceRepo(); + string target = NewPath("canceled-mid-clone"); + using var cts = new CancellationTokenSource(); + _client.AfterFetchForTesting = () => + { + Assert.True(Directory.Exists(Path.Combine(target, ".git")), "the fetch had completed"); + cts.Cancel(); + }; + + await Assert.ThrowsAnyAsync( + () => _client.CloneAsync(source, target, Token, null, cts.Token)); + + Assert.False(Directory.Exists(target)); + } + + + + [Fact] + public async Task FetchAndPull_CanceledAfterTheFetch_ThrowsOperationCanceled_AndMergesNothing() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string tipBefore = HeadSha(target); + CommitFile(source, "update.txt", "new content", "second commit"); + using var cts = new CancellationTokenSource(); + _client.AfterFetchForTesting = cts.Cancel; + + await Assert.ThrowsAnyAsync( + () => _client.FetchAndPullAsync(target, Token, cts.Token)); + + Assert.Equal(tipBefore, HeadSha(target)); + Assert.False(File.Exists(Path.Combine(target, "update.txt"))); + + // The fetch itself landed: a later uncanceled pull is a plain fast-forward. + _client.AfterFetchForTesting = null; + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + Assert.True(File.Exists(Path.Combine(target, "update.txt"))); + } + + + + [Fact] + public async Task Clone_TokenAlreadyCanceled_ThrowsOperationCanceledAndCreatesNothing() + { + string source = CreateSourceRepo(); + string target = NewPath("canceled-clone"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => _client.CloneAsync(source, target, Token, null, cts.Token)); + + Assert.False(Directory.Exists(target)); + } + + + + // ---------------------------------------------------------------- IsValidRepository + + [Fact] + public void IsValidRepository_MissingDirectory_ReturnsFalse() + { + Assert.False(_client.IsValidRepository(NewPath("does-not-exist"))); + } + + + + [Fact] + public void IsValidRepository_EmptyDirectory_ReturnsFalse() + { + string path = NewPath("empty"); + Directory.CreateDirectory(path); + + Assert.False(_client.IsValidRepository(path)); + } + + + + [Fact] + public void IsValidRepository_DirectoryWithUnrelatedFiles_ReturnsFalse() + { + string path = NewPath("not-a-repo"); + Directory.CreateDirectory(Path.Combine(path, "sub")); + File.WriteAllText(Path.Combine(path, "notes.txt"), "just a file"); + File.WriteAllText(Path.Combine(path, "sub", "data.bin"), "more bytes"); + + Assert.False(_client.IsValidRepository(path)); + } + + + + [Fact] + public void IsValidRepository_RealRepository_ReturnsTrue() + { + string source = CreateSourceRepo(); + + Assert.True(_client.IsValidRepository(source)); + } + + + + // ---------------------------------------------------------------- fetch & pull + + [Fact] + public async Task FetchAndPull_RemoteAhead_FastForwardsToRemoteTip() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + string newSha = CommitFile(source, "update.txt", "new content", "second commit"); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + Assert.Equal(newSha, HeadSha(target)); + Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt"))); + } + + + + [Fact] + public async Task FetchAndPull_AlreadyUpToDate_CompletesAndLeavesTipUnchanged() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string tipBefore = HeadSha(target); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + Assert.Equal(tipBefore, HeadSha(target)); + } + + + + [Fact] + public async Task FetchAndPull_IncompleteCloneLeftBehind_CompletesCheckoutInsteadOfReportingUpToDate() + { + // Simulates a clone whose checkout failed (or was killed) and whose cleanup + // could not delete the directory: .git is complete and valid, the marker is + // still set, and the working tree is partial. Commit tips are EQUAL, which is + // exactly the case that used to pass as "already up to date". + string source = CreateSourceRepo(); + string target = NewPath("half-clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string tipBefore = HeadSha(target); + File.Delete(Path.Combine(target, "readme.txt")); + SetCheckoutPending(target, true); + Assert.True(_client.IsValidRepository(target)); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); + Assert.Equal(tipBefore, HeadSha(target)); + Assert.False(IsCheckoutPending(target)); + } + + + + [Fact] + public async Task FetchAndPull_IncompleteCloneAndRemoteAhead_MaterializesTheNewTip() + { + string source = CreateSourceRepo(); + string target = NewPath("half-clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + File.Delete(Path.Combine(target, "readme.txt")); + SetCheckoutPending(target, true); + string newSha = CommitFile(source, "update.txt", "new content", "second commit"); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + Assert.Equal(newSha, HeadSha(target)); + Assert.Equal("hello from source", File.ReadAllText(Path.Combine(target, "readme.txt"))); + Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "update.txt"))); + Assert.False(IsCheckoutPending(target)); + } + + + + [Fact] + public async Task FetchAndPull_IncompleteCloneWhoseTipIsNowInvalidOnWindows_FailsTypedNotUpToDate() + { + // The pending path re-validates before checking out: an incomplete clone whose + // tree gained a Windows-invalid path must surface the structured failure, not + // a silent "Done" with a partial tree. (Forged entries cannot be created on a + // Windows working tree, which is why this uses the object-database fixture.) + string source = CreateSourceRepo(); + string target = NewPath("half-clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + SetCheckoutPending(target, true); + AppendForgedCommit(source, ("bad:name.txt", "unwritable on Windows")); + + if(!OperatingSystem.IsWindows()) + { + return; // the validator only runs on Windows + } + + InvalidRepositoryPathsException ex = await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + + Assert.Equal("bad:name.txt", Assert.Single(ex.Paths).RepoPath); + Assert.True(IsCheckoutPending(target), "still incomplete; the marker must survive the failure"); + } + + + + [Fact] + public async Task FetchAndPull_DivergedHistories_ThrowsNonFastForward() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + // Both sides advance from the shared base: no fast-forward is possible, and a + // mirror tool must never manufacture a merge commit. + CommitFile(source, "from-remote.txt", "remote change", "remote commit"); + CommitFile(target, "from-local.txt", "local change", "local commit"); + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + + // Actionable, not libgit2's "cannot fast-forward": names the branch, says what + // gclo refuses to do, and gives the three ways out (#30). + Assert.Contains("local commits that origin does not have", ex.Message); + Assert.Contains("never merges", ex.Message); + Assert.Contains("re-clone", ex.Message); + Assert.IsType(ex.InnerException); + } + + + + [Fact] + public async Task FetchAndPull_DetachedHead_ThrowsInvalidOperationMentioningDetached() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + + using(var repo = new Repository(target)) + { + Commands.Checkout(repo, repo.Head.Tip.Sha); + Assert.True(repo.Info.IsHeadDetached); + } + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + + Assert.Contains("detached", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Check out a branch", ex.Message); + } + + + + [Fact] + public async Task FetchAndPull_UpstreamDefaultBranchRenamed_FollowsTheNewBranch() + { + // The routine master -> main migration: the tracked remote branch disappears, + // and a mirror must follow origin's new default instead of saying "up to + // date" forever against a pruned ref (#31). + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string oldName; + using(var src = new Repository(source)) + { + oldName = src.Head.FriendlyName; + src.Branches.Rename(src.Head, "renamed-main"); + src.Refs.UpdateTarget("HEAD", "refs/heads/renamed-main"); + } + string newSha = CommitFile(source, "after-rename.txt", "after", "commit on the renamed branch"); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.Equal("renamed-main", after.Head.FriendlyName); + Assert.Equal(newSha, after.Head.Tip.Sha); + Assert.Equal("origin/renamed-main", after.Head.TrackedBranch!.FriendlyName); + Assert.Equal("after", File.ReadAllText(Path.Combine(target, "after-rename.txt"))); + Assert.Null(after.Branches[oldName]); // the orphaned local branch is gone + Assert.Null(after.Branches["origin/" + oldName]); // and so is its pruned tracking ref + + // The next pull is an ordinary up-to-date pull on the new branch. + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + Assert.Equal(newSha, HeadSha(target)); + } + + + + [Fact] + public async Task FetchAndPull_UpstreamBranchRenamed_ButLocalHasOwnCommits_RefusesToDiscardThem() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string localSha = CommitFile(target, "local.txt", "mine", "local work"); + using(var src = new Repository(source)) + { + src.Branches.Rename(src.Head, "renamed-main"); + src.Refs.UpdateTarget("HEAD", "refs/heads/renamed-main"); + } + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); + + Assert.Contains("local commits", ex.Message); + Assert.Contains("never discards local work", ex.Message); + Assert.Equal(localSha, HeadSha(target)); // untouched + } + + + + [Fact] + public async Task FetchAndPull_BranchWithoutUpstream_FetchesButMergesNothing() + { + string source = CreateSourceRepo(); + string target = NewPath("clone"); + await _client.CloneAsync(source, target, Token, null, CancellationToken.None); + string tipBefore = HeadSha(target); + + string defaultBranch; + using(var repo = new Repository(target)) + { + defaultBranch = repo.Head.FriendlyName; + Branch localOnly = repo.CreateBranch("local-only"); + Commands.Checkout(repo, localOnly); + Assert.Null(repo.Head.TrackedBranch); + } + + string remoteSha = CommitFile(source, "later.txt", "later content", "remote commit"); + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.Equal("local-only", after.Head.FriendlyName); + Assert.Equal(tipBefore, after.Head.Tip.Sha); // nothing was merged... + Assert.Equal(remoteSha, after.Branches["origin/" + defaultBranch].Tip.Sha); // ...but the fetch ran + Assert.False(File.Exists(Path.Combine(target, "later.txt"))); + } + + + + [Fact] + public async Task FetchAndPull_UnbornHead_MaterializesBranchUpstreamAndWorkingTree() + { + // A clone of an empty origin leaves HEAD unborn: pointing at a branch with no commits. + string origin = NewPath("bare-origin"); + Repository.Init(origin, isBare: true); + + string target = NewPath("unborn-clone"); + await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); + using(var repo = new Repository(target)) + { + Assert.True(repo.Info.IsHeadUnborn); + } + + // Seed the origin through a third working copy — a local push needs no credentials. + string seeder = NewPath("seeder"); + await _client.CloneAsync(origin, seeder, Token, null, CancellationToken.None); + string seededSha = CommitFile(seeder, "seed.txt", "seeded content", "initial commit"); + using(var repo = new Repository(seeder)) + { + repo.Network.Push(repo.Network.Remotes["origin"], repo.Head.CanonicalName, new PushOptions()); + } + + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.False(after.Info.IsHeadUnborn); + Assert.Equal(seededSha, after.Head.Tip.Sha); + Assert.NotNull(after.Head.TrackedBranch); + Assert.Equal("seeded content", File.ReadAllText(Path.Combine(target, "seed.txt"))); + } + + + + [Fact] + public async Task FetchAndPull_UnbornHeadAndRemoteStillEmpty_ReturnsWithoutError() + { + string origin = NewPath("bare-origin"); + Repository.Init(origin, isBare: true); + + string target = NewPath("unborn-clone"); + await _client.CloneAsync(origin, target, Token, null, CancellationToken.None); + + // Nothing to pull yet: there is no branch to materialize, and that is not an error. + await _client.FetchAndPullAsync(target, Token, CancellationToken.None); + + using var after = new Repository(target); + Assert.True(after.Info.IsHeadUnborn); + } + + + + // ---------------------------------------------------------------- fixture helpers + + private string NewPath(string name) => Path.Combine(_root, name); + + + + private static bool IsCheckoutPending(string workdir) + { + using var repo = new Repository(workdir); + return repo.Config.Get("gclo.checkoutpending")?.Value == true; + } + + + + private static void SetCheckoutPending(string workdir, bool value) + { + using var repo = new Repository(workdir); + repo.Config.Set("gclo.checkoutpending", value, ConfigurationLevel.Local); + } + + + + /// Initializes a non-bare repository under the test root with one commit. + private string CreateSourceRepo(string name = "source") + { + string path = NewPath(name); + Repository.Init(path); + CommitFile(path, "readme.txt", "hello from source", "initial commit"); + return path; + } + + + + /// Writes a file into the working tree, stages it, and commits; returns the new SHA. + private static string CommitFile(string workdir, string fileName, string content, string message) + { + using var repo = new Repository(workdir); + File.WriteAllText(Path.Combine(workdir, fileName), content); + Commands.Stage(repo, fileName); + Signature signature = MakeSignature(); + return repo.Commit(message, signature, signature).Sha; + } } diff --git a/gclo.Engine.Tests/OrgSyncEngineTests.cs b/gclo.Engine.Tests/OrgSyncEngineTests.cs index 87f75d6..b762f59 100644 --- a/gclo.Engine.Tests/OrgSyncEngineTests.cs +++ b/gclo.Engine.Tests/OrgSyncEngineTests.cs @@ -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 { "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(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 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 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 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 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(() => 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(() => 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(() => CreateEngine().SyncAsync(request)); - Assert.Empty(_lister.Calls); - } + [Fact] + public async Task Sync_NullRequest_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync(() => 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( - () => 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(() => CreateEngine().SyncAsync(request)); + Assert.Empty(_lister.Calls); + } - await Assert.ThrowsAsync(() => 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(() => 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( - () => 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( + () => 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( - () => 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(() => 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( + () => 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( + () => 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); + } } diff --git a/gclo.Engine.Tests/PathRecoveryPlannerTests.cs b/gclo.Engine.Tests/PathRecoveryPlannerTests.cs index 51c9269..239a7a2 100644 --- a/gclo.Engine.Tests/PathRecoveryPlannerTests.cs +++ b/gclo.Engine.Tests/PathRecoveryPlannerTests.cs @@ -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; + /// /// 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; /// 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(() => Build(null!)); - } + + + [Fact] + public void Build_Null_Throws() + { + Assert.Throws(() => Build(null!)); + } } diff --git a/gclo.Engine.Tests/PathRecoveryTests.cs b/gclo.Engine.Tests/PathRecoveryTests.cs index 2e665b1..30a8c40 100644 --- a/gclo.Engine.Tests/PathRecoveryTests.cs +++ b/gclo.Engine.Tests/PathRecoveryTests.cs @@ -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; + /// /// 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; /// 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( - () => _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( + () => _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( - () => _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( - () => _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( - () => _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( - () => _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( - () => _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( - () => _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( - () => _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( - () => _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); - - /// Clones a repo known to hold invalid paths, asserting the typed failure; returns the kept target. - private async Task CloneExpectingInvalidPathsAsync(string source, string name) - { - string target = NewPath(name); - await Assert.ThrowsAsync( - () => _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(skips ?? [], StringComparer.Ordinal)); - - /// Adds a commit that removes one forged entry and adds another — "upstream fixed the path". - 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(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( + () => _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( + () => _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( + () => _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( + () => _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( + () => _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( + () => _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( + () => _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( + () => _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); + + + + /// Clones a repo known to hold invalid paths, asserting the typed failure; returns the kept target. + private async Task CloneExpectingInvalidPathsAsync(string source, string name) + { + string target = NewPath(name); + await Assert.ThrowsAsync( + () => _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(skips ?? [], StringComparer.Ordinal)); + + + + /// Adds a commit that removes one forged entry and adds another — "upstream fixed the path". + 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(PendingKey)?.Value == true; + } + + + + private static string RecoveryFilePath(string repoPath) + { + using var repo = new Repository(repoPath); + return Path.Combine(repo.Info.Path, "gclo-recovery.json"); + } } diff --git a/gclo.Engine.Tests/RepositoryPathResolverTests.cs b/gclo.Engine.Tests/RepositoryPathResolverTests.cs index e16ec78..17a32fe 100644 --- a/gclo.Engine.Tests/RepositoryPathResolverTests.cs +++ b/gclo.Engine.Tests/RepositoryPathResolverTests.cs @@ -1,7 +1,11 @@ -using gclo.Engine; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ namespace gclo.Engine.Tests; + /// /// 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; /// 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => RepositoryPathResolver.Resolve(root, "alpha")); + } + + + + [Fact] + public void Resolve_NullRoot_ThrowsArgumentNullException() + { + Assert.Throws(() => 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(() => 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); + } } diff --git a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs index 6e5a6d8..a8903a5 100644 --- a/gclo.Engine.Tests/SyncAllCoordinatorTests.cs +++ b/gclo.Engine.Tests/SyncAllCoordinatorTests.cs @@ -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; + /// /// Tests for over fake-backed, account-seeded /// s: strict sequential order, load-before-sync, @@ -10,342 +17,386 @@ namespace gclo.Engine.Tests; /// 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 _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(); - /// Synchronous progress: handler runs inline on the reporting thread. - private sealed class SyncProgress(Action handler) : IProgress - { - public void Report(RepoProgress value) => handler(value); - } + private readonly OrderedLister _lister = new(); - /// - /// One lister shared by every workspace, so the recorded organization order proves - /// globally which account loaded when. Behavior is scripted per organization. - /// - private sealed class OrderedLister : IRepositoryLister - { - private readonly List _organizations = new(); - private readonly object _gate = new(); + private readonly FakeOrganizationLister _orgs = new(); - /// Repositories (or exception) per organization. Default: one repo named '<org>-repo'. - public Func> Handler { get; set; } - = org => new[] { Repo(org + "-repo") }; + private readonly List _workspaces = new(); - /// Organizations listed so far, in call order. - public IReadOnlyList Organizations - { - get + private readonly SyncAllCoordinator _coordinator = new(new NullActivityLog()); + + + + public void Dispose() + { + foreach(WorkspaceViewModel workspace in _workspaces) + { + workspace.Dispose(); + } + TryDeleteDirectory(_root); + } + + + + /// Synchronous progress: handler runs inline on the reporting thread. + private sealed class SyncProgress(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + + + /// + /// One lister shared by every workspace, so the recorded organization order proves + /// globally which account loaded when. Behavior is scripted per organization. + /// + private sealed class OrderedLister : IRepositoryLister + { + private readonly List _organizations = new(); + + private readonly Lock _gate = new(); + + /// Repositories (or exception) per organization. Default: one repo named '<org>-repo'. + public Func> Handler { get; set; } + = org => new[] { Repo(org + "-repo") }; + + + + /// Organizations listed so far, in call order. + public IReadOnlyList Organizations + { + get + { + lock(_gate) { - lock (_gate) - { - return _organizations.ToArray(); - } + return _organizations.ToArray(); } - } + } + } - public Task> 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>(ex); - } - } - } - /// - /// 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. - /// - 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> 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>(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 run = _coordinator.RunAsync(new[] { alpha, beta }, CancellationToken.None); + /// + /// 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. + /// + 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 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(); - 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 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(); + 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 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); + } } diff --git a/gclo.Engine.Tests/SyncOverloadTests.cs b/gclo.Engine.Tests/SyncOverloadTests.cs index 00a2eb3..ac27459 100644 --- a/gclo.Engine.Tests/SyncOverloadTests.cs +++ b/gclo.Engine.Tests/SyncOverloadTests.cs @@ -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; + /// /// Tests for the overload that takes a caller-supplied @@ -11,122 +17,145 @@ namespace gclo.Engine.Tests; /// 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 { "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(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(), _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( - () => CreateEngine().SyncAsync(CreateRequest(), (IReadOnlyList)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(), _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( + () => CreateEngine().SyncAsync(CreateRequest(), (IReadOnlyList)null!)); + } } diff --git a/gclo.Engine.Tests/SyncRequestTests.cs b/gclo.Engine.Tests/SyncRequestTests.cs index 2608f92..6819695 100644 --- a/gclo.Engine.Tests/SyncRequestTests.cs +++ b/gclo.Engine.Tests/SyncRequestTests.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine.Tests; + /// /// Pins the security contract of : the record ships on /// NuGet, and its ToString must never print the PAT (the generated record @@ -7,15 +13,15 @@ namespace gclo.Engine.Tests; /// 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 + } } diff --git a/gclo.Engine.Tests/VaultEdgeTests.cs b/gclo.Engine.Tests/VaultEdgeTests.cs index d5f0ec4..9d3fbab 100644 --- a/gclo.Engine.Tests/VaultEdgeTests.cs +++ b/gclo.Engine.Tests/VaultEdgeTests.cs @@ -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; + /// /// 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(() => new CredentialManagerVault(isWindows: false)); + [Fact] + public void Constructor_NotWindows_ThrowsPlatformNotSupported() + => Assert.Throws(() => 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(() => 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(() => 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(() => vault.Delete(invalidTarget, Guid.NewGuid())); + Win32Exception ex = Assert.Throws(() => 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(() => 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(() => vault.Delete(invalidTarget, Guid.NewGuid())); + + Assert.Contains("CredDeleteW failed", ex.Message); + } } diff --git a/gclo.Engine.Tests/VaultTests.cs b/gclo.Engine.Tests/VaultTests.cs index a1e61bb..c5a73f4 100644 --- a/gclo.Engine.Tests/VaultTests.cs +++ b/gclo.Engine.Tests/VaultTests.cs @@ -1,87 +1,102 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.ViewModels; + namespace gclo.Engine.Tests; + /// /// Contract tests for plus one real round-trip through /// against the Windows Credential Manager. /// 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); + } + } } diff --git a/gclo.Engine.Tests/WindowsPathValidationTests.cs b/gclo.Engine.Tests/WindowsPathValidationTests.cs index e2eaafd..7c8a419 100644 --- a/gclo.Engine.Tests/WindowsPathValidationTests.cs +++ b/gclo.Engine.Tests/WindowsPathValidationTests.cs @@ -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; + /// /// Tests for Windows path validation on clone/pull: paths that are legal in git but /// invalid on Windows must surface as a structured @@ -13,317 +19,359 @@ namespace gclo.Engine.Tests; /// 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 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 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 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 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( - () => _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("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 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(), 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 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(), 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(), 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 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(), 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(), 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( + () => _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(() => WindowsPathValidator.ValidateIncoming(null!, null!, null!)); - Assert.Throws(() => 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("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( - () => _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); - /// Runs the validator over a tree containing exactly the given entry names. - private IReadOnlyList 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) - /// Creates a repo containing one file at a deep path exceeding legacy MAX_PATH. - 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(), null, null); + IReadOnlyList 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(), 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(), null, null); + IReadOnlyList 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(), 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(), null, null); + IReadOnlyList 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(() => WindowsPathValidator.ValidateIncoming(null!, null!, null!)); + Assert.Throws(() => 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( + () => _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); + + + + /// Runs the validator over a tree containing exactly the given entry names. + private IReadOnlyList 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)); + } + + + + /// Creates a repo containing one file at a deep path exceeding legacy MAX_PATH. + 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; + } } diff --git a/gclo.Engine.Tests/WorkspaceViewModelTests.cs b/gclo.Engine.Tests/WorkspaceViewModelTests.cs index 95e4bfc..cf3933d 100644 --- a/gclo.Engine.Tests/WorkspaceViewModelTests.cs +++ b/gclo.Engine.Tests/WorkspaceViewModelTests.cs @@ -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; + /// /// Headless tests for using a synchronous progress factory /// (the production default, , posts asynchronously and would @@ -11,2039 +17,2249 @@ namespace gclo.Engine.Tests; /// public sealed class WorkspaceViewModelTests : IDisposable { - private readonly FakeRepositoryLister _lister = new(); - private readonly FakeGitClient _git = new(); - private readonly FakeOrganizationLister _orgs = new(); - private readonly FakeWslCloner _wsl = new(); - - /// Every view model the test created, disposed and cleaned up after it — pass or fail. - private readonly List _created = new(); - - public void Dispose() - { - foreach (WorkspaceViewModel vm in _created) - { - vm.Dispose(); - if (vm.TargetFolder.Length > 0 && Path.IsPathRooted(vm.TargetFolder)) - { - TryDeleteDirectory(vm.TargetFolder); - } - } - } - - /// Synchronous progress: handler runs inline on the reporting thread. - private sealed class SyncProgress(Action handler) : IProgress - { - public void Report(RepoProgress value) => handler(value); - } - - /// A lister that blocks until the test releases . - private sealed class GatedLister : IRepositoryLister - { - public TaskCompletionSource> Gate { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default) - => Gate.Task; - } - - private WorkspaceViewModel CreateViewModel( - TimeSpan? debounce = null, - Account? account = null, - ITokenVault? vault = null, - AccountsStore? store = null) - => Track(new WorkspaceViewModel(_lister, _git, _orgs, - handler => new SyncProgress(handler), - debounce ?? TimeSpan.FromMilliseconds(1), - new NullActivityLog(), - account, vault, store, _wsl)); - - private WorkspaceViewModel Track(WorkspaceViewModel vm) - { - _created.Add(vm); - return vm; - } - - /// - /// A view model with valid inputs and the given repositories already loaded into the - /// table. Waits out the token-triggered org lookup first so its status message cannot - /// land on top of a later assertion. - /// - private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos) - { - _lister.Repositories = repos; - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - vm.MaxConcurrencyValue = 1; // serialize progress: the sync fake reports inline across threads - - await vm.LoadReposCommand.ExecuteAsync(null); - return vm; - } - - // ---------------------------------------------------------------- command gating - - [Fact] - public void LoadReposCommand_RequiresOrganizationAndToken() - { - var vm = CreateViewModel(); - Assert.False(vm.LoadReposCommand.CanExecute(null)); - - vm.Organization = "acme"; - Assert.False(vm.LoadReposCommand.CanExecute(null)); - - vm.Token = "token-1234567890"; - Assert.True(vm.LoadReposCommand.CanExecute(null)); - } - - [Fact] - public void SyncCommand_DisabledBeforeAnyReposLoaded() - { - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - vm.TargetFolder = @"C:\src\acme"; - - Assert.False(vm.SyncCommand.CanExecute(null)); // nothing loaded, so nothing selected - Assert.False(vm.RetryFailedCommand.CanExecute(null)); // and nothing has failed - } - - [Fact] - public async Task SyncCommand_RequiresTargetFolder_AndASelectedRepo() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - Assert.True(vm.SyncCommand.CanExecute(null)); - - vm.Repos[0].IsSelected = false; - Assert.False(vm.SyncCommand.CanExecute(null)); - - vm.Repos[0].IsSelected = true; - vm.TargetFolder = " "; - Assert.False(vm.SyncCommand.CanExecute(null)); - } - - // ---------------------------------------------------------------- load flow - - [Fact] - public async Task LoadRepos_FillsTable_AllSelected_AllQueued() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - - Assert.Equal(3, vm.Repos.Count); - Assert.Equal(3, vm.TotalCount); - Assert.Equal(0, vm.CompletedCount); - Assert.Equal("3 repositories loaded.", vm.StatusText); - Assert.False(vm.IsLoadingRepos); - Assert.True(vm.AllSelected); - Assert.All(vm.Repos, r => Assert.True(r.IsSelected)); - Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Queued, r.Status)); - Assert.Empty(_git.CloneCalls); // loading must not touch git - Assert.Empty(_git.PullCalls); - } - - [Fact] - public async Task LoadRepos_ListerFailure_SurfacesMessage_AndResetsLoading() - { - _lister.ExceptionToThrow = new InvalidOperationException("org not found"); - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - - await vm.LoadReposCommand.ExecuteAsync(null); - - Assert.Equal("org not found", vm.StatusText); - Assert.Empty(vm.Repos); - Assert.False(vm.IsLoadingRepos); - Assert.True(vm.LoadReposCommand.CanExecute(null)); - Assert.False(vm.SyncCommand.CanExecute(null)); - // The failure gets a real error surface, not just the caption live region (#30). - Assert.True(vm.LoadErrorOpen); - Assert.Equal("Could not load repositories", vm.LoadErrorTitle); - Assert.Equal("org not found", vm.LoadErrorMessage); - Assert.Null(vm.LoadedOrganization); - } - - // ---------------------------------------------------------------- connect card state - - [Fact] - public async Task HasLoadedRepos_TurnsOnAfterTheFirstSuccessfulLoad_Only() - { - _lister.ExceptionToThrow = new InvalidOperationException("org not found"); - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - Assert.False(vm.HasLoadedRepos); - - await vm.LoadReposCommand.ExecuteAsync(null); - Assert.False(vm.HasLoadedRepos); // a failed load keeps the connect card - - _lister.ExceptionToThrow = null; - _lister.Repositories = [Repo("alpha")]; - await vm.LoadReposCommand.ExecuteAsync(null); - Assert.True(vm.HasLoadedRepos); - } - - [Fact] - public async Task CanEditInputs_LocksDuringLoad_AndDuringSync() - { - var lister = new GatedLister(); - var vm = new WorkspaceViewModel(lister, _git, _orgs, - handler => new SyncProgress(handler), - TimeSpan.FromMilliseconds(1), - new NullActivityLog()); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - vm.MaxConcurrencyValue = 1; - Assert.True(vm.CanEditInputs); - - Task load = vm.LoadReposCommand.ExecuteAsync(null); - Assert.False(vm.CanEditInputs); // locked while listing - lister.Gate.SetResult([Repo("alpha")]); - await load; - Assert.True(vm.CanEditInputs); - - var gate = new TaskCompletionSource(); - int started = 0; - _git.CloneHandler = async (url, path, token, onProgress, ct) => - { - Interlocked.Increment(ref started); - // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - }; - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync(() => started > 0, "the run to start"); - Assert.False(vm.CanEditInputs); // locked while syncing - - gate.SetResult(); - await run; - Assert.True(vm.CanEditInputs); - - } - - // ---------------------------------------------------------------- sync flow - - [Fact] - public async Task LoadThenSync_SyncsOnlySelectedRepos() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - var byName = vm.Repos.ToDictionary(r => r.Name); - byName["bravo"].IsSelected = false; - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(["alpha", "charlie"], _git.ClonedRepoNames); - Assert.DoesNotContain(_git.ValidityChecks, p => Path.GetFileName(p) == "bravo"); - Assert.Empty(_git.PullCalls); - Assert.Equal(SyncStatus.Done, byName["alpha"].Status); - Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // untouched by the run - Assert.Equal(SyncStatus.Done, byName["charlie"].Status); - Assert.Equal(2, vm.CompletedCount); - Assert.Equal(2, vm.TotalCount); // run-scoped (#22): the run set was 2, not the table's 3 - Assert.Contains("2 cloned", vm.StatusText); - Assert.True(vm.HasCompletedRun); - Assert.False(vm.IsRunning); - - } - - [Fact] - public async Task Sync_TracksPulls_AndSummarizesFailures() - { - _git.IsValidRepositoryHandler = path => Path.GetFileName(path) == "alpha"; // alpha pulls - _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "charlie" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - - await vm.SyncCommand.ExecuteAsync(null); - - var byName = vm.Repos.ToDictionary(r => r.Name); - Assert.Equal(["alpha"], _git.PulledRepoNames); - Assert.Equal(SyncStatus.Done, byName["alpha"].Status); - Assert.Equal(SyncStatus.Done, byName["bravo"].Status); - Assert.Equal(SyncStatus.Failed, byName["charlie"].Status); - Assert.Contains("boom", byName["charlie"].Error); - Assert.Equal(3, vm.CompletedCount); - Assert.Contains("1 updated", vm.StatusText); - Assert.Contains("1 failed", vm.StatusText); - - } - - [Fact] - public async Task Sync_RepoFailure_RaisesAssistiveAnnouncement() - { - _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "bravo" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - var announcements = new List(); - vm.AnnouncementRequested += announcements.Add; - - await vm.SyncCommand.ExecuteAsync(null); - - var announcement = Assert.Single(announcements); - Assert.Contains("bravo", announcement); - Assert.Contains("failed", announcement); - Assert.Contains("boom", announcement); - - } - - [Fact] - public async Task Sync_AllSucceed_RaisesNoAnnouncements() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - var announcements = new List(); - vm.AnnouncementRequested += announcements.Add; - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Empty(announcements); - - } - - [Fact] - public async Task Sync_CancelCommand_CancelsRun_AndReportsCanceled() - { - var gate = new TaskCompletionSource(); - int started = 0; - _git.CloneHandler = async (url, path, token, onProgress, ct) => - { - Interlocked.Increment(ref started); - // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - ct.ThrowIfCancellationRequested(); - }; - - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync(() => started > 0, "first clone to start"); - - Assert.True(vm.IsRunning); - Assert.False(vm.SyncCommand.CanExecute(null)); // all three commands lock while running - Assert.False(vm.LoadReposCommand.CanExecute(null)); - Assert.False(vm.RetryFailedCommand.CanExecute(null)); - - vm.SyncCancelCommand.Execute(null); - gate.SetResult(); - await run; - - Assert.StartsWith("Canceled", vm.StatusText); - Assert.False(vm.IsRunning); - Assert.True(vm.HasCompletedRun); - Assert.All(vm.Repos, r => Assert.True( - r.Status is SyncStatus.Canceled or SyncStatus.Done or SyncStatus.Failed, - $"{r.Name} left in non-terminal state {r.Status}")); - - } - - // ---------------------------------------------------------------- run-scoped progress - - [Fact] - public async Task Sync_SubsetRun_ScopesProgressToTheRunSet() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - var byName = vm.Repos.ToDictionary(r => r.Name); - byName["bravo"].IsSelected = false; - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(2, vm.TotalCount); // the two selected rows, not the table's three (#22) - Assert.Equal(2, vm.CompletedCount); - Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // unselected row untouched - - // The run size sticks until the next load resets the counters to the table. - await vm.LoadReposCommand.ExecuteAsync(null); - Assert.Equal(3, vm.TotalCount); - Assert.Equal(0, vm.CompletedCount); - - } - - // ---------------------------------------------------------------- active strip - - [Fact] - public async Task ActiveRepos_TracksInFlightRows_AndEmptiesWhenTheRunEnds() - { - var gateAlpha = new TaskCompletionSource(); - var gateBravo = new TaskCompletionSource(); - _git.CloneHandler = async (url, path, token, onProgress, ct) => - { - Task gate = Path.GetFileName(path) == "alpha" ? gateAlpha.Task : gateBravo.Task; - // CancellationToken.None: the gates must not be cancelable — the timeout is the safety valve. - await gate.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - }; - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); // concurrency 1 - var byName = vm.Repos.ToDictionary(r => r.Name); - Assert.Empty(vm.ActiveRepos); - - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync( - () => byName["alpha"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1, - "alpha to enter the active strip"); - Assert.Equal("alpha", Assert.Single(vm.ActiveRepos).Name); // bounded by parallelism (1) - - gateAlpha.SetResult(); - await WaitUntilAsync( - () => byName["bravo"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1, - "alpha to leave and bravo to enter the active strip"); - Assert.Equal("bravo", Assert.Single(vm.ActiveRepos).Name); // alpha was removed on Done - - gateBravo.SetResult(); - await run; - - Assert.Empty(vm.ActiveRepos); // cleared when the run ends - Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status)); - - } - - // ---------------------------------------------------------------- retry failed - - [Fact] - public async Task RetryFailed_ReRunsOnlyTheFailedRepos() - { - _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "bravo" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - await vm.SyncCommand.ExecuteAsync(null); - - var byName = vm.Repos.ToDictionary(r => r.Name); - Assert.Equal(SyncStatus.Failed, byName["bravo"].Status); - Assert.True(vm.RetryFailedCommand.CanExecute(null)); - int clonesBeforeRetry = _git.CloneCalls.Count; - - _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone - await vm.RetryFailedCommand.ExecuteAsync(null); - - Assert.Equal(["bravo"], _git.ClonedRepoNames.Skip(clonesBeforeRetry)); - Assert.Equal(SyncStatus.Done, byName["bravo"].Status); - Assert.Null(byName["bravo"].Error); - Assert.Equal(SyncStatus.Done, byName["alpha"].Status); // untouched by the retry - Assert.True(byName["bravo"].IsSelected); // retry selected exactly the failures - Assert.False(byName["alpha"].IsSelected); - Assert.False(byName["charlie"].IsSelected); - Assert.Equal(1, vm.TotalCount); // run-scoped (#22): the retry run was just bravo - Assert.Equal(1, vm.CompletedCount); - Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore - - } - - // ---------------------------------------------------------------- run results - - [Fact] - public async Task Sync_AllSucceed_ReportsASuccessResult() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - Assert.False(vm.ResultOpen); // nothing to show before the first run - Assert.Equal(RunResultKind.None, vm.ResultKind); - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.True(vm.ResultOpen); - Assert.Equal(RunResultKind.Success, vm.ResultKind); - Assert.Contains("2 cloned", vm.ResultMessage); - - } - - [Fact] - public async Task Sync_WithAFailure_ReportsAPartialFailureResult() - { - _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "bravo" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.True(vm.ResultOpen); - Assert.Equal(RunResultKind.PartialFailure, vm.ResultKind); - Assert.Contains("1 failed", vm.ResultMessage); - - } - - [Fact] - public async Task Sync_Canceled_ReportsACanceledResult() - { - var gate = new TaskCompletionSource(); - int started = 0; - _git.CloneHandler = async (url, path, token, onProgress, ct) => - { - Interlocked.Increment(ref started); - // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - ct.ThrowIfCancellationRequested(); - }; - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync(() => started > 0, "first clone to start"); - vm.SyncCancelCommand.Execute(null); - gate.SetResult(); - await run; - - Assert.True(vm.ResultOpen); - Assert.Equal(RunResultKind.Canceled, vm.ResultKind); - Assert.StartsWith("Canceled", vm.ResultMessage); - - } - - [Fact] - public async Task Sync_RunLevelError_ReportsAnErrorResult() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - // A file squats on the target root, so the engine's CreateDirectory throws - // before any repository is processed. - Directory.CreateDirectory(Path.GetDirectoryName(vm.TargetFolder)!); - File.WriteAllText(vm.TargetFolder, "not a directory"); - try - { - await vm.SyncCommand.ExecuteAsync(null); - - Assert.True(vm.ResultOpen); - Assert.Equal(RunResultKind.Error, vm.ResultKind); - Assert.Equal(vm.StatusText, vm.ResultMessage); // the error line, verbatim - Assert.NotEmpty(vm.ResultMessage); - Assert.False(vm.IsRunning); - } - finally - { - File.Delete(vm.TargetFolder); - } - } - - [Fact] - public async Task Sync_NextRun_ResetsTheResultSurface_WhileRunning() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - Assert.True(vm.ResultOpen); - - var gate = new TaskCompletionSource(); - int started = 0; - _git.CloneHandler = async (url, path, token, onProgress, ct) => - { - Interlocked.Increment(ref started); - // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - }; - - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync(() => started > 0, "second run to start"); - - Assert.False(vm.ResultOpen); // the stale result is gone while the new run is live - Assert.Equal(RunResultKind.None, vm.ResultKind); - Assert.Equal("", vm.ResultMessage); - - gate.SetResult(); - await run; - - Assert.True(vm.ResultOpen); - Assert.Equal(RunResultKind.Success, vm.ResultKind); - - } - - [Fact] - public async Task LoadRepos_ClearsTheResultSurface() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - Assert.True(vm.ResultOpen); - - await vm.LoadReposCommand.ExecuteAsync(null); - - Assert.False(vm.ResultOpen); - Assert.Equal(RunResultKind.None, vm.ResultKind); - Assert.Equal("", vm.ResultMessage); - - } - - // ---------------------------------------------------------------- path recovery - - private static readonly IReadOnlyList SampleInvalidPaths = - [ - new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_"), + private readonly FakeRepositoryLister _lister = new(); + + private readonly FakeGitClient _git = new(); + + private readonly FakeOrganizationLister _orgs = new(); + + private readonly FakeWslCloner _wsl = new(); + + /// Every view model the test created, disposed and cleaned up after it — pass or fail. + private readonly List _created = new(); + + + + public void Dispose() + { + foreach(WorkspaceViewModel vm in _created) + { + vm.Dispose(); + if(vm.TargetFolder.Length > 0 && Path.IsPathRooted(vm.TargetFolder)) + { + TryDeleteDirectory(vm.TargetFolder); + } + } + } + + + + /// Synchronous progress: handler runs inline on the reporting thread. + private sealed class SyncProgress(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + + + /// A lister that blocks until the test releases . + private sealed class GatedLister : IRepositoryLister + { + public TaskCompletionSource> Gate { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + + + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + => Gate.Task; + } + + + + private WorkspaceViewModel CreateViewModel( + TimeSpan? debounce = null, + Account? account = null, + ITokenVault? vault = null, + AccountsStore? store = null) + => Track(new WorkspaceViewModel(_lister, _git, _orgs, + handler => new SyncProgress(handler), + debounce ?? TimeSpan.FromMilliseconds(1), + new NullActivityLog(), + account, vault, store, _wsl)); + + + + private WorkspaceViewModel Track(WorkspaceViewModel vm) + { + _created.Add(vm); + return vm; + } + + + + /// + /// A view model with valid inputs and the given repositories already loaded into the + /// table. Waits out the token-triggered org lookup first so its status message cannot + /// land on top of a later assertion. + /// + private async Task CreateLoadedViewModelAsync(params RepoDescriptor[] repos) + { + _lister.Repositories = repos; + WorkspaceViewModel vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.MaxConcurrencyValue = 1; // serialize progress: the sync fake reports inline across threads + + await vm.LoadReposCommand.ExecuteAsync(null); + return vm; + } + + + + // ---------------------------------------------------------------- command gating + + [Fact] + public void LoadReposCommand_RequiresOrganizationAndToken() + { + WorkspaceViewModel vm = CreateViewModel(); + Assert.False(vm.LoadReposCommand.CanExecute(null)); + + vm.Organization = "acme"; + Assert.False(vm.LoadReposCommand.CanExecute(null)); + + vm.Token = "token-1234567890"; + Assert.True(vm.LoadReposCommand.CanExecute(null)); + } + + + + [Fact] + public void SyncCommand_DisabledBeforeAnyReposLoaded() + { + WorkspaceViewModel vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + vm.TargetFolder = @"C:\src\acme"; + + Assert.False(vm.SyncCommand.CanExecute(null)); // nothing loaded, so nothing selected + Assert.False(vm.RetryFailedCommand.CanExecute(null)); // and nothing has failed + } + + + + [Fact] + public async Task SyncCommand_RequiresTargetFolder_AndASelectedRepo() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + Assert.True(vm.SyncCommand.CanExecute(null)); + + vm.Repos[0].IsSelected = false; + Assert.False(vm.SyncCommand.CanExecute(null)); + + vm.Repos[0].IsSelected = true; + vm.TargetFolder = " "; + Assert.False(vm.SyncCommand.CanExecute(null)); + } + + + + // ---------------------------------------------------------------- load flow + + [Fact] + public async Task LoadRepos_FillsTable_AllSelected_AllQueued() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + + Assert.Equal(3, vm.Repos.Count); + Assert.Equal(3, vm.TotalCount); + Assert.Equal(0, vm.CompletedCount); + Assert.Equal("3 repositories loaded.", vm.StatusText); + Assert.False(vm.IsLoadingRepos); + Assert.True(vm.AllSelected); + Assert.All(vm.Repos, r => Assert.True(r.IsSelected)); + Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Queued, r.Status)); + Assert.Empty(_git.CloneCalls); // loading must not touch git + Assert.Empty(_git.PullCalls); + } + + + + [Fact] + public async Task LoadRepos_ListerFailure_SurfacesMessage_AndResetsLoading() + { + _lister.ExceptionToThrow = new InvalidOperationException("org not found"); + WorkspaceViewModel vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + + await vm.LoadReposCommand.ExecuteAsync(null); + + Assert.Equal("org not found", vm.StatusText); + Assert.Empty(vm.Repos); + Assert.False(vm.IsLoadingRepos); + Assert.True(vm.LoadReposCommand.CanExecute(null)); + Assert.False(vm.SyncCommand.CanExecute(null)); + // The failure gets a real error surface, not just the caption live region (#30). + Assert.True(vm.LoadErrorOpen); + Assert.Equal("Could not load repositories", vm.LoadErrorTitle); + Assert.Equal("org not found", vm.LoadErrorMessage); + Assert.Null(vm.LoadedOrganization); + } + + + + // ---------------------------------------------------------------- connect card state + + [Fact] + public async Task HasLoadedRepos_TurnsOnAfterTheFirstSuccessfulLoad_Only() + { + _lister.ExceptionToThrow = new InvalidOperationException("org not found"); + WorkspaceViewModel vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + Assert.False(vm.HasLoadedRepos); + + await vm.LoadReposCommand.ExecuteAsync(null); + Assert.False(vm.HasLoadedRepos); // a failed load keeps the connect card + + _lister.ExceptionToThrow = null; + _lister.Repositories = [Repo("alpha")]; + await vm.LoadReposCommand.ExecuteAsync(null); + Assert.True(vm.HasLoadedRepos); + } + + + + [Fact] + public async Task CanEditInputs_LocksDuringLoad_AndDuringSync() + { + var lister = new GatedLister(); + var vm = new WorkspaceViewModel(lister, _git, _orgs, + handler => new SyncProgress(handler), + TimeSpan.FromMilliseconds(1), + new NullActivityLog()); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + vm.MaxConcurrencyValue = 1; + Assert.True(vm.CanEditInputs); + + Task load = vm.LoadReposCommand.ExecuteAsync(null); + Assert.False(vm.CanEditInputs); // locked while listing + lister.Gate.SetResult([Repo("alpha")]); + await load; + Assert.True(vm.CanEditInputs); + + var gate = new TaskCompletionSource(); + int started = 0; + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Interlocked.Increment(ref started); + // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + }; + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => started > 0, "the run to start"); + Assert.False(vm.CanEditInputs); // locked while syncing + + gate.SetResult(); + await run; + Assert.True(vm.CanEditInputs); + + } + + + + // ---------------------------------------------------------------- sync flow + + [Fact] + public async Task LoadThenSync_SyncsOnlySelectedRepos() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + byName["bravo"].IsSelected = false; + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(["alpha", "charlie"], _git.ClonedRepoNames); + Assert.DoesNotContain(_git.ValidityChecks, p => string.Equals(Path.GetFileName(p), "bravo", StringComparison.Ordinal)); + Assert.Empty(_git.PullCalls); + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); + Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // untouched by the run + Assert.Equal(SyncStatus.Done, byName["charlie"].Status); + Assert.Equal(2, vm.CompletedCount); + Assert.Equal(2, vm.TotalCount); // run-scoped (#22): the run set was 2, not the table's 3 + Assert.Contains("2 cloned", vm.StatusText); + Assert.True(vm.HasCompletedRun); + Assert.False(vm.IsRunning); + + } + + + + [Fact] + public async Task Sync_TracksPulls_AndSummarizesFailures() + { + _git.IsValidRepositoryHandler = path => string.Equals(Path.GetFileName(path), "alpha", StringComparison.Ordinal); // alpha pulls + _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "charlie" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + + await vm.SyncCommand.ExecuteAsync(null); + + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + Assert.Equal(["alpha"], _git.PulledRepoNames); + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); + Assert.Equal(SyncStatus.Done, byName["bravo"].Status); + Assert.Equal(SyncStatus.Failed, byName["charlie"].Status); + Assert.Contains("boom", byName["charlie"].Error); + Assert.Equal(3, vm.CompletedCount); + Assert.Contains("1 updated", vm.StatusText); + Assert.Contains("1 failed", vm.StatusText); + + } + + + + [Fact] + public async Task Sync_RepoFailure_RaisesAssistiveAnnouncement() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "bravo" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + var announcements = new List(); + vm.AnnouncementRequested += (_, e) => announcements.Add(e.Message); + + await vm.SyncCommand.ExecuteAsync(null); + + string announcement = Assert.Single(announcements); + Assert.Contains("bravo", announcement); + Assert.Contains("failed", announcement); + Assert.Contains("boom", announcement); + + } + + + + [Fact] + public async Task Sync_AllSucceed_RaisesNoAnnouncements() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + var announcements = new List(); + vm.AnnouncementRequested += (_, e) => announcements.Add(e.Message); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Empty(announcements); + + } + + + + [Fact] + public async Task Sync_CancelCommand_CancelsRun_AndReportsCanceled() + { + var gate = new TaskCompletionSource(); + int started = 0; + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Interlocked.Increment(ref started); + // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + ct.ThrowIfCancellationRequested(); + }; + + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => started > 0, "first clone to start"); + + Assert.True(vm.IsRunning); + Assert.False(vm.SyncCommand.CanExecute(null)); // all three commands lock while running + Assert.False(vm.LoadReposCommand.CanExecute(null)); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); + + vm.SyncCancelCommand.Execute(null); + gate.SetResult(); + await run; + + Assert.StartsWith("Canceled", vm.StatusText); + Assert.False(vm.IsRunning); + Assert.True(vm.HasCompletedRun); + Assert.All(vm.Repos, r => Assert.True( + r.Status is SyncStatus.Canceled or SyncStatus.Done or SyncStatus.Failed, + $"{r.Name} left in non-terminal state {r.Status}")); + + } + + + + // ---------------------------------------------------------------- run-scoped progress + + [Fact] + public async Task Sync_SubsetRun_ScopesProgressToTheRunSet() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + byName["bravo"].IsSelected = false; + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(2, vm.TotalCount); // the two selected rows, not the table's three (#22) + Assert.Equal(2, vm.CompletedCount); + Assert.Equal(SyncStatus.Queued, byName["bravo"].Status); // unselected row untouched + + // The run size sticks until the next load resets the counters to the table. + await vm.LoadReposCommand.ExecuteAsync(null); + Assert.Equal(3, vm.TotalCount); + Assert.Equal(0, vm.CompletedCount); + + } + + + + // ---------------------------------------------------------------- active strip + + [Fact] + public async Task ActiveRepos_TracksInFlightRows_AndEmptiesWhenTheRunEnds() + { + var gateAlpha = new TaskCompletionSource(); + var gateBravo = new TaskCompletionSource(); + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Task gate = string.Equals(Path.GetFileName(path), "alpha", StringComparison.Ordinal) ? gateAlpha.Task : gateBravo.Task; + // CancellationToken.None: the gates must not be cancelable — the timeout is the safety valve. + await gate.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + }; + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); // concurrency 1 + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + Assert.Empty(vm.ActiveRepos); + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync( + () => byName["alpha"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1, + "alpha to enter the active strip"); + Assert.Equal("alpha", Assert.Single(vm.ActiveRepos).Name); // bounded by parallelism (1) + + gateAlpha.SetResult(); + await WaitUntilAsync( + () => byName["bravo"].Status == SyncStatus.Cloning && vm.ActiveRepos.Count == 1, + "alpha to leave and bravo to enter the active strip"); + Assert.Equal("bravo", Assert.Single(vm.ActiveRepos).Name); // alpha was removed on Done + + gateBravo.SetResult(); + await run; + + Assert.Empty(vm.ActiveRepos); // cleared when the run ends + Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Done, r.Status)); + + } + + + + // ---------------------------------------------------------------- retry failed + + [Fact] + public async Task RetryFailed_ReRunsOnlyTheFailedRepos() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "bravo" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + await vm.SyncCommand.ExecuteAsync(null); + + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + Assert.Equal(SyncStatus.Failed, byName["bravo"].Status); + Assert.True(vm.RetryFailedCommand.CanExecute(null)); + int clonesBeforeRetry = _git.CloneCalls.Count; + + _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone + await vm.RetryFailedCommand.ExecuteAsync(null); + + Assert.Equal(["bravo"], _git.ClonedRepoNames.Skip(clonesBeforeRetry), StringComparer.Ordinal); + Assert.Equal(SyncStatus.Done, byName["bravo"].Status); + Assert.Null(byName["bravo"].Error); + Assert.Equal(SyncStatus.Done, byName["alpha"].Status); // untouched by the retry + Assert.True(byName["bravo"].IsSelected); // retry selected exactly the failures + Assert.False(byName["alpha"].IsSelected); + Assert.False(byName["charlie"].IsSelected); + Assert.Equal(1, vm.TotalCount); // run-scoped (#22): the retry run was just bravo + Assert.Equal(1, vm.CompletedCount); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore + + } + + + + // ---------------------------------------------------------------- run results + + [Fact] + public async Task Sync_AllSucceed_ReportsASuccessResult() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + Assert.False(vm.ResultOpen); // nothing to show before the first run + Assert.Equal(RunResultKind.None, vm.ResultKind); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.True(vm.ResultOpen); + Assert.Equal(RunResultKind.Success, vm.ResultKind); + Assert.Contains("2 cloned", vm.ResultMessage); + + } + + + + [Fact] + public async Task Sync_WithAFailure_ReportsAPartialFailureResult() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "bravo" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.True(vm.ResultOpen); + Assert.Equal(RunResultKind.PartialFailure, vm.ResultKind); + Assert.Contains("1 failed", vm.ResultMessage); + + } + + + + [Fact] + public async Task Sync_Canceled_ReportsACanceledResult() + { + var gate = new TaskCompletionSource(); + int started = 0; + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Interlocked.Increment(ref started); + // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + ct.ThrowIfCancellationRequested(); + }; + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => started > 0, "first clone to start"); + vm.SyncCancelCommand.Execute(null); + gate.SetResult(); + await run; + + Assert.True(vm.ResultOpen); + Assert.Equal(RunResultKind.Canceled, vm.ResultKind); + Assert.StartsWith("Canceled", vm.ResultMessage); + + } + + + + [Fact] + public async Task Sync_RunLevelError_ReportsAnErrorResult() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + // A file squats on the target root, so the engine's CreateDirectory throws + // before any repository is processed. + Directory.CreateDirectory(Path.GetDirectoryName(vm.TargetFolder)!); + File.WriteAllText(vm.TargetFolder, "not a directory"); + try + { + await vm.SyncCommand.ExecuteAsync(null); + + Assert.True(vm.ResultOpen); + Assert.Equal(RunResultKind.Error, vm.ResultKind); + Assert.Equal(vm.StatusText, vm.ResultMessage); // the error line, verbatim + Assert.NotEmpty(vm.ResultMessage); + Assert.False(vm.IsRunning); + } + finally + { + File.Delete(vm.TargetFolder); + } + } + + + + [Fact] + public async Task Sync_NextRun_ResetsTheResultSurface_WhileRunning() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + Assert.True(vm.ResultOpen); + + var gate = new TaskCompletionSource(); + int started = 0; + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Interlocked.Increment(ref started); + // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + }; + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => started > 0, "second run to start"); + + Assert.False(vm.ResultOpen); // the stale result is gone while the new run is live + Assert.Equal(RunResultKind.None, vm.ResultKind); + Assert.Equal("", vm.ResultMessage); + + gate.SetResult(); + await run; + + Assert.True(vm.ResultOpen); + Assert.Equal(RunResultKind.Success, vm.ResultKind); + + } + + + + [Fact] + public async Task LoadRepos_ClearsTheResultSurface() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + Assert.True(vm.ResultOpen); + + await vm.LoadReposCommand.ExecuteAsync(null); + + Assert.False(vm.ResultOpen); + Assert.Equal(RunResultKind.None, vm.ResultKind); + Assert.Equal("", vm.ResultMessage); + + } + + + + // ---------------------------------------------------------------- path recovery + + private static readonly IReadOnlyList s_sampleInvalidPaths = + [ + new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_"), new InvalidPathInfo("docs/bad:name.txt", "contains a character that is invalid on Windows", "bad_name.txt"), ]; - /// Clones of 'alpha' fail with ; everything else succeeds. - private void MakeAlphaFailWithInvalidPaths() - => _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "alpha" - ? Task.FromException(new InvalidRepositoryPathsException(SampleInvalidPaths)) - : Task.CompletedTask; - [Fact] - public async Task Sync_InvalidPathFailure_FlowsPayloadToRow_AndEnablesResolve() - { - MakeAlphaFailWithInvalidPaths(); - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - await vm.SyncCommand.ExecuteAsync(null); + /// Clones of 'alpha' fail with ; everything else succeeds. + private void MakeAlphaFailWithInvalidPaths() + => _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "alpha" +, StringComparison.Ordinal) ? Task.FromException(new InvalidRepositoryPathsException(s_sampleInvalidPaths)) + : Task.CompletedTask; - var byName = vm.Repos.ToDictionary(r => r.Name); - Assert.Equal(SyncStatus.Failed, byName["alpha"].Status); - Assert.True(byName["alpha"].HasPathIssue); - Assert.Equal(SampleInvalidPaths, byName["alpha"].InvalidPaths); - Assert.Equal(SyncStatus.Done, byName["bravo"].Status); - Assert.False(byName["bravo"].HasPathIssue); - Assert.Null(byName["bravo"].InvalidPaths); - Assert.True(vm.ResolvePathsCommand.CanExecute(byName["alpha"])); - Assert.False(vm.ResolvePathsCommand.CanExecute(byName["bravo"])); // no path issue - Assert.False(vm.ResolvePathsCommand.CanExecute(null)); - } + [Fact] + public async Task Sync_InvalidPathFailure_FlowsPayloadToRow_AndEnablesResolve() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - [Fact] - public async Task ResolvePaths_AppliesRecovery_AndMarksRowDone() - { - MakeAlphaFailWithInvalidPaths(); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; + await vm.SyncCommand.ExecuteAsync(null); - var recovery = new PathRecovery( - new Dictionary { ["aux"] = "aux_" }, - new HashSet { "docs/bad:name.txt" }); - var asked = new List(); - vm.RecoveryInteraction = item => - { - asked.Add(item); - return Task.FromResult(new PathRecoveryDecision.Apply(recovery)); - }; + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + Assert.Equal(SyncStatus.Failed, byName["alpha"].Status); + Assert.True(byName["alpha"].HasPathIssue); + Assert.Equal(s_sampleInvalidPaths, byName["alpha"].InvalidPaths); + Assert.Equal(SyncStatus.Done, byName["bravo"].Status); + Assert.False(byName["bravo"].HasPathIssue); + Assert.Null(byName["bravo"].InvalidPaths); - await vm.ResolvePathsCommand.ExecuteAsync(row); + Assert.True(vm.ResolvePathsCommand.CanExecute(byName["alpha"])); + Assert.False(vm.ResolvePathsCommand.CanExecute(byName["bravo"])); // no path issue + Assert.False(vm.ResolvePathsCommand.CanExecute(null)); - Assert.Equal([row], asked); - ApplyRecoveryCall call = Assert.Single(_git.ApplyRecoveryCalls); - Assert.Equal(Path.Combine(vm.EffectiveTargetRoot, "alpha"), call.LocalPath); - Assert.Same(recovery, call.Recovery); + } - Assert.Equal(SyncStatus.Done, row.Status); - Assert.Null(row.Error); - Assert.Null(row.InvalidPaths); - Assert.False(row.HasPathIssue); - Assert.False(vm.ResolvePathsCommand.CanExecute(row)); // nothing left to resolve - Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore - } - [Fact] - public async Task ResolvePaths_ShowsPulling_WhileTheRecoveryIsInFlight() - { - MakeAlphaFailWithInvalidPaths(); - var gate = new TaskCompletionSource(); - _git.ApplyRecoveryHandler = async (_, _, _) => - // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); + [Fact] + public async Task ResolvePaths_AppliesRecovery_AndMarksRowDone() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; - Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row); - await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight recovery to show"); + var recovery = new PathRecovery( + new Dictionary(StringComparer.Ordinal) { ["aux"] = "aux_" }, + new HashSet(StringComparer.Ordinal) { "docs/bad:name.txt" }); + var asked = new List(); + vm.RecoveryInteraction = item => + { + asked.Add(item); + return Task.FromResult(new PathRecoveryDecision.Apply(recovery)); + }; - Assert.True(row.ShowProgress); // the row's indeterminate bar is visible - Assert.True(row.IsIndeterminate); + await vm.ResolvePathsCommand.ExecuteAsync(row); - gate.SetResult(); - await resolve; + Assert.Equal([row], asked); + ApplyRecoveryCall call = Assert.Single(_git.ApplyRecoveryCalls); + Assert.Equal(Path.Combine(vm.EffectiveTargetRoot, "alpha"), call.LocalPath); + Assert.Same(recovery, call.Recovery); - Assert.Equal(SyncStatus.Done, row.Status); + Assert.Equal(SyncStatus.Done, row.Status); + Assert.Null(row.Error); + Assert.Null(row.InvalidPaths); + Assert.False(row.HasPathIssue); + Assert.False(vm.ResolvePathsCommand.CanExecute(row)); // nothing left to resolve + Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore - } + } - [Fact] - public async Task ResolvePaths_InteractionReturnsNull_LeavesRowFailed() - { - MakeAlphaFailWithInvalidPaths(); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult(null); - await vm.ResolvePathsCommand.ExecuteAsync(row); - Assert.Empty(_git.ApplyRecoveryCalls); // canceled: nothing applied - Assert.Equal(SyncStatus.Failed, row.Status); - Assert.True(row.HasPathIssue); // payload kept for another attempt - Assert.True(vm.ResolvePathsCommand.CanExecute(row)); + [Fact] + public async Task ResolvePaths_ShowsPulling_WhileTheRecoveryIsInFlight() + { + MakeAlphaFailWithInvalidPaths(); + var gate = new TaskCompletionSource(); + _git.ApplyRecoveryHandler = async (_, _, _) => + // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)))); - } + Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row); + await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight recovery to show"); - [Fact] - public async Task ResolvePaths_ApplyStillInvalid_KeepsRowFailed_WithFreshPayload() - { - MakeAlphaFailWithInvalidPaths(); - var stillInvalid = new List + Assert.True(row.ShowProgress); // the row's indeterminate bar is visible + Assert.True(row.IsIndeterminate); + + gate.SetResult(); + await resolve; + + Assert.Equal(SyncStatus.Done, row.Status); + + } + + + + [Fact] + public async Task ResolvePaths_InteractionReturnsNull_LeavesRowFailed() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(null); + + await vm.ResolvePathsCommand.ExecuteAsync(row); + + Assert.Empty(_git.ApplyRecoveryCalls); // canceled: nothing applied + Assert.Equal(SyncStatus.Failed, row.Status); + Assert.True(row.HasPathIssue); // payload kept for another attempt + Assert.True(vm.ResolvePathsCommand.CanExecute(row)); + + } + + + + [Fact] + public async Task ResolvePaths_ApplyStillInvalid_KeepsRowFailed_WithFreshPayload() + { + MakeAlphaFailWithInvalidPaths(); + var stillInvalid = new List { new("aux_", "differs only by case from 'AUX_'", null), }; - _git.ApplyRecoveryHandler = (_, _, _) => - Task.FromException(new InvalidRepositoryPathsException(stillInvalid)); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); - - await vm.ResolvePathsCommand.ExecuteAsync(row); - - Assert.Single(_git.ApplyRecoveryCalls); - Assert.Equal(SyncStatus.Failed, row.Status); - Assert.Contains("aux_", row.Error); - Assert.Equal(stillInvalid, row.InvalidPaths); // the fresh list, ready for another round - Assert.True(vm.ResolvePathsCommand.CanExecute(row)); - - } - - [Fact] - public async Task ResolvePaths_ApplyFailsForAnotherReason_ClearsPayload() - { - MakeAlphaFailWithInvalidPaths(); - _git.ApplyRecoveryHandler = (_, _, _) => - Task.FromException(new IOException("disk full")); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); - - await vm.ResolvePathsCommand.ExecuteAsync(row); - - Assert.Equal(SyncStatus.Failed, row.Status); - Assert.Equal("disk full", row.Error); - Assert.False(row.HasPathIssue); // renaming again would not help - - } - - // ---------------------------------------------------------------- clone in WSL (#8) - - [Fact] - public async Task ResolvePaths_ProbesWslOnce_BeforeTheInteraction_AndExposesAvailability() - { - MakeAlphaFailWithInvalidPaths(); - _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - Assert.False(vm.IsWslCloneAvailable); // not probed until recovery is requested - Assert.Equal("", vm.WslAvailabilityDetail); - - bool? seenDuringInteraction = null; - vm.RecoveryInteraction = _ => - { - seenDuringInteraction = vm.IsWslCloneAvailable; - return Task.FromResult(null); - }; - - await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); - await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); - - Assert.True(seenDuringInteraction); - Assert.True(vm.IsWslCloneAvailable); - Assert.Equal("git version 2.53.0", vm.WslAvailabilityDetail); - Assert.Equal(1, _wsl.Probes); // cached after the first request - } - - [Fact] - public async Task ResolvePaths_WslUnavailable_StaysUnavailable_WithTheReason() - { - MakeAlphaFailWithInvalidPaths(); - _wsl.Availability = new WslAvailability(false, "wsl.exe is not installed"); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - vm.RecoveryInteraction = _ => Task.FromResult(null); - - await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); - - Assert.False(vm.IsWslCloneAvailable); - Assert.Equal("wsl.exe is not installed", vm.WslAvailabilityDetail); - } - - [Fact] - public async Task ResolvePaths_CloneInWsl_ClonesWithTheRowsUrlAndTheWorkspaceToken_AndMarksRowDone() - { - MakeAlphaFailWithInvalidPaths(); - _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); - - await vm.ResolvePathsCommand.ExecuteAsync(row); - - WslCloneCall call = Assert.Single(_wsl.CloneCalls); - Assert.Equal(row.Descriptor.CloneUrl, call.Url); - Assert.Equal(vm.Organization, call.Organization); - Assert.Equal("alpha", call.RepositoryName); - Assert.Equal(vm.Token, call.Token); - Assert.Empty(_git.ApplyRecoveryCalls); // the Windows copy is left alone - - Assert.Equal(SyncStatus.Done, row.Status); - Assert.Null(row.Error); - Assert.Null(row.InvalidPaths); - Assert.False(row.HasPathIssue); - Assert.True(row.HasNote); - Assert.Equal(@"Cloned in WSL (Ubuntu): \\wsl.localhost\Ubuntu\home\me\gclo\" + vm.Organization + @"\alpha", row.Note); - Assert.False(vm.ResolvePathsCommand.CanExecute(row)); - Assert.False(vm.RetryFailedCommand.CanExecute(null)); - Assert.False(vm.IsResolvingPaths); - } - - [Fact] - public async Task ResolvePaths_CloneInWsl_ShowsAnIndeterminateBar_AndLocksTheWorkspace_WhileInFlight() - { - MakeAlphaFailWithInvalidPaths(); - _wsl.Availability = new WslAvailability(true, "git"); - var gate = new TaskCompletionSource(); - _wsl.CloneHandler = (_, _) => gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); - - Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row); - await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight WSL clone to show"); - - Assert.True(row.IsIndeterminate); - Assert.True(vm.IsResolvingPaths); - Assert.False(vm.SyncCommand.CanExecute(null)); - - gate.SetResult(new WslCloneResult("Ubuntu", "/home/me/gclo/o/alpha", @"\\wsl.localhost\Ubuntu\home\me\gclo\o\alpha")); - await resolve; - - Assert.Equal(SyncStatus.Done, row.Status); - Assert.False(vm.IsResolvingPaths); - Assert.True(vm.SyncCommand.CanExecute(null)); - } - - [Fact] - public async Task ResolvePaths_CloneInWslFails_KeepsRowFailed_WithPayload_ForAnotherAttempt() - { - MakeAlphaFailWithInvalidPaths(); - _wsl.Availability = new WslAvailability(true, "git"); - _wsl.CloneHandler = (_, _) => Task.FromException( - new WslCloneException("Clone in WSL failed: fatal: Authentication failed")); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); - - await vm.ResolvePathsCommand.ExecuteAsync(row); - - Assert.Equal(SyncStatus.Failed, row.Status); - Assert.Equal("Clone in WSL failed: fatal: Authentication failed", row.Error); - Assert.True(row.HasPathIssue); // the rename/skip route is still open - Assert.False(row.HasNote); - Assert.True(vm.ResolvePathsCommand.CanExecute(row)); - Assert.True(vm.RetryFailedCommand.CanExecute(null)); - Assert.False(vm.IsResolvingPaths); - } - - [Fact] - public void RepoItem_Note_RaisesHasNote() - { - var item = new RepoItemViewModel(Repo("alpha")); - var changed = new List(); - item.PropertyChanged += (_, e) => changed.Add(e.PropertyName); - Assert.False(item.HasNote); - - item.Note = "Cloned in WSL"; - - Assert.True(item.HasNote); - Assert.True(item.HasDetails); // the row-details strip shows for a note alone - Assert.Contains(nameof(RepoItemViewModel.Note), changed); - Assert.Contains(nameof(RepoItemViewModel.HasNote), changed); - Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed); - - changed.Clear(); - item.Note = null; - Assert.False(item.HasDetails); - item.Error = "boom"; - Assert.True(item.HasDetails); // and for an error alone - Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed); - } - - [Fact] - public async Task ResolvePaths_DisabledWhileARunIsInFlight() - { - MakeAlphaFailWithInvalidPaths(); - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - await vm.SyncCommand.ExecuteAsync(null); - var byName = vm.Repos.ToDictionary(r => r.Name); - RepoItemViewModel alpha = byName["alpha"]; - Assert.True(vm.ResolvePathsCommand.CanExecute(alpha)); - - // Second run touches only bravo, so alpha keeps its payload throughout. - alpha.IsSelected = false; - var gate = new TaskCompletionSource(); - int started = 0; - _git.CloneHandler = async (url, path, token, onProgress, ct) => - { - Interlocked.Increment(ref started); - // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - }; - - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync(() => started > 0, "second run to start"); - Assert.True(alpha.HasPathIssue); - Assert.False(vm.ResolvePathsCommand.CanExecute(alpha)); // locked while running - - gate.SetResult(); - await run; - Assert.True(vm.ResolvePathsCommand.CanExecute(alpha)); // unlocked again - - } - - [Fact] - public async Task Sync_ReRun_ClearsAStalePathPayload() - { - MakeAlphaFailWithInvalidPaths(); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - RepoItemViewModel row = vm.Repos[0]; - Assert.True(row.HasPathIssue); - - _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // upstream fixed the paths - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(SyncStatus.Done, row.Status); - Assert.Null(row.InvalidPaths); - Assert.False(row.HasPathIssue); - Assert.False(vm.ResolvePathsCommand.CanExecute(row)); - - } - - // ---------------------------------------------------------------- load errors and stale tables (#30) - - [Fact] - public async Task LoadRepos_StartingALoad_ClosesThePreviousLoadError() - { - _lister.ExceptionToThrow = new InvalidOperationException("boom"); - var vm = CreateViewModel(); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - await vm.LoadReposCommand.ExecuteAsync(null); - Assert.True(vm.LoadErrorOpen); - - _lister.ExceptionToThrow = null; - _lister.Repositories = Repos("alpha"); - await vm.LoadReposCommand.ExecuteAsync(null); - - Assert.False(vm.LoadErrorOpen); - Assert.Equal("acme", vm.LoadedOrganization); - Assert.True(vm.HasLoadedRepos); - } - - [Fact] - public async Task OrgLookupFailure_OpensTheErrorSurface_AndASuccessfulLookupClosesIt() - { - _orgs.Handler = (_, _) => Task.FromException>( - new InvalidOperationException("GitHub rejected the token (401). Check the PAT.")); - var vm = CreateViewModel(); - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface"); - - Assert.Equal("Could not list organizations", vm.LoadErrorTitle); - Assert.Contains("401", vm.LoadErrorMessage); - Assert.Equal(vm.LoadErrorMessage, vm.StatusText); - - _orgs.Handler = (_, _) => Task.FromResult>(["me", "acme"]); - vm.Token = "token-1234567890-fixed"; - await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to succeed"); - - Assert.False(vm.LoadErrorOpen); - } - - [Fact] - public async Task EditingTheOrganizationAfterALoad_MarksTheTableStale_AndBlocksSync() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - Assert.False(vm.IsTableStale); - Assert.Equal("", vm.StaleTableMessage); - Assert.True(vm.SyncCommand.CanExecute(null)); - var raised = new List(); - vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); - - vm.Organization = "acme-labs"; - - Assert.True(vm.IsTableStale); - Assert.Contains("'acme'", vm.StaleTableMessage); - Assert.Contains("'acme-labs'", vm.StaleTableMessage); - Assert.False(vm.SyncCommand.CanExecute(null), "syncing a stale table would clone the wrong org"); - Assert.Contains(nameof(WorkspaceViewModel.IsTableStale), raised); - Assert.Contains(nameof(WorkspaceViewModel.StaleTableMessage), raised); - - // Putting it back (case and whitespace are not a change) clears the state... - vm.Organization = " ACME "; - Assert.False(vm.IsTableStale); - Assert.True(vm.SyncCommand.CanExecute(null)); - - // ...and so does reloading under the new name. - vm.Organization = "acme-labs"; - await vm.LoadReposCommand.ExecuteAsync(null); - Assert.Equal("acme-labs", vm.LoadedOrganization); - Assert.False(vm.IsTableStale); - Assert.True(vm.SyncCommand.CanExecute(null)); - } - - [Fact] - public void BeforeAnyLoad_TheTableIsNeverStale() - { - var vm = CreateViewModel(); - vm.Organization = "anything"; - Assert.False(vm.IsTableStale); - } - - // ---------------------------------------------------------------- selection - - [Fact] - public async Task AllSelected_ActsOnVisibleRowsOnly_AndReflectsThem() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - vm.NameFilter = "al"; // alpha only - Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name)); - Assert.True(vm.AllSelected); - - vm.AllSelected = false; - - // Only the visible row changed; hidden rows keep their selection and still sync. - Assert.False(vm.Repos[0].IsSelected); - Assert.True(vm.Repos[1].IsSelected); - Assert.True(vm.Repos[2].IsSelected); - Assert.Equal(2, vm.SelectedCount); - Assert.True(vm.SyncCommand.CanExecute(null)); - - // The header reflects the visible set as the filter changes. - vm.NameFilter = ""; - Assert.False(vm.AllSelected); - vm.NameFilter = "br"; - Assert.True(vm.AllSelected); - - vm.AllSelected = true; // no-op on the visible row (already selected) - Assert.False(vm.Repos[0].IsSelected); - } - - [Fact] - public async Task AllSelected_HeaderPush_ReQueriesSyncOncePerBatch() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); - int canExecuteRaised = 0; - vm.SyncCommand.CanExecuteChanged += (_, _) => canExecuteRaised++; - - vm.AllSelected = false; - - // One notification for the whole batch, not one per row (#30). - Assert.Equal(1, canExecuteRaised); - } - - [Fact] - public async Task AllSelected_TogglesEveryRow_AndFollowsItemChanges() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - Assert.True(vm.AllSelected); - - vm.AllSelected = false; - Assert.All(vm.Repos, r => Assert.False(r.IsSelected)); - Assert.False(vm.SyncCommand.CanExecute(null)); // nothing selected - - vm.AllSelected = true; - Assert.All(vm.Repos, r => Assert.True(r.IsSelected)); - Assert.True(vm.SyncCommand.CanExecute(null)); - - vm.Repos[1].IsSelected = false; - Assert.False(vm.AllSelected); - Assert.True(vm.Repos[0].IsSelected); // header recompute must not cascade to other rows - Assert.True(vm.Repos[2].IsSelected); - - vm.Repos[1].IsSelected = true; - Assert.True(vm.AllSelected); - } - - [Fact] - public async Task SelectedCount_AndSyncButtonLabel_FollowSelectionChanges() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - var raised = new List(); - vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); - - Assert.Equal(3, vm.SelectedCount); - Assert.Equal("Sync 3 repos", vm.SyncButtonLabel); - - vm.Repos[0].IsSelected = false; - vm.Repos[1].IsSelected = false; - Assert.Equal(1, vm.SelectedCount); - Assert.Equal("Sync 1 repo", vm.SyncButtonLabel); // singular - Assert.Contains(nameof(WorkspaceViewModel.SyncButtonLabel), raised); - - vm.Repos[2].IsSelected = false; - Assert.Equal(0, vm.SelectedCount); - Assert.Equal("Sync 0 repos", vm.SyncButtonLabel); - - vm.AllSelected = true; // header toggle recomputes too - Assert.Equal(3, vm.SelectedCount); - Assert.Equal("Sync 3 repos", vm.SyncButtonLabel); - } - - // ---------------------------------------------------------------- sorting - - [Fact] - public async Task Sort_ByName_TogglesDirection_AndSwitchingColumnResetsIt() - { - var vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"), Repo("charlie")); - - vm.SortCommand.Execute("Name"); - Assert.Equal(["alpha", "bravo", "charlie"], vm.Repos.Select(r => r.Name)); - Assert.Equal("Name", vm.SortColumn); - Assert.False(vm.SortDescending); - - vm.SortCommand.Execute("Name"); - Assert.Equal(["charlie", "bravo", "alpha"], vm.Repos.Select(r => r.Name)); - Assert.True(vm.SortDescending); - - vm.SortCommand.Execute("Status"); - Assert.Equal("Status", vm.SortColumn); - Assert.False(vm.SortDescending); // direction resets on a new column - } - - [Fact] - public async Task Sort_KeepsFilteredReposInTheNewOrder_AndMembershipUpdatesStillLandInPlace() - { - var vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("alpha"), Repo("bravo")); - vm.SortCommand.Execute("Name"); - Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name)); - - // A progress-driven membership change after a sort must respect the sorted - // order (the position map is rebuilt by Sort). - vm.Filter = RepoFilter.Failed; - Assert.Empty(vm.FilteredRepos); - _git.CloneHandler = (_, path, _, _, _) => - Path.GetFileName(path) is "charlie" or "alpha" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(["alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name)); - } - - [Fact] - public async Task Sort_ByStatus_OrdersByLifecycle_AndIsStable() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); - var byName = vm.Repos.ToDictionary(r => r.Name); - byName["alpha"].Status = SyncStatus.Done; - byName["bravo"].Status = SyncStatus.Failed; - byName["charlie"].Status = SyncStatus.Done; - - vm.SortCommand.Execute("Status"); - - // Done sorts before Failed; the two Done rows keep their load order (stable sort). - Assert.Equal(["alpha", "charlie", "bravo"], vm.Repos.Select(r => r.Name)); - } - - // ---------------------------------------------------------------- filtering - - [Fact] - public async Task FilteredRepos_DefaultAll_MirrorsTheTableAfterLoad() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - - Assert.Equal(RepoFilter.All, vm.Filter); - Assert.Equal(vm.Repos, vm.FilteredRepos); - } - - [Fact] - public async Task Filter_RebuildsFilteredRepos_PerMode() - { - var vm = await CreateLoadedViewModelAsync( - Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); - var byName = vm.Repos.ToDictionary(r => r.Name); - byName["alpha"].Status = SyncStatus.Failed; - byName["bravo"].Status = SyncStatus.Cloning; - byName["charlie"].Status = SyncStatus.Pulling; - // delta stays Queued - - vm.Filter = RepoFilter.Active; - Assert.Equal(["bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name)); - - vm.Filter = RepoFilter.Failed; - Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name)); - - vm.Filter = RepoFilter.Pending; - Assert.Equal(["delta"], vm.FilteredRepos.Select(r => r.Name)); - - vm.Filter = RepoFilter.All; - Assert.Equal(["alpha", "bravo", "charlie", "delta"], vm.FilteredRepos.Select(r => r.Name)); - } - - [Fact] - public async Task ProgressTransitions_UpdateFilteredReposInPlace_WithoutAReset() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); - vm.Filter = RepoFilter.Pending; - Assert.Equal(4, vm.FilteredRepos.Count); - var actions = new List(); - vm.FilteredRepos.CollectionChanged += (_, e) => actions.Add(e.Action); - - var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _git.CloneHandler = async (_, path, _, _, _) => - { - if (Path.GetFileName(path) == "bravo") - { - await gate.Task; - } - }; - var run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync(() => vm.Repos[1].Status == SyncStatus.Cloning, "bravo to start"); - - // Rows leave Pending one by one as they start: removals, never a Reset. - Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions); - Assert.DoesNotContain(vm.Repos[1], vm.FilteredRepos); - - gate.SetResult(); - await run; - Assert.Empty(vm.FilteredRepos); - Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions); - - // And a later transition back into the filtered set inserts in table order. - vm.Filter = RepoFilter.Failed; - _git.CloneHandler = (_, path, _, _, _) => - Path.GetFileName(path) is "delta" or "alpha" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - actions.Clear(); - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(["alpha", "delta"], vm.FilteredRepos.Select(r => r.Name)); - Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions); - } - - [Fact] - public async Task FailedReports_AreLoggedOnTheReportingThread_NotTheUiHandler() - { - var log = new ThreadRecordingLog(); - _lister.Repositories = Repos("alpha"); - var vm = new WorkspaceViewModel(_lister, _git, _orgs, - handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), log); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - await vm.LoadReposCommand.ExecuteAsync(null); - int reportingThread = -1; - _git.CloneHandler = (_, _, _, _, _) => - { - reportingThread = Environment.CurrentManagedThreadId; - return Task.FromException(new InvalidOperationException("boom")); - }; - - await vm.SyncCommand.ExecuteAsync(null); - - var entry = Assert.Single(log.Errors, e => e.Message.StartsWith("alpha failed", StringComparison.Ordinal)); - Assert.Contains("boom", entry.Message); - // The engine reports from the thread that ran the git operation; the fake's - // synchronous progress means the log call happened right there, before any - // UI-marshaled handler could have run. - Assert.Equal(reportingThread, entry.Thread); - } - - private sealed class ThreadRecordingLog : IActivityLog - { - public List<(string Message, int Thread)> Errors { get; } = []; - - public void Info(string message) - { - } - - public void Error(string message, Exception? exception = null) - => Errors.Add((message, Environment.CurrentManagedThreadId)); - - public string LogDirectory => ""; - - public string CurrentLogFilePath => ""; - } - - [Fact] - public async Task Filter_WithNoMatchingRows_LeavesFilteredReposEmpty() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - - vm.Filter = RepoFilter.Failed; // nothing has failed - - Assert.Empty(vm.FilteredRepos); - Assert.NotEmpty(vm.Repos); // the empty-filter hint case: rows exist, none match - } - - [Fact] - public async Task NameFilter_CaseInsensitiveSubstring_ComposesWithStatusFilter() - { - var vm = await CreateLoadedViewModelAsync( - Repo("platform-api"), Repo("platform-web"), Repo("tools")); - vm.Repos.First(r => r.Name == "platform-api").Status = SyncStatus.Failed; - - vm.NameFilter = "PLATFORM"; - Assert.Equal(["platform-api", "platform-web"], vm.FilteredRepos.Select(r => r.Name)); - - vm.Filter = RepoFilter.Failed; // AND-composes with the name filter - Assert.Equal(["platform-api"], vm.FilteredRepos.Select(r => r.Name)); - - vm.NameFilter = ""; - vm.Filter = RepoFilter.All; - Assert.Equal(3, vm.FilteredRepos.Count); - } - - [Fact] - public async Task NameFilter_TrimsBeforeMatching() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - - vm.NameFilter = " alpha "; - - Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name)); - } - - [Fact] - public async Task BranchFilter_MatchesTheDisplayedBranchText() - { - var vm = await CreateLoadedViewModelAsync( - Repo("alpha", branch: "main"), - Repo("bravo", branch: "develop"), - Repo("empty", branch: null)); // empty repository: BranchText is "" - - vm.BranchFilter = "dev"; - - Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name)); - } - - [Fact] - public async Task ArchivedFilter_TriState_AllArchivedUnarchived() - { - var vm = await CreateLoadedViewModelAsync( - Repo("alpha", archived: true), Repo("bravo")); - - vm.ArchivedFilter = true; - Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name)); - - vm.ArchivedFilter = false; - Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name)); - - vm.ArchivedFilter = null; - Assert.Equal(2, vm.FilteredRepos.Count); - } + _git.ApplyRecoveryHandler = (_, _, _) => + Task.FromException(new InvalidRepositoryPathsException(stillInvalid)); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)))); - [Fact] - public async Task ColumnFilters_NeverTouchSelection_HiddenRowsStillSync() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - Assert.True(vm.Repos.All(r => r.IsSelected)); // load selects everything + await vm.ResolvePathsCommand.ExecuteAsync(row); - vm.NameFilter = "alpha"; // bravo is hidden but stays selected + Assert.Single(_git.ApplyRecoveryCalls); + Assert.Equal(SyncStatus.Failed, row.Status); + Assert.Contains("aux_", row.Error); + Assert.Equal(stillInvalid, row.InvalidPaths); // the fresh list, ready for another round + Assert.True(vm.ResolvePathsCommand.CanExecute(row)); + + } + + + + [Fact] + public async Task ResolvePaths_ApplyFailsForAnotherReason_ClearsPayload() + { + MakeAlphaFailWithInvalidPaths(); + _git.ApplyRecoveryHandler = (_, _, _) => + Task.FromException(new IOException("disk full")); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)))); + + await vm.ResolvePathsCommand.ExecuteAsync(row); + + Assert.Equal(SyncStatus.Failed, row.Status); + Assert.Equal("disk full", row.Error); + Assert.False(row.HasPathIssue); // renaming again would not help + + } + + + + // ---------------------------------------------------------------- clone in WSL (#8) + + [Fact] + public async Task ResolvePaths_ProbesWslOnce_BeforeTheInteraction_AndExposesAvailability() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + Assert.False(vm.IsWslCloneAvailable); // not probed until recovery is requested + Assert.Equal("", vm.WslAvailabilityDetail); + + bool? seenDuringInteraction = null; + vm.RecoveryInteraction = _ => + { + seenDuringInteraction = vm.IsWslCloneAvailable; + return Task.FromResult(null); + }; + + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + + Assert.True(seenDuringInteraction); + Assert.True(vm.IsWslCloneAvailable); + Assert.Equal("git version 2.53.0", vm.WslAvailabilityDetail); + Assert.Equal(1, _wsl.Probes); // cached after the first request + } + + + + [Fact] + public async Task ResolvePaths_WslUnavailable_StaysUnavailable_WithTheReason() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(false, "wsl.exe is not installed"); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + vm.RecoveryInteraction = _ => Task.FromResult(null); + + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + + Assert.False(vm.IsWslCloneAvailable); + Assert.Equal("wsl.exe is not installed", vm.WslAvailabilityDetail); + } + + + + [Fact] + public async Task ResolvePaths_CloneInWsl_ClonesWithTheRowsUrlAndTheWorkspaceToken_AndMarksRowDone() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git version 2.53.0"); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + await vm.ResolvePathsCommand.ExecuteAsync(row); + + WslCloneCall call = Assert.Single(_wsl.CloneCalls); + Assert.Equal(row.Descriptor.CloneUrl, call.Url); + Assert.Equal(vm.Organization, call.Organization); + Assert.Equal("alpha", call.RepositoryName); + Assert.Equal(vm.Token, call.Token); + Assert.Empty(_git.ApplyRecoveryCalls); // the Windows copy is left alone + + Assert.Equal(SyncStatus.Done, row.Status); + Assert.Null(row.Error); + Assert.Null(row.InvalidPaths); + Assert.False(row.HasPathIssue); + Assert.True(row.HasNote); + Assert.Equal(@"Cloned in WSL (Ubuntu): \\wsl.localhost\Ubuntu\home\me\gclo\" + vm.Organization + @"\alpha", row.Note); + Assert.False(vm.ResolvePathsCommand.CanExecute(row)); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); + Assert.False(vm.IsResolvingPaths); + } + + + + [Fact] + public async Task ResolvePaths_CloneInWsl_ShowsAnIndeterminateBar_AndLocksTheWorkspace_WhileInFlight() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git"); + var gate = new TaskCompletionSource(); + _wsl.CloneHandler = (_, _) => gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + Task resolve = vm.ResolvePathsCommand.ExecuteAsync(row); + await WaitUntilAsync(() => row.Status == SyncStatus.Pulling, "the in-flight WSL clone to show"); + + Assert.True(row.IsIndeterminate); + Assert.True(vm.IsResolvingPaths); + Assert.False(vm.SyncCommand.CanExecute(null)); + + gate.SetResult(new WslCloneResult("Ubuntu", "/home/me/gclo/o/alpha", @"\\wsl.localhost\Ubuntu\home\me\gclo\o\alpha")); + await resolve; + + Assert.Equal(SyncStatus.Done, row.Status); + Assert.False(vm.IsResolvingPaths); + Assert.True(vm.SyncCommand.CanExecute(null)); + } + + + + [Fact] + public async Task ResolvePaths_CloneInWslFails_KeepsRowFailed_WithPayload_ForAnotherAttempt() + { + MakeAlphaFailWithInvalidPaths(); + _wsl.Availability = new WslAvailability(true, "git"); + _wsl.CloneHandler = (_, _) => Task.FromException( + new WslCloneException("Clone in WSL failed: fatal: Authentication failed")); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + vm.RecoveryInteraction = _ => Task.FromResult(new PathRecoveryDecision.CloneInWsl()); + + await vm.ResolvePathsCommand.ExecuteAsync(row); + + Assert.Equal(SyncStatus.Failed, row.Status); + Assert.Equal("Clone in WSL failed: fatal: Authentication failed", row.Error); + Assert.True(row.HasPathIssue); // the rename/skip route is still open + Assert.False(row.HasNote); + Assert.True(vm.ResolvePathsCommand.CanExecute(row)); + Assert.True(vm.RetryFailedCommand.CanExecute(null)); + Assert.False(vm.IsResolvingPaths); + } + + + + [Fact] + public void RepoItem_Note_RaisesHasNote() + { + var item = new RepoItemViewModel(Repo("alpha")); + var changed = new List(); + item.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + Assert.False(item.HasNote); + + item.Note = "Cloned in WSL"; + + Assert.True(item.HasNote); + Assert.True(item.HasDetails); // the row-details strip shows for a note alone + Assert.Contains(nameof(RepoItemViewModel.Note), changed, StringComparer.Ordinal); + Assert.Contains(nameof(RepoItemViewModel.HasNote), changed, StringComparer.Ordinal); + Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed, StringComparer.Ordinal); + + changed.Clear(); + item.Note = null; + Assert.False(item.HasDetails); + item.Error = "boom"; + Assert.True(item.HasDetails); // and for an error alone + Assert.Contains(nameof(RepoItemViewModel.HasDetails), changed, StringComparer.Ordinal); + } + + + + [Fact] + public async Task ResolvePaths_DisabledWhileARunIsInFlight() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + await vm.SyncCommand.ExecuteAsync(null); + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + RepoItemViewModel alpha = byName["alpha"]; + Assert.True(vm.ResolvePathsCommand.CanExecute(alpha)); + + // Second run touches only bravo, so alpha keeps its payload throughout. + alpha.IsSelected = false; + var gate = new TaskCompletionSource(); + int started = 0; + _git.CloneHandler = async (url, path, token, onProgress, ct) => + { + Interlocked.Increment(ref started); + // CancellationToken.None: the gate must not be cancelable — the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + }; + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => started > 0, "second run to start"); + Assert.True(alpha.HasPathIssue); + Assert.False(vm.ResolvePathsCommand.CanExecute(alpha)); // locked while running + + gate.SetResult(); + await run; + Assert.True(vm.ResolvePathsCommand.CanExecute(alpha)); // unlocked again + + } + + + + [Fact] + public async Task Sync_ReRun_ClearsAStalePathPayload() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + RepoItemViewModel row = vm.Repos[0]; + Assert.True(row.HasPathIssue); + + _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // upstream fixed the paths + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(SyncStatus.Done, row.Status); + Assert.Null(row.InvalidPaths); + Assert.False(row.HasPathIssue); + Assert.False(vm.ResolvePathsCommand.CanExecute(row)); + + } + + + + // ---------------------------------------------------------------- load errors and stale tables (#30) + + [Fact] + public async Task LoadRepos_StartingALoad_ClosesThePreviousLoadError() + { + _lister.ExceptionToThrow = new InvalidOperationException("boom"); + WorkspaceViewModel vm = CreateViewModel(); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + await vm.LoadReposCommand.ExecuteAsync(null); + Assert.True(vm.LoadErrorOpen); + + _lister.ExceptionToThrow = null; + _lister.Repositories = Repos("alpha"); + await vm.LoadReposCommand.ExecuteAsync(null); + + Assert.False(vm.LoadErrorOpen); + Assert.Equal("acme", vm.LoadedOrganization); + Assert.True(vm.HasLoadedRepos); + } + + + + [Fact] + public async Task OrgLookupFailure_OpensTheErrorSurface_AndASuccessfulLookupClosesIt() + { + _orgs.Handler = (_, _) => Task.FromException>( + new InvalidOperationException("GitHub rejected the token (401). Check the PAT.")); + WorkspaceViewModel vm = CreateViewModel(); + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.LoadErrorOpen, "lookup failure to surface"); + + Assert.Equal("Could not list organizations", vm.LoadErrorTitle); + Assert.Contains("401", vm.LoadErrorMessage); + Assert.Equal(vm.LoadErrorMessage, vm.StatusText); - Assert.Equal(2, vm.SelectedCount); - Assert.Contains("2", vm.SyncButtonLabel); - } - - [Fact] - public async Task FilteredRepos_FollowsTerminalTransitions_AcrossARun() - { - _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "bravo" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - vm.Filter = RepoFilter.Failed; - Assert.Empty(vm.FilteredRepos); - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name)); - - } - - [Fact] - public async Task FilteredRepos_PreservesTheTableSortOrder() - { - var vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"), Repo("charlie")); - - vm.SortCommand.Execute("Name"); - - Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name)); - } - - // ---------------------------------------------------------------- target preview - - [Fact] - public void TargetPreview_ReflectsOrgSubfolder_AndRaisesChange() - { - var vm = CreateViewModel(); - var raised = new List(); - vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); - - vm.Organization = "acme"; - vm.TargetFolder = @"C:\src"; - - Assert.Equal(@"C:\src", vm.EffectiveTargetRoot); - Assert.Equal(@"C:\src\my-repo", vm.TargetPreview); - - raised.Clear(); - vm.CreateOrgSubfolder = true; - - Assert.Equal(@"C:\src\acme", vm.EffectiveTargetRoot); - Assert.Equal(@"C:\src\acme\my-repo", vm.TargetPreview); - Assert.Contains(nameof(WorkspaceViewModel.EffectiveTargetRoot), raised); - Assert.Contains(nameof(WorkspaceViewModel.TargetPreview), raised); - } - - [Fact] - public async Task TargetPreview_UsesFirstLoadedRepoName() - { - var vm = await CreateLoadedViewModelAsync(Repo("zulu"), Repo("alpha")); - - Assert.Equal(Path.Combine(vm.TargetFolder, "zulu"), vm.TargetPreview); - - vm.CreateOrgSubfolder = true; - Assert.Equal(Path.Combine(vm.TargetFolder, "acme", "zulu"), vm.TargetPreview); - } - - // ---------------------------------------------------------------- org lookup - - [Fact] - public async Task TokenChange_PopulatesOrganizations_AfterDebounce() - { - _orgs.Handler = (_, _) => Task.FromResult>(["KofTwentyTwo", "acme"]); - var vm = CreateViewModel(); - - vm.Token = "token-1234567890"; - - await WaitUntilAsync(() => vm.Organizations.Count == 2, "organizations to load"); - Assert.Equal(["KofTwentyTwo", "acme"], vm.Organizations); - Assert.Contains("Found 2", vm.StatusText); - Assert.False(vm.IsLoadingOrgs); - } - - [Fact] - public async Task TokenChange_OnlyPersonalLoginVisible_ExplainsAndAllowsManualEntry() - { - // The production lister always includes the token's own account, so a - // single-entry answer means no organizations were visible. - _orgs.Handler = (_, _) => Task.FromResult>(["KofTwentyTwo"]); - var vm = CreateViewModel(); - - vm.Token = "token-1234567890"; - - await WaitUntilAsync(() => vm.StatusText.Length > 0, "status text"); - Assert.Equal( - "Only your personal account is visible — add read:org (classic) for organizations, " - + "or type an org name manually.", - vm.StatusText); - Assert.Equal(["KofTwentyTwo"], vm.Organizations); - } - - [Fact] - public async Task TokenChange_ToShortTokenDuringLookup_ClearsTheLoadingFlag() - { - // The first lookup blocks inside the lister; editing the token to something - // implausibly short cancels it and returns early — the early return owns the - // flag now, so it must not leave the spinner stuck on. - var gate = new TaskCompletionSource>( - TaskCreationOptions.RunContinuationsAsynchronously); - _orgs.Handler = (_, _) => gate.Task; - var vm = CreateViewModel(); - - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.IsLoadingOrgs, "the first lookup to start"); - - vm.Token = "short"; - - Assert.False(vm.IsLoadingOrgs); - Assert.Empty(vm.Organizations); - gate.SetResult(["acme"]); // release the abandoned lookup; it was canceled and is ignored - } - - [Fact] - public async Task TokenChange_LookupFailure_SurfacesMessage_WithoutThrowing() - { - _orgs.Handler = (_, _) => Task.FromException>( - new InvalidOperationException("GitHub rejected the token (401). Check the PAT.")); - var vm = CreateViewModel(); - - vm.Token = "token-1234567890"; - - await WaitUntilAsync(() => vm.StatusText.Contains("401"), "error to surface"); - Assert.False(vm.IsLoadingOrgs); - } - - [Fact] - public async Task TokenChange_TooShort_ClearsListWithoutLookup() - { - _orgs.Handler = (_, _) => Task.FromResult>(["acme"]); - var vm = CreateViewModel(); - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.Organizations.Count == 1, "initial load"); - - vm.Token = "short"; - - Assert.Empty(vm.Organizations); - Assert.Equal(1, _orgs.Calls); // no second lookup for an implausible token - } - - [Fact] - public async Task TokenChange_RapidEdits_OnlyNewestLookupLands() - { - _orgs.Handler = (token, _) => Task.FromResult>([token[^4..]]); - // The two assignments below must land inside one debounce window; a - // loaded CI runner can pause a test for hundreds of milliseconds between - // two lines, so the window is generous (this is the one wall-clock test). - var vm = CreateViewModel(debounce: TimeSpan.FromMilliseconds(1500)); - - vm.Token = "token-1234567890-AAAA"; - vm.Token = "token-1234567890-BBBB"; // supersedes within the debounce window - - await WaitUntilAsync(() => vm.Organizations.Count == 1, "debounced load"); - Assert.Equal("BBBB", vm.Organizations[0]); - Assert.Equal(1, _orgs.Calls); - } - - // ---------------------------------------------------------------- lifecycle - - [Fact] - public async Task Dispose_MidOrgLookup_CancelsTheLookup_AndALateResultCannotMutateState() - { - var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); - CancellationToken seen = default; - _orgs.Handler = (_, ct) => - { - seen = ct; - return gate.Task.WaitAsync(ct); - }; - var vm = CreateViewModel(); - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => _orgs.Calls == 1, "lookup to start"); - - vm.Dispose(); - - Assert.True(seen.IsCancellationRequested, "the in-flight lookup's token must be canceled"); - gate.SetResult(["late"]); - await Task.Yield(); - Assert.Empty(vm.Organizations); // the canceled wait threw before the result could land - } - - [Fact] - public void Dispose_Twice_IsANoOp() - { - var vm = CreateViewModel(); - vm.Dispose(); - vm.Dispose(); // must not throw (the lookup token source is already gone) - Assert.Empty(vm.Organizations); - } - - [Fact] - public async Task RefreshPresentation_ReRaisesStatusForEveryRow_WithoutChangingIt() - { - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - var raised = new List(); - foreach (RepoItemViewModel row in vm.Repos) - { - row.PropertyChanged += (sender, e) => raised.Add($"{((RepoItemViewModel)sender!).Name}:{e.PropertyName}"); - } - - vm.RefreshPresentation(); - - Assert.Equal(["alpha:Status", "bravo:Status"], raised); - Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Queued, r.Status)); - } - - // ---------------------------------------------------------------- account workspaces - - /// An account profile whose repositories land under . - private static Account MakeAccount(string targetRoot) => new() - { - Id = Guid.NewGuid(), - Name = "Work", - Organization = "acme", - TargetRoot = targetRoot, - MaxConcurrency = 1, // serialize progress: the sync fake reports inline across threads - }; - - [Fact] - public async Task OrgRefresh_AccountSeededOrganization_SurvivesAndResyncsTheView() - { - // Regression: the view's editable ComboBox resets its Text when its - // ItemsSource is mutated, blanking the org an account workspace seeded in - // the constructor. The VM must keep the value across the refresh AND raise - // Organization again after the list mutation so the view resyncs its Text. - var account = new Account - { - Id = Guid.NewGuid(), - Name = "Kof", - Organization = "KofTwentyTwo", - TargetRoot = @"C:\repos\kof", - }; - var vault = new InMemoryVault(); - vault.Store(account.Id, "vault-token-1234567890"); - _orgs.Handler = (_, _) => - Task.FromResult>(["KofTwentyTwo", "acme", "other"]); - - var vm = CreateViewModel(account: account, vault: vault); - bool resyncAfterListArrived = false; - vm.PropertyChanged += (_, e) => - { - if (e.PropertyName == nameof(WorkspaceViewModel.Organization) - && vm.Organizations.Count > 0) - { - resyncAfterListArrived = true; - } - }; - - await WaitUntilAsync(() => vm.Organizations.Count == 3, "org list refresh"); - - Assert.Equal("KofTwentyTwo", vm.Organization); - Assert.True(resyncAfterListArrived, "Organization must be re-raised after the list mutation"); - } - - [Fact] - public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken() - { - var account = new Account - { - Id = Guid.NewGuid(), - Name = "Work", - Organization = "acme", - TargetRoot = @"C:\repos\work", - CreateOrgSubfolder = true, - MaxConcurrency = 4, - }; - var vault = new InMemoryVault(); - vault.Store(account.Id, "vault-token-1234567890"); - string? lookedUpToken = null; - _orgs.Handler = (token, _) => - { - lookedUpToken = token; - return Task.FromResult>(["acme", "other"]); - }; - - var vm = CreateViewModel(account: account, vault: vault); - - Assert.Equal("acme", vm.Organization); - Assert.Equal(@"C:\repos\work", vm.TargetFolder); - Assert.True(vm.CreateOrgSubfolder); - Assert.Equal(4, vm.MaxConcurrency); - Assert.Equal("vault-token-1234567890", vm.Token); - Assert.Equal(account.Id, vm.AccountId); - Assert.Equal("Work", vm.DisplayName); - - await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire"); - Assert.Equal("vault-token-1234567890", lookedUpToken); - } - - [Fact] - public void QuickSyncConstruction_KeepsDefaults_AndQuickSyncIdentity() - { - var vm = CreateViewModel(); - - Assert.Null(vm.AccountId); - Assert.Equal("Quick Sync", vm.DisplayName); - Assert.Equal("", vm.Organization); - Assert.Equal("", vm.Token); - Assert.Equal("", vm.TargetFolder); - Assert.False(vm.CreateOrgSubfolder); - Assert.Equal(AppSettings.DefaultConcurrency, vm.MaxConcurrency); - Assert.False(vm.HasFailedRepos); - } - - [Fact] - public async Task HasFailedRepos_TurnsOnWithAFailure_AndOffWhenRetrySucceeds() - { - _git.CloneHandler = (url, path, token, onProgress, ct) => - Path.GetFileName(path) == "bravo" - ? Task.FromException(new InvalidOperationException("boom")) - : Task.CompletedTask; - var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); - Assert.False(vm.HasFailedRepos); - - await vm.SyncCommand.ExecuteAsync(null); - Assert.True(vm.HasFailedRepos); - - _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone - await vm.RetryFailedCommand.ExecuteAsync(null); - Assert.False(vm.HasFailedRepos); - - } - - [Fact] - public async Task Sync_AccountWorkspace_RecordsTheResultOnTheStore() - { - string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - try - { - var vault = new InMemoryVault(); - var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog()); - var account = MakeAccount(Path.Combine(root, "repos")); - store.Save(account, "token-1234567890"); // the vault feeds the VM its token - _lister.Repositories = [Repo("alpha"), Repo("bravo")]; - - var vm = CreateViewModel(account: account, vault: vault, store: store); - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - await vm.LoadReposCommand.ExecuteAsync(null); - - await vm.SyncCommand.ExecuteAsync(null); - - Account recorded = Assert.Single(store.GetAll()); - Assert.NotNull(recorded.LastSyncUtc); - Assert.NotNull(recorded.LastSyncSummary); - Assert.StartsWith("Finished", recorded.LastSyncSummary); - Assert.Contains("2 cloned", recorded.LastSyncSummary); - Assert.Equal(vm.StatusText, recorded.LastSyncSummary); - } - finally - { - TryDeleteDirectory(root); - } - } - - [Fact] - public async Task Sync_QuickSync_DoesNotRecordOnTheStore() - { - string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - try - { - var vault = new InMemoryVault(); - var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog()); - store.Save(MakeAccount(Path.Combine(root, "repos")), "token-1234567890"); - _lister.Repositories = [Repo("alpha")]; - - // Quick Sync: the shared store exists, but no account backs this workspace. - var vm = CreateViewModel(vault: vault, store: store); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(root, "quick"); - vm.MaxConcurrencyValue = 1; - await vm.LoadReposCommand.ExecuteAsync(null); - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Contains("1 cloned", vm.StatusText); - Account untouched = Assert.Single(store.GetAll()); - Assert.Null(untouched.LastSyncUtc); - Assert.Null(untouched.LastSyncSummary); - } - finally - { - TryDeleteDirectory(root); - } - } - - [Fact] - public async Task Sync_RecordSyncResultFailure_StillCompletesTheRun() - { - string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - string storeDir = Path.Combine(root, "store"); - try - { - var vault = new InMemoryVault(); - var store = new AccountsStore(vault, storeDir, new NullActivityLog()); - var account = MakeAccount(Path.Combine(root, "repos")); - store.Save(account, "token-1234567890"); - _lister.Repositories = [Repo("alpha")]; - - var vm = CreateViewModel(account: account, vault: vault, store: store); - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - await vm.LoadReposCommand.ExecuteAsync(null); - - // Break persistence: a file now squats on the store's directory path, so - // RecordSyncResult's write throws. The finished sync must shrug that off. - Directory.Delete(storeDir, recursive: true); - File.WriteAllText(storeDir, "not a directory"); - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal("Finished: 1 cloned, 0 updated, 0 failed, 0 canceled of 1.", vm.StatusText); - Assert.True(vm.HasCompletedRun); - Assert.False(vm.IsRunning); - Assert.False(vm.HasFailedRepos); - } - finally - { - TryDeleteDirectory(root); - } - } - - // ---------------------------------------------------------------- misc bindings - - [Theory] - [InlineData(100.0, 64)] - [InlineData(0.0, 1)] - [InlineData(12.0, 12)] - [InlineData(double.NaN, 8)] - public void MaxConcurrencyValue_ClampsIntoValidRange(double input, int expected) - { - var vm = CreateViewModel(); - - vm.MaxConcurrencyValue = input; - - Assert.Equal(expected, vm.MaxConcurrency); - } - - [Fact] - public void RepoItem_ExposesDescriptorFields_AndProgressShape() - { - var descriptor = new RepoDescriptor( - "alpha", "https://example.test/acme/alpha.git", "develop", IsArchived: true); - var item = new RepoItemViewModel(descriptor); - - Assert.Same(descriptor, item.Descriptor); - Assert.Equal("alpha", item.Name); - Assert.Equal("develop", item.BranchText); - Assert.True(item.IsArchived); - Assert.True(item.IsSelected); // rows start selected - Assert.Equal("Queued", item.StatusText); - Assert.False(item.ShowProgress); - Assert.False(item.HasError); - Assert.Equal(0, item.ProgressValue); - - item.Status = SyncStatus.Cloning; - item.Percent = 0.42; - Assert.Equal("Cloning 42%", item.StatusText); - Assert.True(item.ShowProgress); - Assert.False(item.IsIndeterminate); - Assert.Equal(0.42, item.ProgressValue); - - item.Status = SyncStatus.Pulling; - Assert.True(item.ShowProgress); - Assert.True(item.IsIndeterminate); // pulls report no percentage - - item.Status = SyncStatus.Failed; - item.Error = "boom"; - Assert.True(item.HasError); - Assert.False(item.ShowProgress); - } - - [Fact] - public void RepoItem_EmptyRepository_HasEmptyBranchText() - { - var item = new RepoItemViewModel(Repo("empty", branch: null)); - - Assert.Equal("", item.BranchText); - } - - [Fact] - public async Task ActiveFilter_FollowsNonTerminalTransitions_DuringARun() - { - var gate = new TaskCompletionSource(); - _git.CloneHandler = async (_, _, _, _, _) => - // CancellationToken.None: the gate must not be cancelable; the timeout is the safety valve. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - vm.Filter = RepoFilter.Active; - Assert.Empty(vm.FilteredRepos); // nothing in flight yet - - Task run = vm.SyncCommand.ExecuteAsync(null); - await WaitUntilAsync( - () => vm.FilteredRepos.Count == 1 && vm.FilteredRepos[0].Name == "alpha", - "the cloning row to enter the Active filter"); - - gate.SetResult(); - await run; - Assert.Empty(vm.FilteredRepos); // done rows leave the Active view - } - - /// A lister whose calls each await their own gate, in order. - private sealed class SequencedGatedLister : IRepositoryLister - { - public Queue>> Gates { get; } = new(); - - public Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default) - => Gates.Dequeue().Task; - } - - [Fact] - public async Task Sync_IsBlocked_WhileAReloadIsInFlight() - { - var lister = new SequencedGatedLister(); - var first = new TaskCompletionSource>( - TaskCreationOptions.RunContinuationsAsynchronously); - var second = new TaskCompletionSource>( - TaskCreationOptions.RunContinuationsAsynchronously); - lister.Gates.Enqueue(first); - lister.Gates.Enqueue(second); - - var vm = new WorkspaceViewModel(lister, _git, _orgs, - handler => new SyncProgress(handler), - TimeSpan.FromMilliseconds(1), - new NullActivityLog()); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - - Task firstLoad = vm.LoadReposCommand.ExecuteAsync(null); - first.SetResult([Repo("alpha")]); - await firstLoad; // table populated, alpha selected - Assert.True(vm.SyncCommand.CanExecute(null)); - - Task reload = vm.LoadReposCommand.ExecuteAsync(null); - await WaitUntilAsync(() => vm.IsLoadingRepos, "the reload to start"); - Assert.False(vm.SyncCommand.CanExecute(null)); - Assert.False(vm.RetryFailedCommand.CanExecute(null)); - - second.SetResult([Repo("alpha")]); - await reload; - Assert.True(vm.SyncCommand.CanExecute(null)); - } - - [Fact] - public async Task PathRecovery_LocksOutEveryOtherGitEntryPoint_WhileInFlight() - { - MakeAlphaFailWithInvalidPaths(); - var gate = new TaskCompletionSource(); - _git.ApplyRecoveryHandler = async (_, _, _) => - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - vm.RecoveryInteraction = _ => Task.FromResult( - new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(), new HashSet()))); - - Task resolve = vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); - await WaitUntilAsync(() => vm.IsResolvingPaths, "the recovery to start"); - Assert.False(vm.SyncCommand.CanExecute(null)); - Assert.False(vm.LoadReposCommand.CanExecute(null)); - Assert.False(vm.RetryFailedCommand.CanExecute(null)); - Assert.False(vm.CanEditInputs); - - gate.SetResult(); - await resolve; - Assert.False(vm.IsResolvingPaths); - Assert.True(vm.SyncCommand.CanExecute(null)); - Assert.True(vm.CanEditInputs); - - } - - // ---------------------------------------------------------------- residue - - [Fact] - public void EffectiveTargetRoot_EmptyFolder_IsEmpty() - { - var vm = CreateViewModel(); - vm.TargetFolder = " "; - - Assert.Equal("", vm.EffectiveTargetRoot); - } - - [Fact] - public void MaxConcurrencyValue_ReflectsMaxConcurrency() - { - var vm = CreateViewModel(); - vm.MaxConcurrency = 7; - - Assert.Equal(7d, vm.MaxConcurrencyValue); - } - - [Fact] - public async Task Sort_UnknownColumn_IsANoOp() - { - var vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha")); - var before = vm.Repos.Select(r => r.Name).ToList(); - - vm.SortCommand.Execute("Nonsense"); - - Assert.Null(vm.SortColumn); - Assert.Equal(before, vm.Repos.Select(r => r.Name)); - } - - [Fact] - public async Task Sort_ByBranch_OrdersOnBranchText() - { - var vm = await CreateLoadedViewModelAsync( - Repo("a", branch: "zeta"), Repo("b", branch: "alpha")); - - vm.SortCommand.Execute("Branch"); - - Assert.Equal(["b", "a"], vm.Repos.Select(r => r.Name)); - } - - [Fact] - public async Task Sort_ByArchived_OrdersUnarchivedFirst() - { - var vm = await CreateLoadedViewModelAsync( - Repo("a", archived: true), Repo("b", archived: false)); - - vm.SortCommand.Execute("Archived"); - - Assert.Equal(["b", "a"], vm.Repos.Select(r => r.Name)); - } - - [Fact] - public async Task Sync_EngineThrowsOperationCanceled_SetsCanceledResult() - { - // The engine swallows cancellation into a summary; the view model's own OCE - // handling is only reachable when the sync runner throws (injected here). - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - vm.SyncRunner = (_, _, _, ct) => throw new OperationCanceledException(ct); - - await vm.SyncCommand.ExecuteAsync(null); - - Assert.Equal(RunResultKind.Canceled, vm.ResultKind); - Assert.Equal("Canceled", vm.ResultMessage); - Assert.Equal("Canceled", vm.StatusText); - } - - [Fact] - public async Task ResolvePaths_NoInteractionSubscriber_IsANoOp() - { - MakeAlphaFailWithInvalidPaths(); - var vm = await CreateLoadedViewModelAsync(Repo("alpha")); - await vm.SyncCommand.ExecuteAsync(null); - vm.RecoveryInteraction = null; // no subscriber - - await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); - - Assert.Equal(SyncStatus.Failed, vm.Repos[0].Status); // unchanged - Assert.False(vm.IsResolvingPaths); - - } - - [Fact] - public async Task HandleProgress_UnknownRepoName_IsIgnored() - { - // The view model builds the progress handler when a sync starts; capture it, - // let the sync finish, then feed the handler a name that is not in the table. - // The guard must ignore it without throwing or adding a row. - Action? handler = null; - _lister.Repositories = [Repo("alpha")]; - var vm = new WorkspaceViewModel( - _lister, _git, _orgs, - h => { handler = h; return new SyncProgress(h); }, - TimeSpan.FromMilliseconds(1), new NullActivityLog()); - vm.Organization = "acme"; - vm.Token = "token-1234567890"; - await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); - vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); - await vm.LoadReposCommand.ExecuteAsync(null); - await vm.SyncCommand.ExecuteAsync(null); // captures the handler - - Assert.NotNull(handler); - handler!(new RepoProgress("ghost-repo", SyncStatus.Cloning, Percent: 0.5)); - - Assert.DoesNotContain(vm.Repos, r => r.Name == "ghost-repo"); - - } + _orgs.Handler = (_, _) => Task.FromResult>(["me", "acme"]); + vm.Token = "token-1234567890-fixed"; + await WaitUntilAsync(() => vm.Organizations.Count == 2, "lookup to succeed"); + + Assert.False(vm.LoadErrorOpen); + } + + + + [Fact] + public async Task EditingTheOrganizationAfterALoad_MarksTheTableStale_AndBlocksSync() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + Assert.False(vm.IsTableStale); + Assert.Equal("", vm.StaleTableMessage); + Assert.True(vm.SyncCommand.CanExecute(null)); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + vm.Organization = "acme-labs"; + + Assert.True(vm.IsTableStale); + Assert.Contains("'acme'", vm.StaleTableMessage); + Assert.Contains("'acme-labs'", vm.StaleTableMessage); + Assert.False(vm.SyncCommand.CanExecute(null), "syncing a stale table would clone the wrong org"); + Assert.Contains(nameof(WorkspaceViewModel.IsTableStale), raised, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.StaleTableMessage), raised, StringComparer.Ordinal); + + // Putting it back (case and whitespace are not a change) clears the state... + vm.Organization = " ACME "; + Assert.False(vm.IsTableStale); + Assert.True(vm.SyncCommand.CanExecute(null)); + + // ...and so does reloading under the new name. + vm.Organization = "acme-labs"; + await vm.LoadReposCommand.ExecuteAsync(null); + Assert.Equal("acme-labs", vm.LoadedOrganization); + Assert.False(vm.IsTableStale); + Assert.True(vm.SyncCommand.CanExecute(null)); + } + + + + [Fact] + public void BeforeAnyLoad_TheTableIsNeverStale() + { + WorkspaceViewModel vm = CreateViewModel(); + vm.Organization = "anything"; + Assert.False(vm.IsTableStale); + } + + + + // ---------------------------------------------------------------- selection + + [Fact] + public async Task AllSelected_ActsOnVisibleRowsOnly_AndReflectsThem() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + vm.NameFilter = "al"; // alpha only + Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + Assert.True(vm.AllSelected); + + vm.AllSelected = false; + + // Only the visible row changed; hidden rows keep their selection and still sync. + Assert.False(vm.Repos[0].IsSelected); + Assert.True(vm.Repos[1].IsSelected); + Assert.True(vm.Repos[2].IsSelected); + Assert.Equal(2, vm.SelectedCount); + Assert.True(vm.SyncCommand.CanExecute(null)); + + // The header reflects the visible set as the filter changes. + vm.NameFilter = ""; + Assert.False(vm.AllSelected); + vm.NameFilter = "br"; + Assert.True(vm.AllSelected); + + vm.AllSelected = true; // no-op on the visible row (already selected) + Assert.False(vm.Repos[0].IsSelected); + } + + + + [Fact] + public async Task AllSelected_HeaderPush_ReQueriesSyncOncePerBatch() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); + int canExecuteRaised = 0; + vm.SyncCommand.CanExecuteChanged += (_, _) => canExecuteRaised++; + + vm.AllSelected = false; + + // One notification for the whole batch, not one per row (#30). + Assert.Equal(1, canExecuteRaised); + } + + + + [Fact] + public async Task AllSelected_TogglesEveryRow_AndFollowsItemChanges() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + Assert.True(vm.AllSelected); + + vm.AllSelected = false; + Assert.All(vm.Repos, r => Assert.False(r.IsSelected)); + Assert.False(vm.SyncCommand.CanExecute(null)); // nothing selected + + vm.AllSelected = true; + Assert.All(vm.Repos, r => Assert.True(r.IsSelected)); + Assert.True(vm.SyncCommand.CanExecute(null)); + + vm.Repos[1].IsSelected = false; + Assert.False(vm.AllSelected); + Assert.True(vm.Repos[0].IsSelected); // header recompute must not cascade to other rows + Assert.True(vm.Repos[2].IsSelected); + + vm.Repos[1].IsSelected = true; + Assert.True(vm.AllSelected); + } + + + + [Fact] + public async Task SelectedCount_AndSyncButtonLabel_FollowSelectionChanges() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + Assert.Equal(3, vm.SelectedCount); + Assert.Equal("Sync 3 repos", vm.SyncButtonLabel); + + vm.Repos[0].IsSelected = false; + vm.Repos[1].IsSelected = false; + Assert.Equal(1, vm.SelectedCount); + Assert.Equal("Sync 1 repo", vm.SyncButtonLabel); // singular + Assert.Contains(nameof(WorkspaceViewModel.SyncButtonLabel), raised, StringComparer.Ordinal); + + vm.Repos[2].IsSelected = false; + Assert.Equal(0, vm.SelectedCount); + Assert.Equal("Sync 0 repos", vm.SyncButtonLabel); + + vm.AllSelected = true; // header toggle recomputes too + Assert.Equal(3, vm.SelectedCount); + Assert.Equal("Sync 3 repos", vm.SyncButtonLabel); + } + + + + // ---------------------------------------------------------------- sorting + + [Fact] + public async Task Sort_ByName_TogglesDirection_AndSwitchingColumnResetsIt() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"), Repo("charlie")); + + vm.SortCommand.Execute("Name"); + Assert.Equal(["alpha", "bravo", "charlie"], vm.Repos.Select(r => r.Name), StringComparer.Ordinal); + Assert.Equal("Name", vm.SortColumn); + Assert.False(vm.SortDescending); + + vm.SortCommand.Execute("Name"); + Assert.Equal(["charlie", "bravo", "alpha"], vm.Repos.Select(r => r.Name), StringComparer.Ordinal); + Assert.True(vm.SortDescending); + + vm.SortCommand.Execute("Status"); + Assert.Equal("Status", vm.SortColumn); + Assert.False(vm.SortDescending); // direction resets on a new column + } + + + + [Fact] + public async Task Sort_KeepsFilteredReposInTheNewOrder_AndMembershipUpdatesStillLandInPlace() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("charlie"), Repo("alpha"), Repo("bravo")); + vm.SortCommand.Execute("Name"); + Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + // A progress-driven membership change after a sort must respect the sorted + // order (the position map is rebuilt by Sort). + vm.Filter = RepoFilter.Failed; + Assert.Empty(vm.FilteredRepos); + _git.CloneHandler = (_, path, _, _, _) => + Path.GetFileName(path) is "charlie" or "alpha" + ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(["alpha", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task Sort_ByStatus_OrdersByLifecycle_AndIsStable() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie")); + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + byName["alpha"].Status = SyncStatus.Done; + byName["bravo"].Status = SyncStatus.Failed; + byName["charlie"].Status = SyncStatus.Done; + + vm.SortCommand.Execute("Status"); + + // Done sorts before Failed; the two Done rows keep their load order (stable sort). + Assert.Equal(["alpha", "charlie", "bravo"], vm.Repos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + // ---------------------------------------------------------------- filtering + + [Fact] + public async Task FilteredRepos_DefaultAll_MirrorsTheTableAfterLoad() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + Assert.Equal(RepoFilter.All, vm.Filter); + Assert.Equal(vm.Repos, vm.FilteredRepos); + } + + + + [Fact] + public async Task Filter_RebuildsFilteredRepos_PerMode() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync( + Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); + var byName = vm.Repos.ToDictionary(r => r.Name, StringComparer.Ordinal); + byName["alpha"].Status = SyncStatus.Failed; + byName["bravo"].Status = SyncStatus.Cloning; + byName["charlie"].Status = SyncStatus.Pulling; + // delta stays Queued + + vm.Filter = RepoFilter.Active; + Assert.Equal(["bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.Filter = RepoFilter.Failed; + Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.Filter = RepoFilter.Pending; + Assert.Equal(["delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.Filter = RepoFilter.All; + Assert.Equal(["alpha", "bravo", "charlie", "delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task ProgressTransitions_UpdateFilteredReposInPlace_WithoutAReset() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"), Repo("charlie"), Repo("delta")); + vm.Filter = RepoFilter.Pending; + Assert.Equal(4, vm.FilteredRepos.Count); + var actions = new List(); + vm.FilteredRepos.CollectionChanged += (_, e) => actions.Add(e.Action); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _git.CloneHandler = async (_, path, _, _, _) => + { + if(string.Equals(Path.GetFileName(path), "bravo", StringComparison.Ordinal)) + { + await gate.Task; + } + }; + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync(() => vm.Repos[1].Status == SyncStatus.Cloning, "bravo to start"); + + // Rows leave Pending one by one as they start: removals, never a Reset. + Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions); + Assert.DoesNotContain(vm.Repos[1], vm.FilteredRepos); + + gate.SetResult(); + await run; + Assert.Empty(vm.FilteredRepos); + Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions); + + // And a later transition back into the filtered set inserts in table order. + vm.Filter = RepoFilter.Failed; + _git.CloneHandler = (_, path, _, _, _) => + Path.GetFileName(path) is "delta" or "alpha" + ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + actions.Clear(); + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(["alpha", "delta"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + Assert.DoesNotContain(System.Collections.Specialized.NotifyCollectionChangedAction.Reset, actions); + } + + + + [Fact] + public async Task FailedReports_AreLoggedOnTheReportingThread_NotTheUiHandler() + { + var log = new ThreadRecordingLog(); + _lister.Repositories = Repos("alpha"); + var vm = new WorkspaceViewModel(_lister, _git, _orgs, + handler => new SyncProgress(handler), TimeSpan.FromMilliseconds(1), log); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + await vm.LoadReposCommand.ExecuteAsync(null); + int reportingThread = -1; + _git.CloneHandler = (_, _, _, _, _) => + { + reportingThread = Environment.CurrentManagedThreadId; + return Task.FromException(new InvalidOperationException("boom")); + }; + + await vm.SyncCommand.ExecuteAsync(null); + + (string Message, int Thread) entry = Assert.Single(log.Errors, e => e.Message.StartsWith("alpha failed", StringComparison.Ordinal)); + Assert.Contains("boom", entry.Message); + // The engine reports from the thread that ran the git operation; the fake's + // synchronous progress means the log call happened right there, before any + // UI-marshaled handler could have run. + Assert.Equal(reportingThread, entry.Thread); + } + + + + private sealed class ThreadRecordingLog : IActivityLog + { + public List<(string Message, int Thread)> Errors { get; } = []; + + + + public void Info(string message) + { + } + + + + public void Error(string message, Exception? exception = null) + => Errors.Add((message, Environment.CurrentManagedThreadId)); + + + + public string LogDirectory => ""; + + + + public string CurrentLogFilePath => ""; + } + + + + [Fact] + public async Task Filter_WithNoMatchingRows_LeavesFilteredReposEmpty() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + vm.Filter = RepoFilter.Failed; // nothing has failed + + Assert.Empty(vm.FilteredRepos); + Assert.NotEmpty(vm.Repos); // the empty-filter hint case: rows exist, none match + } + + + + [Fact] + public async Task NameFilter_CaseInsensitiveSubstring_ComposesWithStatusFilter() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync( + Repo("platform-api"), Repo("platform-web"), Repo("tools")); + vm.Repos.First(r => string.Equals(r.Name, "platform-api", StringComparison.Ordinal)).Status = SyncStatus.Failed; + + vm.NameFilter = "PLATFORM"; + Assert.Equal(["platform-api", "platform-web"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.Filter = RepoFilter.Failed; // AND-composes with the name filter + Assert.Equal(["platform-api"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.NameFilter = ""; + vm.Filter = RepoFilter.All; + Assert.Equal(3, vm.FilteredRepos.Count); + } + + + + [Fact] + public async Task NameFilter_TrimsBeforeMatching() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + + vm.NameFilter = " alpha "; + + Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task BranchFilter_MatchesTheDisplayedBranchText() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync( + Repo("alpha", branch: "main"), + Repo("bravo", branch: "develop"), + Repo("empty", branch: null)); // empty repository: BranchText is "" + + vm.BranchFilter = "dev"; + + Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task ArchivedFilter_TriState_AllArchivedUnarchived() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync( + Repo("alpha", archived: true), Repo("bravo")); + + vm.ArchivedFilter = true; + Assert.Equal(["alpha"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.ArchivedFilter = false; + Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + vm.ArchivedFilter = null; + Assert.Equal(2, vm.FilteredRepos.Count); + } + + + + [Fact] + public async Task ColumnFilters_NeverTouchSelection_HiddenRowsStillSync() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + Assert.True(vm.Repos.All(r => r.IsSelected)); // load selects everything + + vm.NameFilter = "alpha"; // bravo is hidden but stays selected + + Assert.Equal(2, vm.SelectedCount); + Assert.Contains("2", vm.SyncButtonLabel); + } + + + + [Fact] + public async Task FilteredRepos_FollowsTerminalTransitions_AcrossARun() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "bravo" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + vm.Filter = RepoFilter.Failed; + Assert.Empty(vm.FilteredRepos); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(["bravo"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + + } + + + + [Fact] + public async Task FilteredRepos_PreservesTheTableSortOrder() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha"), Repo("charlie")); + + vm.SortCommand.Execute("Name"); + + Assert.Equal(["alpha", "bravo", "charlie"], vm.FilteredRepos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + // ---------------------------------------------------------------- target preview + + [Fact] + public void TargetPreview_ReflectsOrgSubfolder_AndRaisesChange() + { + WorkspaceViewModel vm = CreateViewModel(); + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + vm.Organization = "acme"; + vm.TargetFolder = @"C:\src"; + + Assert.Equal(@"C:\src", vm.EffectiveTargetRoot); + Assert.Equal(@"C:\src\my-repo", vm.TargetPreview); + + raised.Clear(); + vm.CreateOrgSubfolder = true; + + Assert.Equal(@"C:\src\acme", vm.EffectiveTargetRoot); + Assert.Equal(@"C:\src\acme\my-repo", vm.TargetPreview); + Assert.Contains(nameof(WorkspaceViewModel.EffectiveTargetRoot), raised, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.TargetPreview), raised, StringComparer.Ordinal); + } + + + + [Fact] + public async Task TargetPreview_UsesFirstLoadedRepoName() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("zulu"), Repo("alpha")); + + Assert.Equal(Path.Combine(vm.TargetFolder, "zulu"), vm.TargetPreview); + + vm.CreateOrgSubfolder = true; + Assert.Equal(Path.Combine(vm.TargetFolder, "acme", "zulu"), vm.TargetPreview); + } + + + + // ---------------------------------------------------------------- org lookup + + [Fact] + public async Task TokenChange_PopulatesOrganizations_AfterDebounce() + { + _orgs.Handler = (_, _) => Task.FromResult>(["KofTwentyTwo", "acme"]); + WorkspaceViewModel vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + + await WaitUntilAsync(() => vm.Organizations.Count == 2, "organizations to load"); + Assert.Equal(["KofTwentyTwo", "acme"], vm.Organizations); + Assert.Contains("Found 2", vm.StatusText); + Assert.False(vm.IsLoadingOrgs); + } + + + + [Fact] + public async Task TokenChange_OnlyPersonalLoginVisible_ExplainsAndAllowsManualEntry() + { + // The production lister always includes the token's own account, so a + // single-entry answer means no organizations were visible. + _orgs.Handler = (_, _) => Task.FromResult>(["KofTwentyTwo"]); + WorkspaceViewModel vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + + await WaitUntilAsync(() => vm.StatusText.Length > 0, "status text"); + Assert.Equal( + "Only your personal account is visible — add read:org (classic) for organizations, " + + "or type an org name manually.", + vm.StatusText); + Assert.Equal(["KofTwentyTwo"], vm.Organizations); + } + + + + [Fact] + public async Task TokenChange_ToShortTokenDuringLookup_ClearsTheLoadingFlag() + { + // The first lookup blocks inside the lister; editing the token to something + // implausibly short cancels it and returns early — the early return owns the + // flag now, so it must not leave the spinner stuck on. + var gate = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + _orgs.Handler = (_, _) => gate.Task; + WorkspaceViewModel vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.IsLoadingOrgs, "the first lookup to start"); + + vm.Token = "short"; + + Assert.False(vm.IsLoadingOrgs); + Assert.Empty(vm.Organizations); + gate.SetResult(["acme"]); // release the abandoned lookup; it was canceled and is ignored + } + + + + [Fact] + public async Task TokenChange_LookupFailure_SurfacesMessage_WithoutThrowing() + { + _orgs.Handler = (_, _) => Task.FromException>( + new InvalidOperationException("GitHub rejected the token (401). Check the PAT.")); + WorkspaceViewModel vm = CreateViewModel(); + + vm.Token = "token-1234567890"; + + await WaitUntilAsync(() => vm.StatusText.Contains("401"), "error to surface"); + Assert.False(vm.IsLoadingOrgs); + } + + + + [Fact] + public async Task TokenChange_TooShort_ClearsListWithoutLookup() + { + _orgs.Handler = (_, _) => Task.FromResult>(["acme"]); + WorkspaceViewModel vm = CreateViewModel(); + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.Organizations.Count == 1, "initial load"); + + vm.Token = "short"; + + Assert.Empty(vm.Organizations); + Assert.Equal(1, _orgs.Calls); // no second lookup for an implausible token + } + + + + [Fact] + public async Task TokenChange_RapidEdits_OnlyNewestLookupLands() + { + _orgs.Handler = (token, _) => Task.FromResult>([token[^4..]]); + // The two assignments below must land inside one debounce window; a + // loaded CI runner can pause a test for hundreds of milliseconds between + // two lines, so the window is generous (this is the one wall-clock test). + WorkspaceViewModel vm = CreateViewModel(debounce: TimeSpan.FromMilliseconds(1500)); + + vm.Token = "token-1234567890-AAAA"; + vm.Token = "token-1234567890-BBBB"; // supersedes within the debounce window + + await WaitUntilAsync(() => vm.Organizations.Count == 1, "debounced load"); + Assert.Equal("BBBB", vm.Organizations[0]); + Assert.Equal(1, _orgs.Calls); + } + + + + // ---------------------------------------------------------------- lifecycle + + [Fact] + public async Task Dispose_MidOrgLookup_CancelsTheLookup_AndALateResultCannotMutateState() + { + var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + CancellationToken seen = default; + _orgs.Handler = (_, ct) => + { + seen = ct; + return gate.Task.WaitAsync(ct); + }; + WorkspaceViewModel vm = CreateViewModel(); + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => _orgs.Calls == 1, "lookup to start"); + + vm.Dispose(); + + Assert.True(seen.IsCancellationRequested, "the in-flight lookup's token must be canceled"); + gate.SetResult(["late"]); + await Task.Yield(); + Assert.Empty(vm.Organizations); // the canceled wait threw before the result could land + } + + + + [Fact] + public void Dispose_Twice_IsANoOp() + { + WorkspaceViewModel vm = CreateViewModel(); + vm.Dispose(); + vm.Dispose(); // must not throw (the lookup token source is already gone) + Assert.Empty(vm.Organizations); + } + + + + [Fact] + public async Task RefreshPresentation_ReRaisesStatusForEveryRow_WithoutChangingIt() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + var raised = new List(); + foreach(RepoItemViewModel row in vm.Repos) + { + row.PropertyChanged += (sender, e) => raised.Add($"{((RepoItemViewModel)sender!).Name}:{e.PropertyName}"); + } + + vm.RefreshPresentation(); + + Assert.Equal(["alpha:Status", "bravo:Status"], raised); + Assert.All(vm.Repos, r => Assert.Equal(SyncStatus.Queued, r.Status)); + } + + + + // ---------------------------------------------------------------- account workspaces + + /// An account profile whose repositories land under . + private static Account MakeAccount(string targetRoot) => new() + { + Id = Guid.NewGuid(), + Name = "Work", + Organization = "acme", + TargetRoot = targetRoot, + MaxConcurrency = 1, // serialize progress: the sync fake reports inline across threads + }; + + + + [Fact] + public async Task OrgRefresh_AccountSeededOrganization_SurvivesAndResyncsTheView() + { + // Regression: the view's editable ComboBox resets its Text when its + // ItemsSource is mutated, blanking the org an account workspace seeded in + // the constructor. The VM must keep the value across the refresh AND raise + // Organization again after the list mutation so the view resyncs its Text. + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Kof", + Organization = "KofTwentyTwo", + TargetRoot = @"C:\repos\kof", + }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "vault-token-1234567890"); + _orgs.Handler = (_, _) => + Task.FromResult>(["KofTwentyTwo", "acme", "other"]); + + WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault); + bool resyncAfterListArrived = false; + vm.PropertyChanged += (_, e) => + { + if(string.Equals(e.PropertyName, nameof(WorkspaceViewModel.Organization) +, StringComparison.Ordinal) && vm.Organizations.Count > 0) + { + resyncAfterListArrived = true; + } + }; + + await WaitUntilAsync(() => vm.Organizations.Count == 3, "org list refresh"); + + Assert.Equal("KofTwentyTwo", vm.Organization); + Assert.True(resyncAfterListArrived, "Organization must be re-raised after the list mutation"); + } + + + + [Fact] + public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken() + { + var account = new Account + { + Id = Guid.NewGuid(), + Name = "Work", + Organization = "acme", + TargetRoot = @"C:\repos\work", + CreateOrgSubfolder = true, + MaxConcurrency = 4, + }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "vault-token-1234567890"); + string? lookedUpToken = null; + _orgs.Handler = (token, _) => + { + lookedUpToken = token; + return Task.FromResult>(["acme", "other"]); + }; + + WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault); + + Assert.Equal("acme", vm.Organization); + Assert.Equal(@"C:\repos\work", vm.TargetFolder); + Assert.True(vm.CreateOrgSubfolder); + Assert.Equal(4, vm.MaxConcurrency); + Assert.Equal("vault-token-1234567890", vm.Token); + Assert.Equal(account.Id, vm.AccountId); + Assert.Equal("Work", vm.DisplayName); + + await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire"); + Assert.Equal("vault-token-1234567890", lookedUpToken); + } + + + + [Fact] + public void QuickSyncConstruction_KeepsDefaults_AndQuickSyncIdentity() + { + WorkspaceViewModel vm = CreateViewModel(); + + Assert.Null(vm.AccountId); + Assert.Equal("Quick Sync", vm.DisplayName); + Assert.Equal("", vm.Organization); + Assert.Equal("", vm.Token); + Assert.Equal("", vm.TargetFolder); + Assert.False(vm.CreateOrgSubfolder); + Assert.Equal(AppSettings.DefaultConcurrency, vm.MaxConcurrency); + Assert.False(vm.HasFailedRepos); + } + + + + [Fact] + public async Task HasFailedRepos_TurnsOnWithAFailure_AndOffWhenRetrySucceeds() + { + _git.CloneHandler = (url, path, token, onProgress, ct) => string.Equals(Path.GetFileName(path), "bravo" +, StringComparison.Ordinal) ? Task.FromException(new InvalidOperationException("boom")) + : Task.CompletedTask; + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo")); + Assert.False(vm.HasFailedRepos); + + await vm.SyncCommand.ExecuteAsync(null); + Assert.True(vm.HasFailedRepos); + + _git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // transient failure is gone + await vm.RetryFailedCommand.ExecuteAsync(null); + Assert.False(vm.HasFailedRepos); + + } + + + + [Fact] + public async Task Sync_AccountWorkspace_RecordsTheResultOnTheStore() + { + string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + try + { + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog()); + Account account = MakeAccount(Path.Combine(root, "repos")); + store.Save(account, "token-1234567890"); // the vault feeds the VM its token + _lister.Repositories = [Repo("alpha"), Repo("bravo")]; + + WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault, store: store); + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + await vm.LoadReposCommand.ExecuteAsync(null); + + await vm.SyncCommand.ExecuteAsync(null); + + Account recorded = Assert.Single(store.GetAll()); + Assert.NotNull(recorded.LastSyncUtc); + Assert.NotNull(recorded.LastSyncSummary); + Assert.StartsWith("Finished", recorded.LastSyncSummary); + Assert.Contains("2 cloned", recorded.LastSyncSummary); + Assert.Equal(vm.StatusText, recorded.LastSyncSummary); + } + finally + { + TryDeleteDirectory(root); + } + } + + + + [Fact] + public async Task Sync_QuickSync_DoesNotRecordOnTheStore() + { + string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + try + { + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, Path.Combine(root, "store"), new NullActivityLog()); + store.Save(MakeAccount(Path.Combine(root, "repos")), "token-1234567890"); + _lister.Repositories = [Repo("alpha")]; + + // Quick Sync: the shared store exists, but no account backs this workspace. + WorkspaceViewModel vm = CreateViewModel(vault: vault, store: store); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(root, "quick"); + vm.MaxConcurrencyValue = 1; + await vm.LoadReposCommand.ExecuteAsync(null); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Contains("1 cloned", vm.StatusText); + Account untouched = Assert.Single(store.GetAll()); + Assert.Null(untouched.LastSyncUtc); + Assert.Null(untouched.LastSyncSummary); + } + finally + { + TryDeleteDirectory(root); + } + } + + + + [Fact] + public async Task Sync_RecordSyncResultFailure_StillCompletesTheRun() + { + string root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + string storeDir = Path.Combine(root, "store"); + try + { + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, storeDir, new NullActivityLog()); + Account account = MakeAccount(Path.Combine(root, "repos")); + store.Save(account, "token-1234567890"); + _lister.Repositories = [Repo("alpha")]; + + WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault, store: store); + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + await vm.LoadReposCommand.ExecuteAsync(null); + + // Break persistence: a file now squats on the store's directory path, so + // RecordSyncResult's write throws. The finished sync must shrug that off. + Directory.Delete(storeDir, recursive: true); + File.WriteAllText(storeDir, "not a directory"); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal("Finished: 1 cloned, 0 updated, 0 failed, 0 canceled of 1.", vm.StatusText); + Assert.True(vm.HasCompletedRun); + Assert.False(vm.IsRunning); + Assert.False(vm.HasFailedRepos); + } + finally + { + TryDeleteDirectory(root); + } + } + + + + // ---------------------------------------------------------------- misc bindings + + [Theory] + [InlineData(100.0, 64)] + [InlineData(0.0, 1)] + [InlineData(12.0, 12)] + [InlineData(double.NaN, 8)] + public void MaxConcurrencyValue_ClampsIntoValidRange(double input, int expected) + { + WorkspaceViewModel vm = CreateViewModel(); + + vm.MaxConcurrencyValue = input; + + Assert.Equal(expected, vm.MaxConcurrency); + } + + + + [Fact] + public void RepoItem_ExposesDescriptorFields_AndProgressShape() + { + var descriptor = new RepoDescriptor( + "alpha", "https://example.test/acme/alpha.git", "develop", IsArchived: true); + var item = new RepoItemViewModel(descriptor); + + Assert.Same(descriptor, item.Descriptor); + Assert.Equal("alpha", item.Name); + Assert.Equal("develop", item.BranchText); + Assert.True(item.IsArchived); + Assert.True(item.IsSelected); // rows start selected + Assert.Equal("Queued", item.StatusText); + Assert.False(item.ShowProgress); + Assert.False(item.HasError); + Assert.Equal(0, item.ProgressValue); + + item.Status = SyncStatus.Cloning; + item.Percent = 0.42; + Assert.Equal("Cloning 42%", item.StatusText); + Assert.True(item.ShowProgress); + Assert.False(item.IsIndeterminate); + Assert.Equal(0.42, item.ProgressValue); + + item.Status = SyncStatus.Pulling; + Assert.True(item.ShowProgress); + Assert.True(item.IsIndeterminate); // pulls report no percentage + + item.Status = SyncStatus.Failed; + item.Error = "boom"; + Assert.True(item.HasError); + Assert.False(item.ShowProgress); + } + + + + [Fact] + public void RepoItem_EmptyRepository_HasEmptyBranchText() + { + var item = new RepoItemViewModel(Repo("empty", branch: null)); + + Assert.Equal("", item.BranchText); + } + + + + [Fact] + public async Task ActiveFilter_FollowsNonTerminalTransitions_DuringARun() + { + var gate = new TaskCompletionSource(); + _git.CloneHandler = async (_, _, _, _, _) => + // CancellationToken.None: the gate must not be cancelable; the timeout is the safety valve. + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + vm.Filter = RepoFilter.Active; + Assert.Empty(vm.FilteredRepos); // nothing in flight yet + + Task run = vm.SyncCommand.ExecuteAsync(null); + await WaitUntilAsync( + () => vm.FilteredRepos.Count == 1 && string.Equals(vm.FilteredRepos[0].Name, "alpha", StringComparison.Ordinal), + "the cloning row to enter the Active filter"); + + gate.SetResult(); + await run; + Assert.Empty(vm.FilteredRepos); // done rows leave the Active view + } + + + + /// A lister whose calls each await their own gate, in order. + private sealed class SequencedGatedLister : IRepositoryLister + { + public Queue>> Gates { get; } = new(); + + + + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + => Gates.Dequeue().Task; + } + + + + [Fact] + public async Task Sync_IsBlocked_WhileAReloadIsInFlight() + { + var lister = new SequencedGatedLister(); + var first = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + var second = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + lister.Gates.Enqueue(first); + lister.Gates.Enqueue(second); + + var vm = new WorkspaceViewModel(lister, _git, _orgs, + handler => new SyncProgress(handler), + TimeSpan.FromMilliseconds(1), + new NullActivityLog()); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + Task firstLoad = vm.LoadReposCommand.ExecuteAsync(null); + first.SetResult([Repo("alpha")]); + await firstLoad; // table populated, alpha selected + Assert.True(vm.SyncCommand.CanExecute(null)); + + Task reload = vm.LoadReposCommand.ExecuteAsync(null); + await WaitUntilAsync(() => vm.IsLoadingRepos, "the reload to start"); + Assert.False(vm.SyncCommand.CanExecute(null)); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); + + second.SetResult([Repo("alpha")]); + await reload; + Assert.True(vm.SyncCommand.CanExecute(null)); + } + + + + [Fact] + public async Task PathRecovery_LocksOutEveryOtherGitEntryPoint_WhileInFlight() + { + MakeAlphaFailWithInvalidPaths(); + var gate = new TaskCompletionSource(); + _git.ApplyRecoveryHandler = async (_, _, _) => + await gate.Task.WaitAsync(TimeSpan.FromSeconds(30), CancellationToken.None); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + vm.RecoveryInteraction = _ => Task.FromResult( + new PathRecoveryDecision.Apply(new PathRecovery(new Dictionary(StringComparer.Ordinal), new HashSet(StringComparer.Ordinal)))); + + Task resolve = vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + await WaitUntilAsync(() => vm.IsResolvingPaths, "the recovery to start"); + Assert.False(vm.SyncCommand.CanExecute(null)); + Assert.False(vm.LoadReposCommand.CanExecute(null)); + Assert.False(vm.RetryFailedCommand.CanExecute(null)); + Assert.False(vm.CanEditInputs); + + gate.SetResult(); + await resolve; + Assert.False(vm.IsResolvingPaths); + Assert.True(vm.SyncCommand.CanExecute(null)); + Assert.True(vm.CanEditInputs); + + } + + + + // ---------------------------------------------------------------- residue + + [Fact] + public void EffectiveTargetRoot_EmptyFolder_IsEmpty() + { + WorkspaceViewModel vm = CreateViewModel(); + vm.TargetFolder = " "; + + Assert.Equal("", vm.EffectiveTargetRoot); + } + + + + [Fact] + public void MaxConcurrencyValue_ReflectsMaxConcurrency() + { + WorkspaceViewModel vm = CreateViewModel(); + vm.MaxConcurrency = 7; + + Assert.Equal(7d, vm.MaxConcurrencyValue); + } + + + + [Fact] + public async Task Sort_UnknownColumn_IsANoOp() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("bravo"), Repo("alpha")); + var before = vm.Repos.Select(r => r.Name).ToList(); + + vm.SortCommand.Execute("Nonsense"); + + Assert.Null(vm.SortColumn); + Assert.Equal(before, vm.Repos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task Sort_ByBranch_OrdersOnBranchText() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync( + Repo("a", branch: "zeta"), Repo("b", branch: "alpha")); + + vm.SortCommand.Execute("Branch"); + + Assert.Equal(["b", "a"], vm.Repos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task Sort_ByArchived_OrdersUnarchivedFirst() + { + WorkspaceViewModel vm = await CreateLoadedViewModelAsync( + Repo("a", archived: true), Repo("b", archived: false)); + + vm.SortCommand.Execute("Archived"); + + Assert.Equal(["b", "a"], vm.Repos.Select(r => r.Name), StringComparer.Ordinal); + } + + + + [Fact] + public async Task Sync_EngineThrowsOperationCanceled_SetsCanceledResult() + { + // The engine swallows cancellation into a summary; the view model's own OCE + // handling is only reachable when the sync runner throws (injected here). + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + vm.SyncRunner = (_, _, _, ct) => throw new OperationCanceledException(ct); + + await vm.SyncCommand.ExecuteAsync(null); + + Assert.Equal(RunResultKind.Canceled, vm.ResultKind); + Assert.Equal("Canceled", vm.ResultMessage); + Assert.Equal("Canceled", vm.StatusText); + } + + + + [Fact] + public async Task ResolvePaths_NoInteractionSubscriber_IsANoOp() + { + MakeAlphaFailWithInvalidPaths(); + WorkspaceViewModel vm = await CreateLoadedViewModelAsync(Repo("alpha")); + await vm.SyncCommand.ExecuteAsync(null); + vm.RecoveryInteraction = null; // no subscriber + + await vm.ResolvePathsCommand.ExecuteAsync(vm.Repos[0]); + + Assert.Equal(SyncStatus.Failed, vm.Repos[0].Status); // unchanged + Assert.False(vm.IsResolvingPaths); + + } + + + + [Fact] + public async Task HandleProgress_UnknownRepoName_IsIgnored() + { + // The view model builds the progress handler when a sync starts; capture it, + // let the sync finish, then feed the handler a name that is not in the table. + // The guard must ignore it without throwing or adding a row. + Action? handler = null; + _lister.Repositories = [Repo("alpha")]; + var vm = new WorkspaceViewModel( + _lister, _git, _orgs, + h => { handler = h; return new SyncProgress(h); }, + TimeSpan.FromMilliseconds(1), new NullActivityLog()); + vm.Organization = "acme"; + vm.Token = "token-1234567890"; + await WaitUntilAsync(() => vm.StatusText.Length > 0, "org lookup to settle"); + vm.TargetFolder = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + await vm.LoadReposCommand.ExecuteAsync(null); + await vm.SyncCommand.ExecuteAsync(null); // captures the handler + + Assert.NotNull(handler); + handler!(new RepoProgress("ghost-repo", SyncStatus.Cloning, Percent: 0.5)); + + Assert.DoesNotContain(vm.Repos, r => string.Equals(r.Name, "ghost-repo", StringComparison.Ordinal)); + + } } diff --git a/gclo.Engine.Tests/WslClonerTests.cs b/gclo.Engine.Tests/WslClonerTests.cs index fef0a0d..73aac60 100644 --- a/gclo.Engine.Tests/WslClonerTests.cs +++ b/gclo.Engine.Tests/WslClonerTests.cs @@ -1,7 +1,11 @@ -using gclo.Engine; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ namespace gclo.Engine.Tests; + /// /// (#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; /// 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(new CancellationToken(canceled: true)); - await Assert.ThrowsAnyAsync(() => 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( - () => 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( - () => 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( - () => 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(new CancellationToken(canceled: true)); - var ex = await Assert.ThrowsAsync( - () => Create().CloneAsync("https://x/y.git", "acme", "repo", Token, CancellationToken.None)); + await Assert.ThrowsAnyAsync(() => 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( - () => 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(new CancellationToken(canceled: true)); + WslCloneResult result = await Create().CloneAsync( + "https://github.com/acme/repo.git", "acme", "repo", Token, CancellationToken.None); - await Assert.ThrowsAnyAsync( - () => 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( - () => 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( - () => 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( + () => 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( + () => 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( + () => 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( + () => 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( + () => 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(new CancellationToken(canceled: true)); + + await Assert.ThrowsAnyAsync( + () => 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( + () => Create().CloneAsync(url, org, repo, Token, CancellationToken.None)); + Assert.Empty(_runner.Calls); + } + + + + [Fact] + public async Task Clone_RejectsNullToken() + { + await Assert.ThrowsAsync( + () => 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); + } } diff --git a/gclo.Engine.Tests/gclo.Engine.Tests.csproj b/gclo.Engine.Tests/gclo.Engine.Tests.csproj index fa2e525..c4a0fc5 100644 --- a/gclo.Engine.Tests/gclo.Engine.Tests.csproj +++ b/gclo.Engine.Tests/gclo.Engine.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/gclo.Engine.Tests/packages.lock.json b/gclo.Engine.Tests/packages.lock.json index 6e20aab..7be9d01 100644 --- a/gclo.Engine.Tests/packages.lock.json +++ b/gclo.Engine.Tests/packages.lock.json @@ -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==" } } } diff --git a/gclo.Engine/BuildVersion.cs b/gclo.Engine/BuildVersion.cs index 71fb03f..41d93e6 100644 --- a/gclo.Engine/BuildVersion.cs +++ b/gclo.Engine/BuildVersion.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Reflection; + namespace gclo.Engine; + /// /// 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; /// public static class BuildVersion { - private const int ShortHashLength = 9; + private const int ShortHashLength = 9; - /// Describes 's version and commit. - public static string Describe(Assembly assembly) - => Format(assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString()); - /// - /// Formats an informational version: the "+<metadata>" suffix that SourceLink - /// appends becomes a parenthesized short commit hash. - /// - public static string Format(string? informationalVersion) - { - if (string.IsNullOrWhiteSpace(informationalVersion)) - { - return "unknown"; - } - int plus = informationalVersion.IndexOf('+'); - if (plus < 0) - { - return informationalVersion; - } + /// Describes 's version and commit. + public static string Describe(Assembly assembly) + => Format(assembly.GetCustomAttribute()?.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})"; - } + + /// + /// Formats an informational version: the "+<metadata>" suffix that SourceLink + /// appends becomes a parenthesized short commit hash. + /// + 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})"; + } } diff --git a/gclo.Engine/FileActivityLog.cs b/gclo.Engine/FileActivityLog.cs index ce23e30..d263c79 100644 --- a/gclo.Engine/FileActivityLog.cs +++ b/gclo.Engine/FileActivityLog.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Globalization; + namespace gclo.Engine; + /// /// An 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; /// public sealed class FileActivityLog : IActivityLog { - private readonly object _gate = new(); + private readonly Lock _gate = new(); - /// Creates a log that writes under . - /// - /// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank. - /// - public FileActivityLog(string? directory = null) - { - LogDirectory = string.IsNullOrWhiteSpace(directory) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "gclo", "logs") - : directory; - } - /// - public string LogDirectory { get; } - /// Today's log file; entries roll over to a new file at midnight. - public string CurrentLogFilePath - => Path.Combine( - LogDirectory, - "gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); + /// Creates a log that writes under . + /// + /// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank. + /// + public FileActivityLog(string? directory = null) + { + LogDirectory = string.IsNullOrWhiteSpace(directory) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "gclo", "logs") + : directory; + } - /// - public void Info(string message) => Write("INFO", message, exception: null); - /// - public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception); - private void Write(string level, string message, Exception? exception) - { - try - { - lock (_gate) + /// + public string LogDirectory { get; } + + + + /// Today's log file; entries roll over to a new file at midnight. + public string CurrentLogFilePath + => Path.Combine( + LogDirectory, + "gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); + + + + /// + public void Info(string message) => Write("INFO", message, exception: null); + + + + /// + 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. - } - } -} -/// An that discards everything. -public sealed class NullActivityLog : IActivityLog -{ - /// - public string LogDirectory => ""; - - /// - public string CurrentLogFilePath => ""; - - /// - public void Info(string message) - { - } - - /// - 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. + } + } } diff --git a/gclo.Engine/GitHubAccessException.cs b/gclo.Engine/GitHubAccessException.cs index 892d688..2c97506 100644 --- a/gclo.Engine/GitHubAccessException.cs +++ b/gclo.Engine/GitHubAccessException.cs @@ -1,20 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; -/// Why GitHub refused a request, in the terms a caller can act on. -public enum GitHubAccessKind -{ - /// The token was rejected (401): expired, revoked, or malformed. - Unauthorized, - - /// The token is valid but may not see the target (an ungranted organization, missing SSO). - Forbidden, - - /// A primary or secondary rate limit, or temporary abuse/login throttling: retry later. - RateLimited, - - /// No organization or user account with that login is visible to the token. - NotFound, -} /// /// A GitHub API refusal translated into an actionable message. Derives from @@ -24,13 +14,15 @@ public enum GitHubAccessKind /// public sealed class GitHubAccessException : InvalidOperationException { - /// The category of refusal. - public GitHubAccessKind Kind { get; } + /// The category of refusal. + public GitHubAccessKind Kind { get; } - /// Creates the exception with its category, user-facing message, and the Octokit cause. - public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null) - : base(message, innerException) - { - Kind = kind; - } + + + /// Creates the exception with its category, user-facing message, and the Octokit cause. + public GitHubAccessException(GitHubAccessKind kind, string message, Exception? innerException = null) + : base(message, innerException) + { + Kind = kind; + } } diff --git a/gclo.Engine/GitHubAccessKind.cs b/gclo.Engine/GitHubAccessKind.cs new file mode 100644 index 0000000..9100fe2 --- /dev/null +++ b/gclo.Engine/GitHubAccessKind.cs @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// Why GitHub refused a request, in the terms a caller can act on. +public enum GitHubAccessKind +{ + /// The token was rejected (401): expired, revoked, or malformed. + Unauthorized, + + /// The token is valid but may not see the target (an ungranted organization, missing SSO). + Forbidden, + + /// A primary or secondary rate limit, or temporary abuse/login throttling: retry later. + RateLimited, + + /// No organization or user account with that login is visible to the token. + NotFound, +} diff --git a/gclo.Engine/GitHubAccountKind.cs b/gclo.Engine/GitHubAccountKind.cs new file mode 100644 index 0000000..ac90a7a --- /dev/null +++ b/gclo.Engine/GitHubAccountKind.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// What a GitHub login names, as far as the token can tell. +internal enum GitHubAccountKind +{ + /// A user account. + User, + + /// An organization (whether or not the token can see its repositories). + Organization, + + /// No account with that login is visible to the token. + NotFound, +} diff --git a/gclo.Engine/GitHubOrganizationLister.cs b/gclo.Engine/GitHubOrganizationLister.cs index 0474340..50a7dc0 100644 --- a/gclo.Engine/GitHubOrganizationLister.cs +++ b/gclo.Engine/GitHubOrganizationLister.cs @@ -1,91 +1,104 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using Octokit; + namespace gclo.Engine; + /// Lists the authenticated user's organizations through the GitHub REST API. public sealed class GitHubOrganizationLister : IOrganizationLister { - private readonly Func _gatewayFactory; + private readonly Func _gatewayFactory; - /// Production wiring: the process-wide Octokit gateway for the call's token (connection reuse). - public GitHubOrganizationLister() - : this(OctokitGatewayCache.Get) - { - } - /// Test seam: substitute the GitHub API with a fake gateway. - internal GitHubOrganizationLister(Func gatewayFactory) - => _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory)); - /// - public async Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(token); - cancellationToken.ThrowIfCancellationRequested(); + /// Production wiring: the process-wide Octokit gateway for the call's token (connection reuse). + public GitHubOrganizationLister() + : this(OctokitGatewayCache.Get) + { + } - IGitHubGateway gateway = _gatewayFactory(token); - string userLogin; - IReadOnlyList 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); - } + /// Test seam: substitute the GitHub API with a fake gateway. + internal GitHubOrganizationLister(Func gatewayFactory) + => _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory)); - cancellationToken.ThrowIfCancellationRequested(); - var result = new List { userLogin }; - result.AddRange(organizations - .Where(login => !string.Equals(login, userLogin, StringComparison.OrdinalIgnoreCase)) - .OrderBy(login => login, StringComparer.OrdinalIgnoreCase)); - return result; - } + + /// + public async Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + cancellationToken.ThrowIfCancellationRequested(); + + IGitHubGateway gateway = _gatewayFactory(token); + + string userLogin; + IReadOnlyList 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 { userLogin }; + result.AddRange(organizations + .Where(login => !string.Equals(login, userLogin, StringComparison.OrdinalIgnoreCase)) + .OrderBy(login => login, StringComparer.OrdinalIgnoreCase)); + return result; + } } diff --git a/gclo.Engine/GitHubRepo.cs b/gclo.Engine/GitHubRepo.cs new file mode 100644 index 0000000..3aadc65 --- /dev/null +++ b/gclo.Engine/GitHubRepo.cs @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// One repository as the GitHub API describes it, in the fields gclo uses. +internal readonly record struct GitHubRepo(string Name, string CloneUrl, string? DefaultBranch, bool IsArchived); diff --git a/gclo.Engine/GitHubRepositoryLister.cs b/gclo.Engine/GitHubRepositoryLister.cs index b098d24..44961c8 100644 --- a/gclo.Engine/GitHubRepositoryLister.cs +++ b/gclo.Engine/GitHubRepositoryLister.cs @@ -1,143 +1,156 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using Octokit; + namespace gclo.Engine; + /// Lists org repositories through the GitHub REST API via Octokit. public sealed class GitHubRepositoryLister : IRepositoryLister { - private readonly Func _gatewayFactory; + private readonly Func _gatewayFactory; - /// Production wiring: the process-wide Octokit gateway for the call's token (connection reuse). - public GitHubRepositoryLister() - : this(OctokitGatewayCache.Get) - { - } - /// Test seam: substitute the GitHub API with a fake gateway. - internal GitHubRepositoryLister(Func gatewayFactory) - => _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory)); - /// - public async Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(organization); - ArgumentException.ThrowIfNullOrWhiteSpace(token); - cancellationToken.ThrowIfCancellationRequested(); + /// Production wiring: the process-wide Octokit gateway for the call's token (connection reuse). + 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> PageAsync(Func>> fetchPage) - { - var all = new List(); - for (int page = 1; ; page++) + + /// Test seam: substitute the GitHub API with a fake gateway. + internal GitHubRepositoryLister(Func gatewayFactory) + => _gatewayFactory = gatewayFactory ?? throw new ArgumentNullException(nameof(gatewayFactory)); + + + + /// + public async Task> 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> PageAsync(Func>> fetchPage) + { + var all = new List(); + for(int page = 1; ; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + IReadOnlyList 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 repositories; - try - { - try + List 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(); + } } diff --git a/gclo.Engine/IActivityLog.cs b/gclo.Engine/IActivityLog.cs index a3d0244..d54f07a 100644 --- a/gclo.Engine/IActivityLog.cs +++ b/gclo.Engine/IActivityLog.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// /// 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; /// public interface IActivityLog { - /// Records an informational event. - void Info(string message); + /// Records an informational event. + void Info(string message); - /// Records a failure, with the exception's full text when one is given. - void Error(string message, Exception? exception = null); - /// Folder that holds the log files. - string LogDirectory { get; } - /// Full path of the file new entries are currently appended to. - string CurrentLogFilePath { get; } + /// Records a failure, with the exception's full text when one is given. + void Error(string message, Exception? exception = null); + + + + /// Folder that holds the log files. + string LogDirectory { get; } + + /// Full path of the file new entries are currently appended to. + string CurrentLogFilePath { get; } } diff --git a/gclo.Engine/IGitClient.cs b/gclo.Engine/IGitClient.cs index cf36618..30b7397 100644 --- a/gclo.Engine/IGitClient.cs +++ b/gclo.Engine/IGitClient.cs @@ -1,32 +1,44 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// Git transport operations for a single repository. public interface IGitClient { - /// True when is an existing local git repository. - bool IsValidRepository(string path); + /// True when is an existing local git repository. + bool IsValidRepository(string path); - /// Clones into . - /// HTTPS clone URL. - /// Local directory the repository is cloned into. - /// GitHub Personal Access Token used for git transport. - /// Receives object-transfer completion in [0,1], when known. - /// Cancels the clone. - Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken); - /// Fetches from origin and fast-forwards the current branch to its upstream. - Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken); - /// - /// Materializes the working tree of a fetched-but-never-checked-out repository - /// (one whose clone failed with ) - /// by writing HEAD's blobs to disk with 's renames - /// and skips applied. Any previously persisted recovery is merged in first, - /// with winning on conflicts, so a repository that - /// gains new invalid paths can be fixed without restating earlier choices. - /// Throws 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. - /// - Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken); + /// Clones into . + /// HTTPS clone URL. + /// Local directory the repository is cloned into. + /// GitHub Personal Access Token used for git transport. + /// Receives object-transfer completion in [0,1], when known. + /// Cancels the clone. + Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken); + + + + /// Fetches from origin and fast-forwards the current branch to its upstream. + Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken); + + + + /// + /// Materializes the working tree of a fetched-but-never-checked-out repository + /// (one whose clone failed with ) + /// by writing HEAD's blobs to disk with 's renames + /// and skips applied. Any previously persisted recovery is merged in first, + /// with winning on conflicts, so a repository that + /// gains new invalid paths can be fixed without restating earlier choices. + /// Throws 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. + /// + Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken); } diff --git a/gclo.Engine/IGitHubGateway.cs b/gclo.Engine/IGitHubGateway.cs index 0c438e2..1bcc3f7 100644 --- a/gclo.Engine/IGitHubGateway.cs +++ b/gclo.Engine/IGitHubGateway.cs @@ -1,23 +1,13 @@ -using System.Diagnostics.CodeAnalysis; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using Octokit; + namespace gclo.Engine; -/// One repository as the GitHub API describes it, in the fields gclo uses. -internal readonly record struct GitHubRepo(string Name, string CloneUrl, string? DefaultBranch, bool IsArchived); - -/// What a GitHub login names, as far as the token can tell. -internal enum GitHubAccountKind -{ - /// A user account. - User, - - /// An organization (whether or not the token can see its repositories). - Organization, - - /// No account with that login is visible to the token. - NotFound, -} /// /// The seam between the listers and the GitHub REST API: exactly the calls gclo @@ -29,109 +19,36 @@ internal enum GitHubAccountKind /// internal interface IGitHubGateway { - /// Repositories per page; a batch smaller than this ends the paging loop. - const int PageSize = 100; + /// Repositories per page; a batch smaller than this ends the paging loop. + const int PageSize = 100; - /// One page of an organization's repositories (1-based page number). - Task> GetOrganizationRepositoriesPageAsync(string organization, int page); - /// One page of the token's own repositories (owner affiliation). - Task> GetOwnRepositoriesPageAsync(int page); - /// One page of another user account's visible repositories. - Task> GetUserRepositoriesPageAsync(string user, int page); + /// One page of an organization's repositories (1-based page number). + Task> GetOrganizationRepositoriesPageAsync(string organization, int page); - /// The login of the account the token authenticates as. - Task GetCurrentUserLoginAsync(); - /// Logins of the organizations visible to the token. - Task> GetOrganizationLoginsAsync(); - /// Whether is a user, an organization, or unknown to the token. - Task GetAccountKindAsync(string login); -} + /// One page of the token's own repositories (owner affiliation). + Task> GetOwnRepositoriesPageAsync(int page); -/// -/// One 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. -/// -[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 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)); - } -} -/// -/// The production 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). -/// -[ExcludeFromCodeCoverage(Justification = "Thin Octokit delegation; needs the live GitHub API.")] -internal sealed class OctokitGateway : IGitHubGateway -{ - private readonly GitHubClient _client; + /// One page of another user account's visible repositories. + Task> GetUserRepositoriesPageAsync(string user, int page); - public OctokitGateway(string token) - => _client = new GitHubClient(new ProductHeaderValue("gclo")) - { - Credentials = new Credentials(token), - }; - public async Task> GetOrganizationRepositoriesPageAsync(string organization, int page) - => Map(await _client.Repository.GetAllForOrg(organization, Page(page)).ConfigureAwait(false)); - public async Task> GetOwnRepositoriesPageAsync(int page) - { - var owned = new RepositoryRequest { Affiliation = RepositoryAffiliation.Owner }; - return Map(await _client.Repository.GetAllForCurrent(owned, Page(page)).ConfigureAwait(false)); - } + /// The login of the account the token authenticates as. + Task GetCurrentUserLoginAsync(); - public async Task> GetUserRepositoriesPageAsync(string user, int page) - => Map(await _client.Repository.GetAllForUser(user, Page(page)).ConfigureAwait(false)); - public async Task GetCurrentUserLoginAsync() - => (await _client.User.Current().ConfigureAwait(false)).Login; - public async Task> GetOrganizationLoginsAsync() - { - var organizations = await _client.Organization - .GetAllForCurrent(new ApiOptions { PageSize = IGitHubGateway.PageSize }) - .ConfigureAwait(false); - return organizations.Select(o => o.Login).ToList(); - } + /// Logins of the organizations visible to the token. + Task> GetOrganizationLoginsAsync(); - public async Task 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 Map(IReadOnlyList repositories) - => repositories.Select(r => new GitHubRepo(r.Name, r.CloneUrl, r.DefaultBranch, r.Archived)).ToList(); + /// Whether is a user, an organization, or unknown to the token. + Task GetAccountKindAsync(string login); } diff --git a/gclo.Engine/IOrganizationLister.cs b/gclo.Engine/IOrganizationLister.cs index 1c06ea6..7ca253f 100644 --- a/gclo.Engine/IOrganizationLister.cs +++ b/gclo.Engine/IOrganizationLister.cs @@ -1,13 +1,19 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// Lists the GitHub organizations a token can see. public interface IOrganizationLister { - /// - /// 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. - /// - Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default); + /// + /// 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. + /// + Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default); } diff --git a/gclo.Engine/IProcessRunner.cs b/gclo.Engine/IProcessRunner.cs new file mode 100644 index 0000000..ad3bffd --- /dev/null +++ b/gclo.Engine/IProcessRunner.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// +/// Runs an external process to completion. The seam that keeps +/// testable without a WSL installation: the offline suite scripts results through a fake. +/// +public interface IProcessRunner +{ + /// Executable path. + /// Argument vector (quoted by the runner, never by the caller). + /// Extra environment variables for the child; the parent's are inherited. + /// Text written to the child's stdin, then closed; null leaves stdin closed. + /// Kills the child and throws . + Task RunAsync( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? environment, + string? standardInput, + CancellationToken cancellationToken); +} diff --git a/gclo.Engine/IRepositoryLister.cs b/gclo.Engine/IRepositoryLister.cs index 7cf8a5c..a17031a 100644 --- a/gclo.Engine/IRepositoryLister.cs +++ b/gclo.Engine/IRepositoryLister.cs @@ -1,9 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// Lists every repository in a GitHub organization. public interface IRepositoryLister { - /// Returns all repositories in the organization, following pagination to the end. - Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default); + /// Returns all repositories in the organization, following pagination to the end. + Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default); } diff --git a/gclo.Engine/IWslCloner.cs b/gclo.Engine/IWslCloner.cs new file mode 100644 index 0000000..7157200 --- /dev/null +++ b/gclo.Engine/IWslCloner.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// +/// 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 +/// ~/gclo/<organization>/<repository> and left there. +/// +public interface IWslCloner +{ + /// Checks, without side effects, whether a clone into WSL can be attempted. + Task ProbeAsync(CancellationToken cancellationToken); + + + + /// + /// Clones inside the default WSL distribution, or fast-forwards + /// it when that folder is already a repository. Throws + /// when git or wsl.exe fail. + /// + /// HTTPS clone URL. + /// Organization (or user) name; the parent folder under ~/gclo. + /// Repository name; the clone's folder name. + /// GitHub token; handed to git through the environment only, never on a command line. + /// Kills wsl.exe (and the clone) when canceled. + Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken); +} diff --git a/gclo.Engine/InvalidPathInfo.cs b/gclo.Engine/InvalidPathInfo.cs index 0be96ae..20efca7 100644 --- a/gclo.Engine/InvalidPathInfo.cs +++ b/gclo.Engine/InvalidPathInfo.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// One repository path that cannot be created on a Windows file system. /// Path inside the repository, forward-slash separated. /// Human-readable reason the path is invalid on Windows. diff --git a/gclo.Engine/InvalidRepositoryPathsException.cs b/gclo.Engine/InvalidRepositoryPathsException.cs index 010cf43..f6d96b0 100644 --- a/gclo.Engine/InvalidRepositoryPathsException.cs +++ b/gclo.Engine/InvalidRepositoryPathsException.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// /// 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; /// public sealed class InvalidRepositoryPathsException : Exception { - /// The offending paths, one entry per invalid path. - public IReadOnlyList Paths { get; } + /// The offending paths, one entry per invalid path. + public IReadOnlyList Paths { get; } - /// Creates the exception from the validator's findings. - public InvalidRepositoryPathsException(IReadOnlyList paths) - : base(BuildMessage(paths)) - { - Paths = paths; - } - private static string BuildMessage(IReadOnlyList 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."; - } + + /// Creates the exception from the validator's findings. + public InvalidRepositoryPathsException(IReadOnlyList paths) + : base(BuildMessage(paths)) + { + Paths = paths; + } + + + + private static string BuildMessage(IReadOnlyList 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."; + } } diff --git a/gclo.Engine/LibGit2GitClient.cs b/gclo.Engine/LibGit2GitClient.cs index c6df14f..2c0401f 100644 --- a/gclo.Engine/LibGit2GitClient.cs +++ b/gclo.Engine/LibGit2GitClient.cs @@ -1,10 +1,17 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Diagnostics.CodeAnalysis; using System.Text.Json; using LibGit2Sharp; using LibGit2Sharp.Handlers; + namespace gclo.Engine; + /// /// Git operations implemented with LibGit2Sharp. /// @@ -27,769 +34,815 @@ namespace gclo.Engine; + "cancellation-timing branches are not deterministically reproducible offline. See remarks.")] public sealed class LibGit2GitClient : IGitClient { - /// - /// Local git config flag meaning "the objects are here, but the working tree was - /// never completely checked out". Set the moment a clone's fetch completes and - /// cleared only after checkout finishes, so it is durable evidence of an - /// incomplete clone whatever interrupted it: Windows path validation (the repo is - /// deliberately kept for recovery), a checkout error whose best-effort cleanup - /// could not delete the directory, or the process dying mid-checkout. The next - /// pull sees the flag and completes the checkout instead of comparing commit tips - /// and calling an incomplete tree "up to date". Cleared by a normal checkout or by - /// . - /// - private const string CheckoutPendingKey = "gclo.checkoutpending"; + /// + /// Local git config flag meaning "the objects are here, but the working tree was + /// never completely checked out". Set the moment a clone's fetch completes and + /// cleared only after checkout finishes, so it is durable evidence of an + /// incomplete clone whatever interrupted it: Windows path validation (the repo is + /// deliberately kept for recovery), a checkout error whose best-effort cleanup + /// could not delete the directory, or the process dying mid-checkout. The next + /// pull sees the flag and completes the checkout instead of comparing commit tips + /// and calling an incomplete tree "up to date". Cleared by a normal checkout or by + /// . + /// + private const string CheckoutPendingKey = "gclo.checkoutpending"; - /// File name (under .git) of the persisted . - private const string RecoveryFileName = "gclo-recovery.json"; + /// File name (under .git) of the persisted . + private const string RecoveryFileName = "gclo-recovery.json"; - private static readonly JsonSerializerOptions RecoveryJsonOptions = new() { WriteIndented = true }; + private static readonly JsonSerializerOptions s_recoveryJsonOptions = new() { WriteIndented = true }; - /// - /// Test seam: invoked right after a fetch completed, before the cancellation - /// check that precedes any working-tree work. Local-transport fixtures fire no - /// transfer callbacks, so this is how the offline suite exercises "canceled - /// between fetch and checkout/merge" deterministically (#31). Null in production. - /// - internal Action? AfterFetchForTesting { get; set; } + /// + /// Test seam: invoked right after a fetch completed, before the cancellation + /// check that precedes any working-tree work. Local-transport fixtures fire no + /// transfer callbacks, so this is how the offline suite exercises "canceled + /// between fetch and checkout/merge" deterministically (#31). Null in production. + /// + internal Action? AfterFetchForTesting { get; set; } - /// - public bool IsValidRepository(string path) - => Directory.Exists(path) && Repository.IsValid(path); - // LongRunning: each git operation blocks a thread for its whole duration; on the - // thread pool, MaxConcurrency operations would starve the pool and delay ramp-up. - /// - public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken) - => Task.Factory.StartNew( - () => Clone(url, path, token, onProgress, cancellationToken), - cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); - /// - public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken) - => Task.Factory.StartNew( - () => FetchAndPull(path, token, cancellationToken), - cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + /// + public bool IsValidRepository(string path) + => Directory.Exists(path) && Repository.IsValid(path); - /// - public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) - => Task.Factory.StartNew( - () => ApplyRecovery(path, recovery, cancellationToken), - cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); - private void Clone(string url, string path, string token, Action? onProgress, CancellationToken ct) - { - bool existedBefore = Directory.Exists(path); - var options = new CloneOptions - { - // Two-phase: fetch only, then checkout. On Windows the gap is used to - // set core.longpaths and validate every tree path against Windows rules — - // a repo that is fine on Linux must fail there with a structured error, - // not a libgit2 one. On other platforms phase two is a plain checkout. - Checkout = false, - }; - options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token); - int lastPercent = -1; - options.FetchOptions.OnTransferProgress = tp => - { - if (tp.TotalObjects > 0) + // LongRunning: each git operation blocks a thread for its whole duration; on the + // thread pool, MaxConcurrency operations would starve the pool and delay ramp-up. + /// + public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken) + => Task.Factory.StartNew( + () => Clone(url, path, token, onProgress, cancellationToken), + cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + + + /// + public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken) + => Task.Factory.StartNew( + () => FetchAndPull(path, token, cancellationToken), + cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + + + /// + public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) + => Task.Factory.StartNew( + () => ApplyRecovery(path, recovery, cancellationToken), + cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + + + private void Clone(string url, string path, string token, Action? onProgress, CancellationToken ct) + { + bool existedBefore = Directory.Exists(path); + + var options = new CloneOptions + { + // Two-phase: fetch only, then checkout. On Windows the gap is used to + // set core.longpaths and validate every tree path against Windows rules — + // a repo that is fine on Linux must fail there with a structured error, + // not a libgit2 one. On other platforms phase two is a plain checkout. + Checkout = false, + }; + options.FetchOptions.CredentialsProvider = MakeCredentialsProvider(token); + int lastPercent = -1; + options.FetchOptions.OnTransferProgress = tp => + { + if(tp.TotalObjects > 0) + { + // libgit2 fires this per network read / indexed object — thousands of + // times per clone. Forward only whole-percent changes so the UI + // dispatcher is not flooded. + int percent = (int)(100L * tp.ReceivedObjects / tp.TotalObjects); + if(percent != lastPercent) { - // libgit2 fires this per network read / indexed object — thousands of - // times per clone. Forward only whole-percent changes so the UI - // dispatcher is not flooded. - int percent = (int)(100L * tp.ReceivedObjects / tp.TotalObjects); - if (percent != lastPercent) - { - lastPercent = percent; - onProgress?.Invoke(percent / 100.0); - } + lastPercent = percent; + onProgress?.Invoke(percent / 100.0); } - return !ct.IsCancellationRequested; - }; - try - { - Repository.Clone(url, path, options); - AfterFetchForTesting?.Invoke(); + } + return !ct.IsCancellationRequested; + }; + try + { + Repository.Clone(url, path, options); + AfterFetchForTesting?.Invoke(); - using var repo = new Repository(path); + using var repo = new Repository(path); - // From here until checkout completes the directory is a valid repository - // with no (or a partial) working tree. Record that durably first: if the - // cleanup in the catch below cannot remove the directory, or the process - // dies, the next run must not mistake this for a finished clone. - repo.Config.Set(CheckoutPendingKey, true, ConfigurationLevel.Local); + // From here until checkout completes the directory is a valid repository + // with no (or a partial) working tree. Record that durably first: if the + // cleanup in the catch below cannot remove the directory, or the process + // dies, the next run must not mistake this for a finished clone. + repo.Config.Set(CheckoutPendingKey, true, ConfigurationLevel.Local); - if (OperatingSystem.IsWindows()) + if(OperatingSystem.IsWindows()) + { + // Lifts the 260-character path limit before anything touches the + // working tree; libgit2 honors it for checkout. + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + } + + Commit? tip = repo.Head.Tip; + if(tip is null) + { + // Empty repository — nothing to check out, so nothing is pending. + repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); + return; + } + + if(OperatingSystem.IsWindows()) + { + IReadOnlyList invalidPaths = WindowsPathValidator.Validate(tip.Tree); + if(invalidPaths.Count > 0) { - // Lifts the 260-character path limit before anything touches the - // working tree; libgit2 honors it for checkout. - repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + // Keep the fetched repo: all objects are already downloaded, so + // ApplyRecoveryAsync can materialize a sanitized working tree without + // touching the network. The pending marker (already set) tells + // FetchAndPull that this repository was never checked out. + throw new InvalidRepositoryPathsException(invalidPaths); + } + } + + ct.ThrowIfCancellationRequested(); + Commands.Checkout(repo, repo.Head, new CheckoutOptions + { + CheckoutModifiers = CheckoutModifiers.Force, // materialize the fresh working tree + // The only checkout that lacked the hook: a canceled clone used to + // write its whole tree before the token was ever observed (#31). + CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, + OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, + }); + + // Only now is the clone complete. Config writes are atomic on disk, so an + // observer sees either "pending" or "done", never a torn state. + repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); + } + catch(Exception ex) + { + // Don't leave a half-cloned directory behind: the next run would treat a + // partial checkout as "exists locally" and try to pull it. Invalid-path + // failures are the deliberate exception — the fetched repo is kept (with + // the pending marker set above) so recovery needs no re-download. If this + // delete fails (open handle, antivirus, permissions) the marker is still + // set, and the next pull completes the checkout instead of trusting the tree. + if(!existedBefore && ex is not InvalidRepositoryPathsException) + { + TryDeleteDirectory(path); + } + + if(ex is UserCancelledException && ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + throw; + } + } + + + + private void FetchAndPull(string path, string token, CancellationToken ct) + { + using var repo = new Repository(path); + + if(OperatingSystem.IsWindows()) + { + // Idempotent; also covers repositories that were cloned by other tools. + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + } + + Remote remote = repo.Network.Remotes["origin"] + ?? throw new InvalidOperationException("Repository has no 'origin' remote."); + + var fetchOptions = new FetchOptions + { + CredentialsProvider = MakeCredentialsProvider(token), + OnTransferProgress = _ => !ct.IsCancellationRequested, + // Drop tracking refs for branches origin deleted. Without this a renamed + // default branch leaves refs/remotes/origin/ frozen at its last SHA, + // and every later sync compares against it and says "up to date" (#31). + Prune = true, + }; + + var refSpecs = remote.FetchRefSpecs.Select(s => s.Specification).ToList(); + try + { + Commands.Fetch(repo, remote.Name, refSpecs, fetchOptions, logMessage: null); + } + catch(UserCancelledException) when(ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + AfterFetchForTesting?.Invoke(); + + ct.ThrowIfCancellationRequested(); + + // Repositories whose clone never finished its checkout — Windows-invalid + // paths, a checkout error whose cleanup failed, a killed process — carry the + // pending marker (and, once the user chose renames/skips, a persisted + // recovery). Both take a dedicated path — their working tree is materialized + // from the tip, never by a merge checkout that trusts the existing tree. + // Every other repository takes the normal fetch + fast-forward pull below. + string recoveryFile = GetRecoveryFilePath(repo); + if(File.Exists(recoveryFile)) + { + // A recovery-managed repo stays recovery-managed: re-materialize the new + // tip through the stored mapping. Freshly-invalid paths the mapping does + // not cover surface as a typed failure listing the effective paths. + AdvanceHeadToTrackedTip(repo); + RecoveryDocument stored = LoadRecoveryDocument(recoveryFile); + if(stored.MaterializedTip is not null && string.Equals(stored.MaterializedTip, repo.Head.Tip?.Sha, StringComparison.Ordinal)) + { + // The tree on disk already reflects this tip: nothing to rewrite. A + // recovery-managed repo used to re-materialize every blob on every + // sync, touching gigabytes and every mtime for zero change (#31). + return; + } + ApplyRecoveryCore(repo, ToRecovery(stored), ct); + return; + } + + if(IsCheckoutPending(repo)) + { + CompletePendingCheckout(repo, ct); + return; + } + + if(repo.Info.IsHeadDetached) + { + throw new InvalidOperationException( + "HEAD is detached (a commit is checked out, not a branch); fetched, but nothing was merged. " + + "Check out a branch in this repository to resume updates, or delete the folder to re-clone."); + } + + if(repo.Info.IsHeadUnborn) + { + // Empty clone — or a clone that was killed before its first checkout ran. + // If the remote branch exists now, materialize it instead of silently + // reporting an empty working tree as up to date forever. + MaterializeUnbornHead(repo, ct); + return; + } + + Branch tracked = repo.Head.TrackedBranch; + if(tracked?.Tip is null) + { + if(repo.Head.UpstreamBranchCanonicalName is null) + { + return; // no upstream configured: fetch is all we can do + } + + // Upstream is configured but gone from origin (the fetch pruned it): the + // default branch was renamed (master -> main) or deleted. Follow origin's + // current default instead of reporting "up to date" forever (#31). + tracked = RetargetToRemoteDefaultBranch(repo, remote, fetchOptions.CredentialsProvider, ct); + } + + if(string.Equals(repo.Head.Tip?.Sha, tracked.Tip.Sha, StringComparison.Ordinal)) + { + return; // already up to date + } + + // Incoming commits can introduce Windows-invalid paths just like a clone can. + // Only what the pull introduces is checked: the current tree was validated + // when it was checked out, and re-walking a 100K-file tree for a one-file + // commit was the dominant engine cost of a daily update run (#30). + if(OperatingSystem.IsWindows()) + { + // HEAD cannot be unborn here (handled above), so the current tip exists. + IReadOnlyList invalidIncoming = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip!.Tree, tracked.Tip.Tree); + if(invalidIncoming.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidIncoming); + } + } + + // Fast-forward only: a mirror tool must never manufacture merge commits. + // Diverged local history is the most common pull failure for this tool's + // audience (someone committed in a synced clone), so it gets a message that + // says what gclo refuses to do and what to do about it (#30). + var signature = new Signature("gclo", "gclo@localhost", DateTimeOffset.Now); + try + { + repo.Merge(tracked, signature, new MergeOptions + { + FastForwardStrategy = FastForwardStrategy.FastForwardOnly, + CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, + OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, + }); + } + catch(UserCancelledException) when(ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + catch(NonFastForwardException ex) + { + throw new InvalidOperationException( + $"'{repo.Head.FriendlyName}' has local commits that origin does not have; gclo only fast-forwards and never merges. " + + "Push or reset the local commits, or delete the folder to re-clone.", ex); + } + } + + + + /// + /// The current branch's upstream no longer exists on origin. Asks origin for its + /// current default branch (the remote HEAD symref), checks it out as the new + /// local branch with upstream wired, and removes the orphaned local branch — but + /// only when every commit of the old branch is already contained in the new + /// default, because a mirror tool never discards local work. Returns the new + /// tracking branch so the normal pull continues from it. + /// + private static Branch RetargetToRemoteDefaultBranch( + Repository repo, Remote remote, CredentialsHandler credentials, CancellationToken ct) + { + const string Prefix = "refs/heads/"; + string oldName = repo.Head.FriendlyName; + + string? defaultRef = repo.Network.ListReferences(remote, credentials) + .FirstOrDefault(r => string.Equals(r.CanonicalName, "HEAD", StringComparison.Ordinal))?.TargetIdentifier; + if(defaultRef is null || !defaultRef.StartsWith(Prefix, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"'{oldName}' no longer exists on origin, and origin has no default branch to follow. " + + "Check the repository on GitHub, or delete the folder to re-clone."); + } + + string newName = defaultRef[Prefix.Length..]; + Branch remoteBranch = repo.Branches[$"{remote.Name}/{newName}"]; + if(remoteBranch?.Tip is null) + { + throw new InvalidOperationException( + $"'{oldName}' no longer exists on origin; its default branch is now '{newName}', which was not fetched. " + + "Delete the folder to re-clone."); + } + + Commit? oldTip = repo.Head.Tip; + if(oldTip is not null && !string.Equals(repo.ObjectDatabase.FindMergeBase(oldTip, remoteBranch.Tip)?.Sha, oldTip.Sha, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"'{oldName}' no longer exists on origin (the default branch is now '{newName}'), but it has local commits " + + $"that '{newName}' does not contain; gclo never discards local work. Push or reset them, or delete the folder to re-clone."); + } + + Branch local = repo.Branches[newName] ?? repo.CreateBranch(newName, remoteBranch.Tip); + repo.Branches.Update(local, b => + { + b.Remote = remote.Name; + b.UpstreamBranch = defaultRef; + }); + + ct.ThrowIfCancellationRequested(); + try + { + Commands.Checkout(repo, local, new CheckoutOptions + { + CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, + OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, + }); + } + catch(UserCancelledException) when(ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + + if(!string.Equals(oldName, newName, StringComparison.Ordinal)) + { + repo.Branches.Remove(oldName); // fully contained in the new default; nothing is lost + } + + return repo.Head.TrackedBranch + ?? throw new InvalidOperationException($"'{newName}' could not be set to track origin."); + } + + + + /// + /// HEAD symbolically points at a branch with no commits. If the fetch brought that + /// branch's remote counterpart, create the local branch there, wire its upstream, + /// and check it out to populate the working tree. + /// + private static void MaterializeUnbornHead(Repository repo, CancellationToken ct) + { + const string Prefix = "refs/heads/"; + string targetRef = repo.Refs.Head.TargetIdentifier; + if(!targetRef.StartsWith(Prefix, StringComparison.Ordinal)) + { + return; + } + + string branchName = targetRef[Prefix.Length..]; + Branch remoteBranch = repo.Branches["origin/" + branchName]; + if(remoteBranch?.Tip is null) + { + return; // the remote is still empty; nothing to pull + } + + if(OperatingSystem.IsWindows()) + { + IReadOnlyList invalidPaths = WindowsPathValidator.Validate(remoteBranch.Tip.Tree); + if(invalidPaths.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidPaths); + } + } + + repo.Refs.Add(targetRef, remoteBranch.Tip.Id); + Branch local = repo.Branches[branchName]; + repo.Branches.Update(local, b => + { + b.Remote = "origin"; + b.UpstreamBranch = targetRef; + }); + + ct.ThrowIfCancellationRequested(); + try + { + // Force: the working tree of a killed half-clone may hold partial files; + // recovering means making it match the branch tip exactly. + Commands.Checkout(repo, local, new CheckoutOptions + { + CheckoutModifiers = CheckoutModifiers.Force, + CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, + OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, + }); + } + catch(UserCancelledException) when(ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); + } + } + + + + // ---------------------------------------------------------------- path recovery + + private static void ApplyRecovery(string path, PathRecovery recovery, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(recovery); + using var repo = new Repository(path); + ApplyRecoveryCore(repo, recovery, ct); + } + + + + /// + /// Materializes HEAD's tree into the working directory with the recovery's renames + /// and skips applied, after validating that the effective path set is actually + /// creatable on Windows. Any recovery persisted from an earlier run is merged in + /// first (the incoming recovery wins on conflicts), so a recovery-managed repo + /// that gains new invalid paths can be fixed incrementally. Persists the merged + /// recovery so later pulls re-apply it, then clears the pending-checkout marker. + /// + private static void ApplyRecoveryCore(Repository repo, PathRecovery recovery, CancellationToken ct) + { + if(OperatingSystem.IsWindows()) + { + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + } + + string recoveryFile = GetRecoveryFilePath(repo); + RecoveryDocument? previous = File.Exists(recoveryFile) ? LoadRecoveryDocument(recoveryFile) : null; + if(previous is not null) + { + recovery = MergeRecoveries(stored: ToRecovery(previous), incoming: recovery); + } + + Commit tip = repo.Head.Tip + ?? throw new InvalidOperationException("Repository has no commits; nothing to materialize."); + + List<(string OriginalPath, string EffectivePath, Blob Blob)> entries = CollectEffectiveEntries(tip.Tree, recovery); + + // Validate BEFORE writing anything: a bad mapping (still-invalid segment, or + // two originals landing on one destination) must fail cleanly with the + // effective paths, leaving the working tree untouched. + IReadOnlyList stillInvalid = WindowsPathValidator.ValidatePaths(entries.Select(e => e.EffectivePath)); + if(stillInvalid.Count > 0) + { + throw new InvalidRepositoryPathsException(stillInvalid); + } + + string workdir = repo.Info.WorkingDirectory; + + // A real checkout removes what left the tree; this materialization must too, + // or a recovery-managed working tree accumulates every file upstream ever + // deleted or renamed (#31). The previous apply's manifest says what it wrote. + var effectiveNow = new HashSet(entries.Select(e => e.EffectivePath), StringComparer.Ordinal); + foreach(string stale in previous?.MaterializedPaths ?? []) + { + if(!effectiveNow.Contains(stale)) + { + DeleteMaterializedFile(workdir, stale); + } + } + + foreach((string? originalPath, string? effectivePath, Blob? blob) in entries) + { + ct.ThrowIfCancellationRequested(); + + string fullPath = Path.Combine(workdir, effectivePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + if(File.Exists(fullPath)) + { + // Force semantics: overwrite whatever is there, read-only or not. + File.SetAttributes(fullPath, FileAttributes.Normal); + } + + // Filter through .gitattributes (CRLF etc.) under the ORIGINAL repo path, + // exactly as a checkout of that entry would. + using Stream content = blob.GetContentStream(new FilteringOptions(originalPath)); + using var file = new FileStream(fullPath, FileMode.Create, FileAccess.Write); + content.CopyTo(file); + } + + SaveRecovery(repo, recovery, entries.Select(e => e.EffectivePath), tip.Sha); + repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); + } + + + + /// + /// Removes one file a previous materialization wrote (a user-deleted file is + /// fine) and prunes the directories it leaves empty, stopping at the working + /// directory root. + /// + private static void DeleteMaterializedFile(string workdir, string effectivePath) + { + string fullPath = Path.Combine(workdir, effectivePath.Replace('/', Path.DirectorySeparatorChar)); + if(File.Exists(fullPath)) + { + File.SetAttributes(fullPath, FileAttributes.Normal); + File.Delete(fullPath); + } + + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(workdir)); + string? directory = Path.GetDirectoryName(fullPath); + while(directory is not null + && !string.Equals(Path.TrimEndingDirectorySeparator(directory), root, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(directory) + && !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + directory = Path.GetDirectoryName(directory); + } + } + + + + /// + /// Overlays onto : renames + /// union with the incoming value winning on a shared original path; skips union. + /// + private static PathRecovery MergeRecoveries(PathRecovery stored, PathRecovery incoming) + { + var renames = new Dictionary(stored.SegmentRenames, StringComparer.Ordinal); + foreach((string? originalPath, string? replacement) in incoming.SegmentRenames) + { + renames[originalPath] = replacement; + } + + var skips = new HashSet(stored.SkippedPaths, StringComparer.Ordinal); + skips.UnionWith(incoming.SkippedPaths); + + return new PathRecovery(renames, skips); + } + + + + /// + /// Walks iteratively, resolving every blob to the path it + /// should occupy on disk: skipped files and whole skipped directories are omitted, + /// and a rename of any path (file or directory) replaces its final segment in + /// place — for a directory, the entire subtree moves with it. + /// + private static List<(string OriginalPath, string EffectivePath, Blob Blob)> CollectEffectiveEntries( + Tree tree, PathRecovery recovery) + { + var entries = new List<(string, string, Blob)>(); + var stack = new Stack<(Tree Tree, string OriginalPrefix, string EffectivePrefix)>(); + stack.Push((tree, "", "")); + + while(stack.Count > 0) + { + (Tree? current, string? originalPrefix, string? effectivePrefix) = stack.Pop(); + foreach(TreeEntry? entry in current) + { + string originalPath = originalPrefix.Length == 0 ? entry.Name : $"{originalPrefix}/{entry.Name}"; + if(recovery.SkippedPaths.Contains(originalPath)) + { + continue; // omit the file — or the whole subtree when this is a directory } - var tip = repo.Head.Tip; - if (tip is null) - { - // Empty repository — nothing to check out, so nothing is pending. - repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); - return; - } + // Renames key on the full ORIGINAL path, but a mapped value contributes + // only its last segment, joined onto the parent's EFFECTIVE prefix — so + // renaming both a directory and one of its descendants composes instead + // of the descendant's mapping resurrecting the parent's original name. + // Unmapped entries keep their own name under that same prefix. + string effectiveName = recovery.SegmentRenames.TryGetValue(originalPath, out string? mapped) + ? mapped[(mapped.LastIndexOf('/') + 1)..] + : entry.Name; + string effectivePath = effectivePrefix.Length == 0 + ? effectiveName + : $"{effectivePrefix}/{effectiveName}"; - if (OperatingSystem.IsWindows()) + switch(entry.TargetType) { - var invalidPaths = WindowsPathValidator.Validate(tip.Tree); - if (invalidPaths.Count > 0) - { - // Keep the fetched repo: all objects are already downloaded, so - // ApplyRecoveryAsync can materialize a sanitized working tree without - // touching the network. The pending marker (already set) tells - // FetchAndPull that this repository was never checked out. - throw new InvalidRepositoryPathsException(invalidPaths); - } + case TreeEntryTargetType.Tree: + stack.Push(((Tree)entry.Target, originalPath, effectivePath)); + break; + case TreeEntryTargetType.Blob: + // Executable-bit and symlink entries are written as regular + // files: NTFS has no executable bit, and creating symlinks on + // Windows requires elevation (a symlink blob's content is its + // target path, which is still useful as a plain file). + entries.Add((originalPath, effectivePath, (Blob)entry.Target)); + break; + default: + break; // GitLink (submodule): nothing to materialize } + } + } - ct.ThrowIfCancellationRequested(); + return entries; + } + + + + /// + /// Finishes a clone whose checkout never completed and that has no stored + /// recovery: moves to the upstream tip, re-validates its paths on Windows, and + /// force-checks-out the whole tree (whatever partial files exist are overwritten + /// to match). If the paths are still invalid, rethrows the same typed failure — + /// the repo must not silently report success with an empty or partial tree. + /// + private static void CompletePendingCheckout(Repository repo, CancellationToken ct) + { + AdvanceHeadToTrackedTip(repo); + + Commit? tip = repo.Head.Tip; + if(tip is not null) + { + if(OperatingSystem.IsWindows()) + { + IReadOnlyList invalidPaths = WindowsPathValidator.Validate(tip.Tree); + if(invalidPaths.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidPaths); + } + } + + ct.ThrowIfCancellationRequested(); + try + { Commands.Checkout(repo, repo.Head, new CheckoutOptions { - CheckoutModifiers = CheckoutModifiers.Force, // materialize the fresh working tree - // The only checkout that lacked the hook: a canceled clone used to - // write its whole tree before the token was ever observed (#31). - CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, - OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, + CheckoutModifiers = CheckoutModifiers.Force, // nothing was ever checked out + CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, + OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, }); - - // Only now is the clone complete. Config writes are atomic on disk, so an - // observer sees either "pending" or "done", never a torn state. - repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); - } - catch (Exception ex) - { - // Don't leave a half-cloned directory behind: the next run would treat a - // partial checkout as "exists locally" and try to pull it. Invalid-path - // failures are the deliberate exception — the fetched repo is kept (with - // the pending marker set above) so recovery needs no re-download. If this - // delete fails (open handle, antivirus, permissions) the marker is still - // set, and the next pull completes the checkout instead of trusting the tree. - if (!existedBefore && ex is not InvalidRepositoryPathsException) - { - TryDeleteDirectory(path); - } - - if (ex is UserCancelledException && ct.IsCancellationRequested) - { - throw new OperationCanceledException(ct); - } - throw; - } - } - - private void FetchAndPull(string path, string token, CancellationToken ct) - { - using var repo = new Repository(path); - - if (OperatingSystem.IsWindows()) - { - // Idempotent; also covers repositories that were cloned by other tools. - repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); - } - - var remote = repo.Network.Remotes["origin"] - ?? throw new InvalidOperationException("Repository has no 'origin' remote."); - - var fetchOptions = new FetchOptions - { - CredentialsProvider = MakeCredentialsProvider(token), - OnTransferProgress = _ => !ct.IsCancellationRequested, - // Drop tracking refs for branches origin deleted. Without this a renamed - // default branch leaves refs/remotes/origin/ frozen at its last SHA, - // and every later sync compares against it and says "up to date" (#31). - Prune = true, - }; - - var refSpecs = remote.FetchRefSpecs.Select(s => s.Specification).ToList(); - try - { - Commands.Fetch(repo, remote.Name, refSpecs, fetchOptions, logMessage: null); - } - catch (UserCancelledException) when (ct.IsCancellationRequested) - { + } + catch(UserCancelledException) when(ct.IsCancellationRequested) + { throw new OperationCanceledException(ct); - } - AfterFetchForTesting?.Invoke(); + } + } - ct.ThrowIfCancellationRequested(); + repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); + } - // Repositories whose clone never finished its checkout — Windows-invalid - // paths, a checkout error whose cleanup failed, a killed process — carry the - // pending marker (and, once the user chose renames/skips, a persisted - // recovery). Both take a dedicated path — their working tree is materialized - // from the tip, never by a merge checkout that trusts the existing tree. - // Every other repository takes the normal fetch + fast-forward pull below. - string recoveryFile = GetRecoveryFilePath(repo); - if (File.Exists(recoveryFile)) - { - // A recovery-managed repo stays recovery-managed: re-materialize the new - // tip through the stored mapping. Freshly-invalid paths the mapping does - // not cover surface as a typed failure listing the effective paths. - AdvanceHeadToTrackedTip(repo); - RecoveryDocument stored = LoadRecoveryDocument(recoveryFile); - if (stored.MaterializedTip is not null && stored.MaterializedTip == repo.Head.Tip?.Sha) - { - // The tree on disk already reflects this tip: nothing to rewrite. A - // recovery-managed repo used to re-materialize every blob on every - // sync, touching gigabytes and every mtime for zero change (#31). - return; - } - ApplyRecoveryCore(repo, ToRecovery(stored), ct); + + + /// + /// Moves the current branch's ref to its upstream tip without touching the working + /// tree. Only used for never-checked-out (marker) and recovery-managed repos, whose + /// local branch can hold no local work — so a plain ref move is safe where the + /// normal pull path would insist on a fast-forward merge. + /// + private static void AdvanceHeadToTrackedTip(Repository repo) + { + Branch tracked = repo.Head.TrackedBranch; + if(tracked?.Tip is null || string.Equals(repo.Head.Tip?.Sha, tracked.Tip.Sha, StringComparison.Ordinal)) + { + return; + } + repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), tracked.Tip.Id); + } + + + + private static bool IsCheckoutPending(Repository repo) + => repo.Config.Get(CheckoutPendingKey)?.Value == true; + + + + private static string GetRecoveryFilePath(Repository repo) + => Path.Combine(repo.Info.Path, RecoveryFileName); + + + + /// Serializable shape of for .git\gclo-recovery.json. + /// Original path to replacement path map; null when absent from the file. + /// Paths omitted from the working tree; null when absent from the file. + /// Effective paths the last apply wrote, so the next one can delete what left the tree; null in files from before this field existed. + /// SHA of the commit the last apply materialized; null in older files. + private sealed record RecoveryDocument( + Dictionary? SegmentRenames, + List? SkippedPaths, + List? MaterializedPaths, + string? MaterializedTip); + + + + private static void SaveRecovery( + Repository repo, PathRecovery recovery, IEnumerable materializedPaths, string tipSha) + { + var document = new RecoveryDocument( + new Dictionary(recovery.SegmentRenames, StringComparer.Ordinal), + recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList(), + materializedPaths.Order(StringComparer.Ordinal).ToList(), + tipSha); + + // Atomic replace: a crash mid-write must not leave a truncated document that + // fails every later pull of this repository (#31). + string path = GetRecoveryFilePath(repo); + string temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(document, s_recoveryJsonOptions)); + File.Move(temp, path, overwrite: true); + } + + + + private static RecoveryDocument LoadRecoveryDocument(string filePath) + { + try + { + return JsonSerializer.Deserialize(File.ReadAllText(filePath), s_recoveryJsonOptions) + ?? throw new JsonException("the document is empty"); + } + catch(JsonException ex) + { + throw new InvalidOperationException( + $"The path-recovery file '{filePath}' is corrupt ({ex.Message}). " + + "Delete it and use Resolve on this repository again to rebuild it.", ex); + } + } + + + + private static PathRecovery ToRecovery(RecoveryDocument document) + => new( + new Dictionary(document.SegmentRenames ?? new(StringComparer.Ordinal), StringComparer.Ordinal), + new HashSet(document.SkippedPaths ?? [], StringComparer.Ordinal)); + + + + /// + /// GitHub accepts any username when the PAT is sent as the password. libgit2 asks + /// again after every 401; handing it the same rejected token loops until it gives + /// up with "too many redirects or authentication replays", once per repository. + /// A correct token is never asked for a third time, so the third request means the + /// token is bad and gets the same actionable message the API listers produce (#31). + /// + private static CredentialsHandler MakeCredentialsProvider(string token) + { + int requests = 0; + return (_, _, _) => + { + if(Interlocked.Increment(ref requests) > 2) + { + throw new InvalidOperationException( + "GitHub rejected the token for git over HTTPS (401). Check the PAT: it may have expired, " + + "been revoked, or lack access to this repository."); + } + return new UsernamePasswordCredentials + { + Username = "x-access-token", + Password = token, + }; + }; + } + + + + private static void TryDeleteDirectory(string path) + { + try + { + if(!Directory.Exists(path)) + { return; - } - - if (IsCheckoutPending(repo)) - { - CompletePendingCheckout(repo, ct); - return; - } - - if (repo.Info.IsHeadDetached) - { - throw new InvalidOperationException( - "HEAD is detached (a commit is checked out, not a branch); fetched, but nothing was merged. " - + "Check out a branch in this repository to resume updates, or delete the folder to re-clone."); - } - - if (repo.Info.IsHeadUnborn) - { - // Empty clone — or a clone that was killed before its first checkout ran. - // If the remote branch exists now, materialize it instead of silently - // reporting an empty working tree as up to date forever. - MaterializeUnbornHead(repo, ct); - return; - } - - var tracked = repo.Head.TrackedBranch; - if (tracked?.Tip is null) - { - if (repo.Head.UpstreamBranchCanonicalName is null) - { - return; // no upstream configured: fetch is all we can do - } - - // Upstream is configured but gone from origin (the fetch pruned it): the - // default branch was renamed (master -> main) or deleted. Follow origin's - // current default instead of reporting "up to date" forever (#31). - tracked = RetargetToRemoteDefaultBranch(repo, remote, fetchOptions.CredentialsProvider, ct); - } - - if (repo.Head.Tip?.Sha == tracked.Tip.Sha) - { - return; // already up to date - } - - // Incoming commits can introduce Windows-invalid paths just like a clone can. - // Only what the pull introduces is checked: the current tree was validated - // when it was checked out, and re-walking a 100K-file tree for a one-file - // commit was the dominant engine cost of a daily update run (#30). - if (OperatingSystem.IsWindows()) - { - // HEAD cannot be unborn here (handled above), so the current tip exists. - var invalidIncoming = WindowsPathValidator.ValidateIncoming(repo, repo.Head.Tip!.Tree, tracked.Tip.Tree); - if (invalidIncoming.Count > 0) - { - throw new InvalidRepositoryPathsException(invalidIncoming); - } - } - - // Fast-forward only: a mirror tool must never manufacture merge commits. - // Diverged local history is the most common pull failure for this tool's - // audience (someone committed in a synced clone), so it gets a message that - // says what gclo refuses to do and what to do about it (#30). - var signature = new Signature("gclo", "gclo@localhost", DateTimeOffset.Now); - try - { - repo.Merge(tracked, signature, new MergeOptions - { - FastForwardStrategy = FastForwardStrategy.FastForwardOnly, - CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, - OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, - }); - } - catch (UserCancelledException) when (ct.IsCancellationRequested) - { - throw new OperationCanceledException(ct); - } - catch (NonFastForwardException ex) - { - throw new InvalidOperationException( - $"'{repo.Head.FriendlyName}' has local commits that origin does not have; gclo only fast-forwards and never merges. " - + "Push or reset the local commits, or delete the folder to re-clone.", ex); - } - } - - /// - /// The current branch's upstream no longer exists on origin. Asks origin for its - /// current default branch (the remote HEAD symref), checks it out as the new - /// local branch with upstream wired, and removes the orphaned local branch — but - /// only when every commit of the old branch is already contained in the new - /// default, because a mirror tool never discards local work. Returns the new - /// tracking branch so the normal pull continues from it. - /// - private static Branch RetargetToRemoteDefaultBranch( - Repository repo, Remote remote, CredentialsHandler credentials, CancellationToken ct) - { - const string prefix = "refs/heads/"; - string oldName = repo.Head.FriendlyName; - - string? defaultRef = repo.Network.ListReferences(remote, credentials) - .FirstOrDefault(r => r.CanonicalName == "HEAD")?.TargetIdentifier; - if (defaultRef is null || !defaultRef.StartsWith(prefix, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"'{oldName}' no longer exists on origin, and origin has no default branch to follow. " - + "Check the repository on GitHub, or delete the folder to re-clone."); - } - - string newName = defaultRef[prefix.Length..]; - var remoteBranch = repo.Branches[$"{remote.Name}/{newName}"]; - if (remoteBranch?.Tip is null) - { - throw new InvalidOperationException( - $"'{oldName}' no longer exists on origin; its default branch is now '{newName}', which was not fetched. " - + "Delete the folder to re-clone."); - } - - var oldTip = repo.Head.Tip; - if (oldTip is not null && repo.ObjectDatabase.FindMergeBase(oldTip, remoteBranch.Tip)?.Sha != oldTip.Sha) - { - throw new InvalidOperationException( - $"'{oldName}' no longer exists on origin (the default branch is now '{newName}'), but it has local commits " - + $"that '{newName}' does not contain; gclo never discards local work. Push or reset them, or delete the folder to re-clone."); - } - - var local = repo.Branches[newName] ?? repo.CreateBranch(newName, remoteBranch.Tip); - repo.Branches.Update(local, b => - { - b.Remote = remote.Name; - b.UpstreamBranch = defaultRef; - }); - - ct.ThrowIfCancellationRequested(); - try - { - Commands.Checkout(repo, local, new CheckoutOptions - { - CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, - OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, - }); - } - catch (UserCancelledException) when (ct.IsCancellationRequested) - { - throw new OperationCanceledException(ct); - } - - if (!string.Equals(oldName, newName, StringComparison.Ordinal)) - { - repo.Branches.Remove(oldName); // fully contained in the new default; nothing is lost - } - - return repo.Head.TrackedBranch - ?? throw new InvalidOperationException($"'{newName}' could not be set to track origin."); - } - - /// - /// HEAD symbolically points at a branch with no commits. If the fetch brought that - /// branch's remote counterpart, create the local branch there, wire its upstream, - /// and check it out to populate the working tree. - /// - private static void MaterializeUnbornHead(Repository repo, CancellationToken ct) - { - const string prefix = "refs/heads/"; - string targetRef = repo.Refs.Head.TargetIdentifier; - if (!targetRef.StartsWith(prefix, StringComparison.Ordinal)) - { - return; - } - - string branchName = targetRef[prefix.Length..]; - var remoteBranch = repo.Branches["origin/" + branchName]; - if (remoteBranch?.Tip is null) - { - return; // the remote is still empty; nothing to pull - } - - if (OperatingSystem.IsWindows()) - { - var invalidPaths = WindowsPathValidator.Validate(remoteBranch.Tip.Tree); - if (invalidPaths.Count > 0) - { - throw new InvalidRepositoryPathsException(invalidPaths); - } - } - - repo.Refs.Add(targetRef, remoteBranch.Tip.Id); - var local = repo.Branches[branchName]; - repo.Branches.Update(local, b => - { - b.Remote = "origin"; - b.UpstreamBranch = targetRef; - }); - - ct.ThrowIfCancellationRequested(); - try - { - // Force: the working tree of a killed half-clone may hold partial files; - // recovering means making it match the branch tip exactly. - Commands.Checkout(repo, local, new CheckoutOptions - { - CheckoutModifiers = CheckoutModifiers.Force, - CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, - OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, - }); - } - catch (UserCancelledException) when (ct.IsCancellationRequested) - { - throw new OperationCanceledException(ct); - } - } - - // ---------------------------------------------------------------- path recovery - - private static void ApplyRecovery(string path, PathRecovery recovery, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(recovery); - using var repo = new Repository(path); - ApplyRecoveryCore(repo, recovery, ct); - } - - /// - /// Materializes HEAD's tree into the working directory with the recovery's renames - /// and skips applied, after validating that the effective path set is actually - /// creatable on Windows. Any recovery persisted from an earlier run is merged in - /// first (the incoming recovery wins on conflicts), so a recovery-managed repo - /// that gains new invalid paths can be fixed incrementally. Persists the merged - /// recovery so later pulls re-apply it, then clears the pending-checkout marker. - /// - private static void ApplyRecoveryCore(Repository repo, PathRecovery recovery, CancellationToken ct) - { - if (OperatingSystem.IsWindows()) - { - repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); - } - - string recoveryFile = GetRecoveryFilePath(repo); - RecoveryDocument? previous = File.Exists(recoveryFile) ? LoadRecoveryDocument(recoveryFile) : null; - if (previous is not null) - { - recovery = MergeRecoveries(stored: ToRecovery(previous), incoming: recovery); - } - - var tip = repo.Head.Tip - ?? throw new InvalidOperationException("Repository has no commits; nothing to materialize."); - - var entries = CollectEffectiveEntries(tip.Tree, recovery); - - // Validate BEFORE writing anything: a bad mapping (still-invalid segment, or - // two originals landing on one destination) must fail cleanly with the - // effective paths, leaving the working tree untouched. - var stillInvalid = WindowsPathValidator.ValidatePaths(entries.Select(e => e.EffectivePath)); - if (stillInvalid.Count > 0) - { - throw new InvalidRepositoryPathsException(stillInvalid); - } - - string workdir = repo.Info.WorkingDirectory; - - // A real checkout removes what left the tree; this materialization must too, - // or a recovery-managed working tree accumulates every file upstream ever - // deleted or renamed (#31). The previous apply's manifest says what it wrote. - var effectiveNow = new HashSet(entries.Select(e => e.EffectivePath), StringComparer.Ordinal); - foreach (string stale in previous?.MaterializedPaths ?? []) - { - if (!effectiveNow.Contains(stale)) - { - DeleteMaterializedFile(workdir, stale); - } - } - - foreach (var (originalPath, effectivePath, blob) in entries) - { - ct.ThrowIfCancellationRequested(); - - string fullPath = Path.Combine(workdir, effectivePath.Replace('/', Path.DirectorySeparatorChar)); - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - if (File.Exists(fullPath)) - { - // Force semantics: overwrite whatever is there, read-only or not. - File.SetAttributes(fullPath, FileAttributes.Normal); - } - - // Filter through .gitattributes (CRLF etc.) under the ORIGINAL repo path, - // exactly as a checkout of that entry would. - using var content = blob.GetContentStream(new FilteringOptions(originalPath)); - using var file = new FileStream(fullPath, FileMode.Create, FileAccess.Write); - content.CopyTo(file); - } - - SaveRecovery(repo, recovery, entries.Select(e => e.EffectivePath), tip.Sha); - repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); - } - - /// - /// Removes one file a previous materialization wrote (a user-deleted file is - /// fine) and prunes the directories it leaves empty, stopping at the working - /// directory root. - /// - private static void DeleteMaterializedFile(string workdir, string effectivePath) - { - string fullPath = Path.Combine(workdir, effectivePath.Replace('/', Path.DirectorySeparatorChar)); - if (File.Exists(fullPath)) - { - File.SetAttributes(fullPath, FileAttributes.Normal); - File.Delete(fullPath); - } - - string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(workdir)); - string? directory = Path.GetDirectoryName(fullPath); - while (directory is not null - && !string.Equals(Path.TrimEndingDirectorySeparator(directory), root, StringComparison.OrdinalIgnoreCase) - && Directory.Exists(directory) - && !Directory.EnumerateFileSystemEntries(directory).Any()) - { - Directory.Delete(directory); - directory = Path.GetDirectoryName(directory); - } - } - - /// - /// Overlays onto : renames - /// union with the incoming value winning on a shared original path; skips union. - /// - private static PathRecovery MergeRecoveries(PathRecovery stored, PathRecovery incoming) - { - var renames = new Dictionary(stored.SegmentRenames, StringComparer.Ordinal); - foreach (var (originalPath, replacement) in incoming.SegmentRenames) - { - renames[originalPath] = replacement; - } - - var skips = new HashSet(stored.SkippedPaths, StringComparer.Ordinal); - skips.UnionWith(incoming.SkippedPaths); - - return new PathRecovery(renames, skips); - } - - /// - /// Walks iteratively, resolving every blob to the path it - /// should occupy on disk: skipped files and whole skipped directories are omitted, - /// and a rename of any path (file or directory) replaces its final segment in - /// place — for a directory, the entire subtree moves with it. - /// - private static List<(string OriginalPath, string EffectivePath, Blob Blob)> CollectEffectiveEntries( - Tree tree, PathRecovery recovery) - { - var entries = new List<(string, string, Blob)>(); - var stack = new Stack<(Tree Tree, string OriginalPrefix, string EffectivePrefix)>(); - stack.Push((tree, "", "")); - - while (stack.Count > 0) - { - var (current, originalPrefix, effectivePrefix) = stack.Pop(); - foreach (var entry in current) - { - string originalPath = originalPrefix.Length == 0 ? entry.Name : $"{originalPrefix}/{entry.Name}"; - if (recovery.SkippedPaths.Contains(originalPath)) - { - continue; // omit the file — or the whole subtree when this is a directory - } - - // Renames key on the full ORIGINAL path, but a mapped value contributes - // only its last segment, joined onto the parent's EFFECTIVE prefix — so - // renaming both a directory and one of its descendants composes instead - // of the descendant's mapping resurrecting the parent's original name. - // Unmapped entries keep their own name under that same prefix. - string effectiveName = recovery.SegmentRenames.TryGetValue(originalPath, out string? mapped) - ? mapped[(mapped.LastIndexOf('/') + 1)..] - : entry.Name; - string effectivePath = effectivePrefix.Length == 0 - ? effectiveName - : $"{effectivePrefix}/{effectiveName}"; - - switch (entry.TargetType) - { - case TreeEntryTargetType.Tree: - stack.Push(((Tree)entry.Target, originalPath, effectivePath)); - break; - case TreeEntryTargetType.Blob: - // Executable-bit and symlink entries are written as regular - // files: NTFS has no executable bit, and creating symlinks on - // Windows requires elevation (a symlink blob's content is its - // target path, which is still useful as a plain file). - entries.Add((originalPath, effectivePath, (Blob)entry.Target)); - break; - default: - break; // GitLink (submodule): nothing to materialize - } - } - } - - return entries; - } - - /// - /// Finishes a clone whose checkout never completed and that has no stored - /// recovery: moves to the upstream tip, re-validates its paths on Windows, and - /// force-checks-out the whole tree (whatever partial files exist are overwritten - /// to match). If the paths are still invalid, rethrows the same typed failure — - /// the repo must not silently report success with an empty or partial tree. - /// - private static void CompletePendingCheckout(Repository repo, CancellationToken ct) - { - AdvanceHeadToTrackedTip(repo); - - var tip = repo.Head.Tip; - if (tip is not null) - { - if (OperatingSystem.IsWindows()) - { - var invalidPaths = WindowsPathValidator.Validate(tip.Tree); - if (invalidPaths.Count > 0) - { - throw new InvalidRepositoryPathsException(invalidPaths); - } - } - - ct.ThrowIfCancellationRequested(); - try - { - Commands.Checkout(repo, repo.Head, new CheckoutOptions - { - CheckoutModifiers = CheckoutModifiers.Force, // nothing was ever checked out - CheckoutNotifyFlags = CheckoutNotifyFlags.Updated, - OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested, - }); - } - catch (UserCancelledException) when (ct.IsCancellationRequested) - { - throw new OperationCanceledException(ct); - } - } - - repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); - } - - /// - /// Moves the current branch's ref to its upstream tip without touching the working - /// tree. Only used for never-checked-out (marker) and recovery-managed repos, whose - /// local branch can hold no local work — so a plain ref move is safe where the - /// normal pull path would insist on a fast-forward merge. - /// - private static void AdvanceHeadToTrackedTip(Repository repo) - { - var tracked = repo.Head.TrackedBranch; - if (tracked?.Tip is null || repo.Head.Tip?.Sha == tracked.Tip.Sha) - { - return; - } - repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), tracked.Tip.Id); - } - - private static bool IsCheckoutPending(Repository repo) - => repo.Config.Get(CheckoutPendingKey)?.Value == true; - - private static string GetRecoveryFilePath(Repository repo) - => Path.Combine(repo.Info.Path, RecoveryFileName); - - /// Serializable shape of for .git\gclo-recovery.json. - /// Original path to replacement path map; null when absent from the file. - /// Paths omitted from the working tree; null when absent from the file. - /// Effective paths the last apply wrote, so the next one can delete what left the tree; null in files from before this field existed. - /// SHA of the commit the last apply materialized; null in older files. - private sealed record RecoveryDocument( - Dictionary? SegmentRenames, - List? SkippedPaths, - List? MaterializedPaths, - string? MaterializedTip); - - private static void SaveRecovery( - Repository repo, PathRecovery recovery, IEnumerable materializedPaths, string tipSha) - { - var document = new RecoveryDocument( - new Dictionary(recovery.SegmentRenames, StringComparer.Ordinal), - recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList(), - materializedPaths.Order(StringComparer.Ordinal).ToList(), - tipSha); - - // Atomic replace: a crash mid-write must not leave a truncated document that - // fails every later pull of this repository (#31). - string path = GetRecoveryFilePath(repo); - string temp = path + ".tmp"; - File.WriteAllText(temp, JsonSerializer.Serialize(document, RecoveryJsonOptions)); - File.Move(temp, path, overwrite: true); - } - - private static RecoveryDocument LoadRecoveryDocument(string filePath) - { - try - { - return JsonSerializer.Deserialize(File.ReadAllText(filePath), RecoveryJsonOptions) - ?? throw new JsonException("the document is empty"); - } - catch (JsonException ex) - { - throw new InvalidOperationException( - $"The path-recovery file '{filePath}' is corrupt ({ex.Message}). " - + "Delete it and use Resolve on this repository again to rebuild it.", ex); - } - } - - private static PathRecovery ToRecovery(RecoveryDocument document) - => new( - new Dictionary(document.SegmentRenames ?? new(StringComparer.Ordinal), StringComparer.Ordinal), - new HashSet(document.SkippedPaths ?? [], StringComparer.Ordinal)); - - /// - /// GitHub accepts any username when the PAT is sent as the password. libgit2 asks - /// again after every 401; handing it the same rejected token loops until it gives - /// up with "too many redirects or authentication replays", once per repository. - /// A correct token is never asked for a third time, so the third request means the - /// token is bad and gets the same actionable message the API listers produce (#31). - /// - private static CredentialsHandler MakeCredentialsProvider(string token) - { - int requests = 0; - return (_, _, _) => - { - if (Interlocked.Increment(ref requests) > 2) - { - throw new InvalidOperationException( - "GitHub rejected the token for git over HTTPS (401). Check the PAT: it may have expired, " - + "been revoked, or lack access to this repository."); - } - return new UsernamePasswordCredentials - { - Username = "x-access-token", - Password = token, - }; - }; - } - - private static void TryDeleteDirectory(string path) - { - try - { - if (!Directory.Exists(path)) - { - return; - } - // Pack files under .git are written read-only; clear attributes so delete succeeds. - foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) - { - File.SetAttributes(file, FileAttributes.Normal); - } - Directory.Delete(path, recursive: true); - } - catch - { - // Best effort only; a leftover partial clone surfaces as a failure on the next run. - } - } + } + // Pack files under .git are written read-only; clear attributes so delete succeeds. + foreach(string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + Directory.Delete(path, recursive: true); + } + catch + { + // Best effort only; a leftover partial clone surfaces as a failure on the next run. + } + } } diff --git a/gclo.Engine/NullActivityLog.cs b/gclo.Engine/NullActivityLog.cs new file mode 100644 index 0000000..7628e1d --- /dev/null +++ b/gclo.Engine/NullActivityLog.cs @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// An that discards everything. +public sealed class NullActivityLog : IActivityLog +{ + /// + public string LogDirectory => ""; + + + + /// + public string CurrentLogFilePath => ""; + + + + /// + public void Info(string message) + { + } + + + + /// + public void Error(string message, Exception? exception = null) + { + } +} diff --git a/gclo.Engine/OctokitGateway.cs b/gclo.Engine/OctokitGateway.cs new file mode 100644 index 0000000..a81f372 --- /dev/null +++ b/gclo.Engine/OctokitGateway.cs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Diagnostics.CodeAnalysis; +using Octokit; + + +namespace gclo.Engine; + + +/// +/// The production 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). +/// +[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> GetOrganizationRepositoriesPageAsync(string organization, int page) + => Map(await _client.Repository.GetAllForOrg(organization, Page(page)).ConfigureAwait(false)); + + + + public async Task> GetOwnRepositoriesPageAsync(int page) + { + var owned = new RepositoryRequest { Affiliation = RepositoryAffiliation.Owner }; + return Map(await _client.Repository.GetAllForCurrent(owned, Page(page)).ConfigureAwait(false)); + } + + + + public async Task> GetUserRepositoriesPageAsync(string user, int page) + => Map(await _client.Repository.GetAllForUser(user, Page(page)).ConfigureAwait(false)); + + + + public async Task GetCurrentUserLoginAsync() + => (await _client.User.Current().ConfigureAwait(false)).Login; + + + + public async Task> GetOrganizationLoginsAsync() + { + IReadOnlyList organizations = await _client.Organization + .GetAllForCurrent(new ApiOptions { PageSize = IGitHubGateway.PageSize }) + .ConfigureAwait(false); + return organizations.Select(o => o.Login).ToList(); + } + + + + public async Task 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 Map(IReadOnlyList repositories) + => repositories.Select(r => new GitHubRepo(r.Name, r.CloneUrl, r.DefaultBranch, r.Archived)).ToList(); +} diff --git a/gclo.Engine/OctokitGatewayCache.cs b/gclo.Engine/OctokitGatewayCache.cs new file mode 100644 index 0000000..c3fee61 --- /dev/null +++ b/gclo.Engine/OctokitGatewayCache.cs @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Diagnostics.CodeAnalysis; + + +namespace gclo.Engine; + + +/// +/// One 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. +/// +[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 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)); + } +} diff --git a/gclo.Engine/OrgSyncEngine.cs b/gclo.Engine/OrgSyncEngine.cs index 2dd1eb6..63c07c6 100644 --- a/gclo.Engine/OrgSyncEngine.cs +++ b/gclo.Engine/OrgSyncEngine.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Collections.Concurrent; + namespace gclo.Engine; + /// /// 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; /// public sealed class OrgSyncEngine { - private readonly IRepositoryLister _lister; - private readonly IGitClient _git; + private readonly IRepositoryLister _lister; - /// - /// Creates an engine that discovers repositories through - /// and runs git operations through . - /// - public OrgSyncEngine(IRepositoryLister lister, IGitClient git) - { - _lister = lister ?? throw new ArgumentNullException(nameof(lister)); - _git = git ?? throw new ArgumentNullException(nameof(git)); - } + private readonly IGitClient _git; - /// Lists the organization's repositories, then syncs all of them. - public async Task SyncAsync( - SyncRequest request, - IProgress? 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); - } + /// + /// Creates an engine that discovers repositories through + /// and runs git operations through . + /// + public OrgSyncEngine(IRepositoryLister lister, IGitClient git) + { + _lister = lister ?? throw new ArgumentNullException(nameof(lister)); + _git = git ?? throw new ArgumentNullException(nameof(git)); + } - /// - /// 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 - /// . - /// - public async Task SyncAsync( - SyncRequest request, - IReadOnlyList repositories, - IProgress? 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(); + /// Lists the organization's repositories, then syncs all of them. + public async Task SyncAsync( + SyncRequest request, + IProgress? 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 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( - repos.Select(r => new KeyValuePair(r.Name, r))); - var parallelOptions = new ParallelOptions - { - MaxDegreeOfParallelism = Math.Max(1, request.MaxConcurrency), - CancellationToken = cancellationToken, - }; + /// + /// 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 + /// . + /// + public async Task SyncAsync( + SyncRequest request, + IReadOnlyList repositories, + IProgress? 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( + repos.Select(r => new KeyValuePair(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? 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); + } } diff --git a/gclo.Engine/PathRecovery.cs b/gclo.Engine/PathRecovery.cs index 0d4708d..d12a3ea 100644 --- a/gclo.Engine/PathRecovery.cs +++ b/gclo.Engine/PathRecovery.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// /// A user-approved plan for materializing a repository whose tree contains /// Windows-invalid paths: rename some paths, omit others. Applied by diff --git a/gclo.Engine/ProcessResult.cs b/gclo.Engine/ProcessResult.cs new file mode 100644 index 0000000..ee9dba7 --- /dev/null +++ b/gclo.Engine/ProcessResult.cs @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// Outcome of one external process: exit code plus captured output. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); diff --git a/gclo.Engine/ProcessRunner.cs b/gclo.Engine/ProcessRunner.cs new file mode 100644 index 0000000..a6042c8 --- /dev/null +++ b/gclo.Engine/ProcessRunner.cs @@ -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; + + +/// +/// The real . 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). +/// +[ExcludeFromCodeCoverage(Justification = "Spawns real processes; WslCloner is covered through a fake runner.")] +public sealed class ProcessRunner : IProcessRunner +{ + /// + public async Task RunAsync( + string fileName, + IReadOnlyList arguments, + IReadOnlyDictionary? 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 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); +} diff --git a/gclo.Engine/RepoDescriptor.cs b/gclo.Engine/RepoDescriptor.cs index 5c7ad02..4c149ca 100644 --- a/gclo.Engine/RepoDescriptor.cs +++ b/gclo.Engine/RepoDescriptor.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// A repository discovered in the GitHub organization. /// Repository name (unique within the org; used as the local folder name). /// HTTPS clone URL. diff --git a/gclo.Engine/RepoProgress.cs b/gclo.Engine/RepoProgress.cs index 6116868..ba13362 100644 --- a/gclo.Engine/RepoProgress.cs +++ b/gclo.Engine/RepoProgress.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// A progress snapshot for one repository. /// Repository name. /// Current lifecycle state. diff --git a/gclo.Engine/RepositoryPathResolver.cs b/gclo.Engine/RepositoryPathResolver.cs index 7d45d68..4194870 100644 --- a/gclo.Engine/RepositoryPathResolver.cs +++ b/gclo.Engine/RepositoryPathResolver.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// /// Maps a repository name onto its folder under a sync target root, and refuses any /// name that is not exactly one safe path segment. runs @@ -10,95 +16,99 @@ namespace gclo.Engine; /// 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; - /// - /// Returns the normalized absolute folder for under - /// , after proving the name is a single safe segment - /// and the resolved folder is strictly inside the root. - /// - /// - /// is blank, or is not a - /// valid leaf folder name, or the resolved path escapes the root. - /// - 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)); + /// + /// Returns the normalized absolute folder for under + /// , after proving the name is a single safe segment + /// and the resolved folder is strictly inside the root. + /// + /// + /// is blank, or is not a + /// valid leaf folder name, or the resolved path escapes the root. + /// + 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)); - /// - /// True when is a single, safe folder-name segment: - /// not blank, not . or .., no directory separators (so never rooted - /// or UNC), no drive or stream colon, and no characters a file system rejects in a name. - /// - /// The candidate name. - /// Why the name was rejected; null when it is valid. - 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; - } + /// + /// True when is a single, safe folder-name segment: + /// not blank, not . or .., no directory separators (so never rooted + /// or UNC), no drive or stream colon, and no characters a file system rejects in a name. + /// + /// The candidate name. + /// Why the name was rejected; null when it is valid. + 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; + } } diff --git a/gclo.Engine/SyncRequest.cs b/gclo.Engine/SyncRequest.cs index f64b6ee..6a0993c 100644 --- a/gclo.Engine/SyncRequest.cs +++ b/gclo.Engine/SyncRequest.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// Inputs for an organization sync. /// GitHub organization login name. /// GitHub Personal Access Token used for both the API and git transport. @@ -7,12 +13,12 @@ namespace gclo.Engine; /// Upper bound on simultaneous git operations. public sealed record SyncRequest(string Organization, string Token, string TargetRoot, int MaxConcurrency = 8) { - /// - /// Redacts the token: the record-generated ToString would print every positional - /// member, and this type ships publicly on NuGet where any log or exception - /// interpolation of a request would otherwise leak the PAT. - /// - public override string ToString() => - $"SyncRequest {{ Organization = {Organization}, Token = [redacted], " - + $"TargetRoot = {TargetRoot}, MaxConcurrency = {MaxConcurrency} }}"; + /// + /// Redacts the token: the record-generated ToString would print every positional + /// member, and this type ships publicly on NuGet where any log or exception + /// interpolation of a request would otherwise leak the PAT. + /// + public override string ToString() => + $"SyncRequest {{ Organization = {Organization}, Token = [redacted], " + + $"TargetRoot = {TargetRoot}, MaxConcurrency = {MaxConcurrency} }}"; } diff --git a/gclo.Engine/SyncStatus.cs b/gclo.Engine/SyncStatus.cs index 7c96a8e..d35a0aa 100644 --- a/gclo.Engine/SyncStatus.cs +++ b/gclo.Engine/SyncStatus.cs @@ -1,23 +1,29 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// Lifecycle of a single repository within an org sync. public enum SyncStatus { - /// Discovered and waiting for a worker. - Queued, + /// Discovered and waiting for a worker. + Queued, - /// Being cloned for the first time. - Cloning, + /// Being cloned for the first time. + Cloning, - /// Existing local repository being fetched and fast-forwarded. - Pulling, + /// Existing local repository being fetched and fast-forwarded. + Pulling, - /// Finished successfully. - Done, + /// Finished successfully. + Done, - /// Failed; the progress report carries the reason. - Failed, + /// Failed; the progress report carries the reason. + Failed, - /// Stopped by cancellation before or during processing. - Canceled, + /// Stopped by cancellation before or during processing. + Canceled, } diff --git a/gclo.Engine/SyncSummary.cs b/gclo.Engine/SyncSummary.cs index c308834..b3f3e03 100644 --- a/gclo.Engine/SyncSummary.cs +++ b/gclo.Engine/SyncSummary.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.Engine; + /// Aggregate outcome of an organization sync. /// Number of repositories the sync set out to process. /// Repositories cloned for the first time. diff --git a/gclo.Engine/WindowsPathValidator.cs b/gclo.Engine/WindowsPathValidator.cs index f682192..9dd9ee8 100644 --- a/gclo.Engine/WindowsPathValidator.cs +++ b/gclo.Engine/WindowsPathValidator.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using LibGit2Sharp; + namespace gclo.Engine; + /// /// Validates every path in a git tree against Windows file-system rules before a /// checkout is attempted, so failures surface as structured, actionable results @@ -11,247 +18,259 @@ namespace gclo.Engine; /// public static class WindowsPathValidator { - private const int MaxSegmentLength = 255; + private const int MaxSegmentLength = 255; - private static readonly char[] InvalidChars = Path.GetInvalidFileNameChars(); + private static readonly char[] s_invalidChars = Path.GetInvalidFileNameChars(); - private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet s_reservedNames = new(StringComparer.OrdinalIgnoreCase) { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", }; - /// - /// Returns every Windows-invalid path in ; empty when all - /// are fine. Flattens the tree to its leaf paths and runs them through - /// , so both entry points share one implementation. - /// - public static IReadOnlyList Validate(Tree tree) - => ValidatePaths(FlattenPaths(tree)); - /// - /// Validates only what a pull would introduce: the paths added, copied, renamed, - /// or type-changed between (the checked-out tree, which - /// was validated when it was materialized) and . The new - /// paths get the full segment and collision rules among themselves; case-only - /// collisions are additionally checked against everything that stays on disk - /// (the index minus deleted and renamed-away paths). Modified files keep their - /// already-valid paths and are not re-examined, so a one-file commit on a - /// 100K-file repository costs one diff, not a tree walk. - /// - public static IReadOnlyList ValidateIncoming(Repository repo, Tree current, Tree incoming) - { - ArgumentNullException.ThrowIfNull(repo); - ArgumentNullException.ThrowIfNull(current); - ArgumentNullException.ThrowIfNull(incoming); - var introduced = new List(); - var removed = new HashSet(StringComparer.Ordinal); - using (TreeChanges changes = repo.Diff.Compare( - current, incoming, new CompareOptions { Similarity = SimilarityOptions.Renames })) - { - foreach (TreeEntryChanges change in changes) + /// + /// Returns every Windows-invalid path in ; empty when all + /// are fine. Flattens the tree to its leaf paths and runs them through + /// , so both entry points share one implementation. + /// + public static IReadOnlyList Validate(Tree tree) + => ValidatePaths(FlattenPaths(tree)); + + + + /// + /// Validates only what a pull would introduce: the paths added, copied, renamed, + /// or type-changed between (the checked-out tree, which + /// was validated when it was materialized) and . The new + /// paths get the full segment and collision rules among themselves; case-only + /// collisions are additionally checked against everything that stays on disk + /// (the index minus deleted and renamed-away paths). Modified files keep their + /// already-valid paths and are not re-examined, so a one-file commit on a + /// 100K-file repository costs one diff, not a tree walk. + /// + public static IReadOnlyList ValidateIncoming(Repository repo, Tree current, Tree incoming) + { + ArgumentNullException.ThrowIfNull(repo); + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(incoming); + + var introduced = new List(); + var removed = new HashSet(StringComparer.Ordinal); + using(TreeChanges changes = repo.Diff.Compare( + current, incoming, new CompareOptions { Similarity = SimilarityOptions.Renames })) + { + foreach(TreeEntryChanges change in changes) + { + if(change.Status == ChangeKind.Deleted) { - if (change.Status == ChangeKind.Deleted) - { - removed.Add(change.Path); - continue; - } - if (change.Status == ChangeKind.Renamed) - { - removed.Add(change.OldPath); - } - if (change.Status is ChangeKind.Added or ChangeKind.Copied or ChangeKind.Renamed or ChangeKind.TypeChanged) - { - introduced.Add(change.Path); - } + removed.Add(change.Path); + continue; } - } - - if (introduced.Count == 0) - { - return []; - } - - var invalid = new List(ValidatePaths(introduced)); - - // Every path (and directory prefix) that remains on disk, case-insensitively, - // mapped to its exact spelling; a new path whose exact spelling is absent but - // whose casing matches an existing one cannot be created on Windows. - var remaining = new Dictionary(StringComparer.OrdinalIgnoreCase); - var remainingExact = new HashSet(StringComparer.Ordinal); - foreach (IndexEntry entry in repo.Index) - { - if (!removed.Contains(entry.Path)) + if(change.Status == ChangeKind.Renamed) { - AddWithPrefixes(entry.Path, remaining, remainingExact); + removed.Add(change.OldPath); } - } - - var reported = new HashSet(StringComparer.Ordinal); - foreach (string path in introduced) - { - string[] segments = path.Split('/'); - string prefix = ""; - for (int i = 0; i < segments.Length; i++) + if(change.Status is ChangeKind.Added or ChangeKind.Copied or ChangeKind.Renamed or ChangeKind.TypeChanged) { - prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}"; - if (!remainingExact.Contains(prefix) - && remaining.TryGetValue(prefix, out string? existing) - && reported.Add(prefix)) - { - invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{existing}'", null)); - } + introduced.Add(change.Path); } - } + } + } - return invalid; - } + if(introduced.Count == 0) + { + return []; + } - private static void AddWithPrefixes( - string path, Dictionary byCase, HashSet exact) - { - string[] segments = path.Split('/'); - string prefix = ""; - for (int i = 0; i < segments.Length; i++) - { + var invalid = new List(ValidatePaths(introduced)); + + // Every path (and directory prefix) that remains on disk, case-insensitively, + // mapped to its exact spelling; a new path whose exact spelling is absent but + // whose casing matches an existing one cannot be created on Windows. + var remaining = new Dictionary(StringComparer.OrdinalIgnoreCase); + var remainingExact = new HashSet(StringComparer.Ordinal); + foreach(IndexEntry entry in repo.Index) + { + if(!removed.Contains(entry.Path)) + { + AddWithPrefixes(entry.Path, remaining, remainingExact); + } + } + + var reported = new HashSet(StringComparer.Ordinal); + foreach(string path in introduced) + { + string[] segments = path.Split('/'); + string prefix = ""; + for(int i = 0; i < segments.Length; i++) + { prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}"; - if (exact.Add(prefix)) + if(!remainingExact.Contains(prefix) + && remaining.TryGetValue(prefix, out string? existing) + && reported.Add(prefix)) { - byCase.TryAdd(prefix, prefix); + invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{existing}'", null)); } - } - } + } + } - /// - /// Depth-first flattening of into leaf paths (blobs and - /// submodule links). An empty subtree is emitted as its own path so its name is - /// still validated. - /// - private static IEnumerable FlattenPaths(Tree tree) - { - var stack = new Stack<(Tree Tree, string Prefix)>(); - stack.Push((tree, "")); + return invalid; + } - while (stack.Count > 0) - { - var (current, prefix) = stack.Pop(); - foreach (var entry in current) + + + private static void AddWithPrefixes( + string path, Dictionary byCase, HashSet exact) + { + string[] segments = path.Split('/'); + string prefix = ""; + for(int i = 0; i < segments.Length; i++) + { + prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}"; + if(exact.Add(prefix)) + { + byCase.TryAdd(prefix, prefix); + } + } + } + + + + /// + /// Depth-first flattening of into leaf paths (blobs and + /// submodule links). An empty subtree is emitted as its own path so its name is + /// still validated. + /// + private static IEnumerable FlattenPaths(Tree tree) + { + var stack = new Stack<(Tree Tree, string Prefix)>(); + stack.Push((tree, "")); + + while(stack.Count > 0) + { + (Tree? current, string? prefix) = stack.Pop(); + foreach(TreeEntry? entry in current) + { + string repoPath = prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}"; + if(entry.TargetType == TreeEntryTargetType.Tree) { - string repoPath = prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}"; - if (entry.TargetType == TreeEntryTargetType.Tree) - { - var subtree = (Tree)entry.Target; - if (subtree.Count == 0) - { - yield return repoPath; - } - else - { - stack.Push((subtree, repoPath)); - } - } - else - { - yield return repoPath; - } + var subtree = (Tree)entry.Target; + if(subtree.Count == 0) + { + yield return repoPath; + } + else + { + stack.Push((subtree, repoPath)); + } } - } - } - - /// - /// Validates a flat set of file paths (forward-slash separated) against Windows - /// rules — the single validation implementation, which - /// feeds by flattening a git tree. Also used directly for the EFFECTIVE path set - /// after a mapping is applied, where — unlike in a git - /// tree — two source paths can also land on the same destination; those collisions - /// (duplicate destinations, file/directory clashes) are reported as invalid too. - /// - public static IReadOnlyList ValidatePaths(IEnumerable filePaths) - { - var invalid = new List(); - // Lowered path -> first-seen original casing, for case-only collisions. - var seenCi = new Dictionary(StringComparer.OrdinalIgnoreCase); - // Exact-cased prefixes already segment-checked (shared directories repeat per file). - var seenExact = new HashSet(StringComparer.Ordinal); - var files = new HashSet(StringComparer.Ordinal); - var directories = new HashSet(StringComparer.Ordinal); - - foreach (string path in filePaths) - { - string[] segments = path.Split('/'); - string prefix = ""; - for (int i = 0; i < segments.Length; i++) + else { - prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}"; - bool isLeaf = i == segments.Length - 1; - - if (isLeaf) - { - if (!files.Add(path)) - { - invalid.Add(new InvalidPathInfo(path, "more than one path maps to this destination", null)); - break; // an exact duplicate needs no further checks - } - if (directories.Contains(path)) - { - invalid.Add(new InvalidPathInfo(path, "maps to both a file and a directory", null)); - } - } - else if (directories.Add(prefix) && files.Contains(prefix)) - { - invalid.Add(new InvalidPathInfo(prefix, "maps to both a file and a directory", null)); - } - - if (seenExact.Add(prefix)) - { - if (ValidateSegment(segments[i]) is { } problem) - { - invalid.Add(new InvalidPathInfo(prefix, problem.Reason, problem.Suggestion)); - } - if (seenCi.TryGetValue(prefix, out string? original)) - { - invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{original}'", null)); - } - else - { - seenCi[prefix] = prefix; - } - } + yield return repoPath; } - } + } + } + } - return invalid; - } - private static (string Reason, string? Suggestion)? ValidateSegment(string segment) - { - if (segment.Length == 0) - { - return ("empty path segment", "_"); - } - if (segment.Length > MaxSegmentLength) - { - return ($"segment longer than {MaxSegmentLength} characters", segment[..MaxSegmentLength]); - } - if (segment.EndsWith(' ') || segment.EndsWith('.')) - { - var trimmed = segment.TrimEnd(' ', '.'); - return ("trailing space or dot", trimmed.Length == 0 ? "_" : trimmed); - } - if (segment.IndexOfAny(InvalidChars) >= 0) - { - var sanitized = new string(segment.Select(c => InvalidChars.Contains(c) ? '_' : c).ToArray()); - return ("contains a character that is invalid on Windows", sanitized); - } - string baseName = segment.Split('.')[0]; - if (ReservedNames.Contains(baseName)) - { - return ($"'{baseName}' is a reserved Windows device name", segment + "_"); - } + /// + /// Validates a flat set of file paths (forward-slash separated) against Windows + /// rules — the single validation implementation, which + /// feeds by flattening a git tree. Also used directly for the EFFECTIVE path set + /// after a mapping is applied, where — unlike in a git + /// tree — two source paths can also land on the same destination; those collisions + /// (duplicate destinations, file/directory clashes) are reported as invalid too. + /// + public static IReadOnlyList ValidatePaths(IEnumerable filePaths) + { + var invalid = new List(); + // Lowered path -> first-seen original casing, for case-only collisions. + var seenCi = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Exact-cased prefixes already segment-checked (shared directories repeat per file). + var seenExact = new HashSet(StringComparer.Ordinal); + var files = new HashSet(StringComparer.Ordinal); + var directories = new HashSet(StringComparer.Ordinal); - return null; - } + foreach(string path in filePaths) + { + string[] segments = path.Split('/'); + string prefix = ""; + for(int i = 0; i < segments.Length; i++) + { + prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}"; + bool isLeaf = i == segments.Length - 1; + + if(isLeaf) + { + if(!files.Add(path)) + { + invalid.Add(new InvalidPathInfo(path, "more than one path maps to this destination", null)); + break; // an exact duplicate needs no further checks + } + if(directories.Contains(path)) + { + invalid.Add(new InvalidPathInfo(path, "maps to both a file and a directory", null)); + } + } + else if(directories.Add(prefix) && files.Contains(prefix)) + { + invalid.Add(new InvalidPathInfo(prefix, "maps to both a file and a directory", null)); + } + + if(seenExact.Add(prefix)) + { + if(ValidateSegment(segments[i]) is { } problem) + { + invalid.Add(new InvalidPathInfo(prefix, problem.Reason, problem.Suggestion)); + } + if(seenCi.TryGetValue(prefix, out string? original)) + { + invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{original}'", null)); + } + else + { + seenCi[prefix] = prefix; + } + } + } + } + + return invalid; + } + + + + private static (string Reason, string? Suggestion)? ValidateSegment(string segment) + { + if(segment.Length == 0) + { + return ("empty path segment", "_"); + } + if(segment.Length > MaxSegmentLength) + { + return ($"segment longer than {MaxSegmentLength} characters", segment[..MaxSegmentLength]); + } + if(segment.EndsWith(' ') || segment.EndsWith('.')) + { + string trimmed = segment.TrimEnd(' ', '.'); + return ("trailing space or dot", trimmed.Length == 0 ? "_" : trimmed); + } + if(segment.IndexOfAny(s_invalidChars) >= 0) + { + string sanitized = new string(segment.Select(c => s_invalidChars.Contains(c) ? '_' : c).ToArray()); + return ("contains a character that is invalid on Windows", sanitized); + } + + string baseName = segment.Split('.')[0]; + if(s_reservedNames.Contains(baseName)) + { + return ($"'{baseName}' is a reserved Windows device name", segment + "_"); + } + + return null; + } } diff --git a/gclo.Engine/WslAvailability.cs b/gclo.Engine/WslAvailability.cs new file mode 100644 index 0000000..5c89220 --- /dev/null +++ b/gclo.Engine/WslAvailability.cs @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// Whether a clone into WSL can be attempted on this machine, and why not when it cannot. +/// True when wsl.exe exists, a default distribution starts, and git runs inside it. +/// The git version when available; the reason otherwise (shown to the user). +public sealed record WslAvailability(bool Available, string Detail); diff --git a/gclo.Engine/WslCloneException.cs b/gclo.Engine/WslCloneException.cs new file mode 100644 index 0000000..50e8725 --- /dev/null +++ b/gclo.Engine/WslCloneException.cs @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// A clone into WSL did not complete; the message carries git's or wsl.exe's own explanation. +public sealed class WslCloneException : Exception +{ + /// What failed, in git's or wsl.exe's words. + public WslCloneException(string message) : base(message) { } +} diff --git a/gclo.Engine/WslCloneResult.cs b/gclo.Engine/WslCloneResult.cs new file mode 100644 index 0000000..e4e760f --- /dev/null +++ b/gclo.Engine/WslCloneResult.cs @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Engine; + + +/// Where a repository ended up after a clone into WSL. +/// The WSL distribution that holds the clone (its default distribution). +/// Path inside the distribution, e.g. /home/me/gclo/acme/repo. +/// The same folder as Explorer reaches it: \\wsl.localhost\<distro>\home\me\gclo\acme\repo. +public sealed record WslCloneResult(string Distribution, string LinuxPath, string WindowsPath); diff --git a/gclo.Engine/WslCloner.cs b/gclo.Engine/WslCloner.cs index 58005a8..5443989 100644 --- a/gclo.Engine/WslCloner.cs +++ b/gclo.Engine/WslCloner.cs @@ -1,72 +1,10 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ namespace gclo.Engine; -/// Outcome of one external process: exit code plus captured output. -public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); - -/// -/// Runs an external process to completion. The seam that keeps -/// testable without a WSL installation: the offline suite scripts results through a fake. -/// -public interface IProcessRunner -{ - /// Executable path. - /// Argument vector (quoted by the runner, never by the caller). - /// Extra environment variables for the child; the parent's are inherited. - /// Text written to the child's stdin, then closed; null leaves stdin closed. - /// Kills the child and throws . - Task RunAsync( - string fileName, - IReadOnlyList arguments, - IReadOnlyDictionary? environment, - string? standardInput, - CancellationToken cancellationToken); -} - -/// Whether a clone into WSL can be attempted on this machine, and why not when it cannot. -/// True when wsl.exe exists, a default distribution starts, and git runs inside it. -/// The git version when available; the reason otherwise (shown to the user). -public sealed record WslAvailability(bool Available, string Detail); - -/// Where a repository ended up after a clone into WSL. -/// The WSL distribution that holds the clone (its default distribution). -/// Path inside the distribution, e.g. /home/me/gclo/acme/repo. -/// The same folder as Explorer reaches it: \\wsl.localhost\<distro>\home\me\gclo\acme\repo. -public sealed record WslCloneResult(string Distribution, string LinuxPath, string WindowsPath); - -/// A clone into WSL did not complete; the message carries git's or wsl.exe's own explanation. -public sealed class WslCloneException : Exception -{ - /// What failed, in git's or wsl.exe's words. - public WslCloneException(string message) : base(message) { } -} - -/// -/// 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 -/// ~/gclo/<organization>/<repository> and left there. -/// -public interface IWslCloner -{ - /// Checks, without side effects, whether a clone into WSL can be attempted. - Task ProbeAsync(CancellationToken cancellationToken); - - /// - /// Clones inside the default WSL distribution, or fast-forwards - /// it when that folder is already a repository. Throws - /// when git or wsl.exe fail. - /// - /// HTTPS clone URL. - /// Organization (or user) name; the parent folder under ~/gclo. - /// Repository name; the clone's folder name. - /// GitHub token; handed to git through the environment only, never on a command line. - /// Kills wsl.exe (and the clone) when canceled. - Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken); -} /// /// Drives wsl.exe for . Everything that runs inside the @@ -78,201 +16,144 @@ public interface IWslCloner /// public sealed class WslCloner : IWslCloner { - /// Environment variable the inline credential helper reads the token from. - public const string TokenVariable = "GCLO_GIT_TOKEN"; + /// Environment variable the inline credential helper reads the token from. + public const string TokenVariable = "GCLO_GIT_TOKEN"; - /// - /// The clone script. $1 organization, $2 repository, $3 clone URL. Prints the - /// distribution name and the destination as its last two lines on success. A folder - /// that already is a repository is fast-forwarded instead of failing, so the verb is - /// safe to use twice. - /// - internal const string CloneScript = - "set -e\n" - + "org=\"$1\"; repo=\"$2\"; url=\"$3\"\n" - + "dest=\"$HOME/gclo/$org/$repo\"\n" - + "helper='!f() { echo username=x-access-token; echo \"password=$" + TokenVariable + "\"; }; f'\n" - + "if [ -d \"$dest/.git\" ]; then\n" - + " git -C \"$dest\" -c credential.helper= -c \"credential.helper=$helper\" pull --ff-only --quiet\n" - + "else\n" - + " mkdir -p \"$HOME/gclo/$org\"\n" - + " git -c credential.helper= -c \"credential.helper=$helper\" clone --quiet \"$url\" \"$dest\"\n" - + "fi\n" - + "printf '%s\\n%s\\n' \"$WSL_DISTRO_NAME\" \"$dest\"\n"; + /// + /// The clone script. $1 organization, $2 repository, $3 clone URL. Prints the + /// distribution name and the destination as its last two lines on success. A folder + /// that already is a repository is fast-forwarded instead of failing, so the verb is + /// safe to use twice. + /// + internal const string CloneScript = + "set -e\n" + + "org=\"$1\"; repo=\"$2\"; url=\"$3\"\n" + + "dest=\"$HOME/gclo/$org/$repo\"\n" + + "helper='!f() { echo username=x-access-token; echo \"password=$" + TokenVariable + "\"; }; f'\n" + + "if [ -d \"$dest/.git\" ]; then\n" + + " git -C \"$dest\" -c credential.helper= -c \"credential.helper=$helper\" pull --ff-only --quiet\n" + + "else\n" + + " mkdir -p \"$HOME/gclo/$org\"\n" + + " git -c credential.helper= -c \"credential.helper=$helper\" clone --quiet \"$url\" \"$dest\"\n" + + "fi\n" + + "printf '%s\\n%s\\n' \"$WSL_DISTRO_NAME\" \"$dest\"\n"; - private readonly IProcessRunner _runner; - private readonly Func _fileExists; - private readonly string _wslExe; + private readonly IProcessRunner _runner; - /// Process seam; the real by default. - /// File-existence probe for wsl.exe; by default. - /// Path of wsl.exe; %SystemRoot%\System32\wsl.exe by default. - public WslCloner(IProcessRunner? runner = null, Func? fileExists = null, string? wslExePath = null) - { - _runner = runner ?? new ProcessRunner(); - _fileExists = fileExists ?? File.Exists; - _wslExe = wslExePath ?? Path.Combine(Environment.SystemDirectory, "wsl.exe"); - } + private readonly Func _fileExists; - /// - public async Task ProbeAsync(CancellationToken cancellationToken) - { - if (!_fileExists(_wslExe)) - { - return new WslAvailability(false, "wsl.exe is not installed (Windows Subsystem for Linux is not enabled)."); - } + private readonly string _wslExe; - ProcessResult result; - try - { - result = await _runner.RunAsync(_wslExe, ["-e", "git", "--version"], null, null, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return new WslAvailability(false, $"wsl.exe could not be started: {ex.Message}"); - } - string stdout = result.StandardOutput.Trim(); - if (result.ExitCode == 0 && stdout.StartsWith("git version", StringComparison.Ordinal)) - { - return new WslAvailability(true, stdout); - } - string reason = FirstLine(result.StandardError) ?? FirstLine(stdout) ?? $"exit code {result.ExitCode}"; - return new WslAvailability(false, result.ExitCode == 0 - ? $"git is not installed in the default WSL distribution ({reason})." - : $"the default WSL distribution did not run git: {reason}"); - } + /// Process seam; the real by default. + /// File-existence probe for wsl.exe; by default. + /// Path of wsl.exe; %SystemRoot%\System32\wsl.exe by default. + public WslCloner(IProcessRunner? runner = null, Func? fileExists = null, string? wslExePath = null) + { + _runner = runner ?? new ProcessRunner(); + _fileExists = fileExists ?? File.Exists; + _wslExe = wslExePath ?? Path.Combine(Environment.SystemDirectory, "wsl.exe"); + } - /// - public async Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrWhiteSpace(url); - ArgumentException.ThrowIfNullOrWhiteSpace(organization); - ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); - ArgumentNullException.ThrowIfNull(token); - var environment = new Dictionary(StringComparer.Ordinal) - { - [TokenVariable] = token, - ["GIT_TERMINAL_PROMPT"] = "0", - // Forward both into the distribution (WSLENV is a colon-separated list). - ["WSLENV"] = TokenVariable + ":GIT_TERMINAL_PROMPT", - }; - ProcessResult result; - try - { - result = await _runner.RunAsync( - _wslExe, - ["-e", "sh", "-s", organization, repositoryName, url], - environment, - CloneScript, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - throw new WslCloneException($"wsl.exe could not be started: {ex.Message}"); - } + /// + public async Task ProbeAsync(CancellationToken cancellationToken) + { + if(!_fileExists(_wslExe)) + { + return new WslAvailability(false, "wsl.exe is not installed (Windows Subsystem for Linux is not enabled)."); + } - if (result.ExitCode != 0) - { - string reason = LastLine(result.StandardError) ?? LastLine(result.StandardOutput) ?? $"exit code {result.ExitCode}"; - throw new WslCloneException($"Clone in WSL failed: {reason}"); - } + ProcessResult result; + try + { + result = await _runner.RunAsync(_wslExe, ["-e", "git", "--version"], null, null, cancellationToken).ConfigureAwait(false); + } + catch(OperationCanceledException) + { + throw; + } + catch(Exception ex) + { + return new WslAvailability(false, $"wsl.exe could not be started: {ex.Message}"); + } - string[] lines = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (lines.Length < 2 || lines[^1].Length == 0 || lines[^2].Length == 0) - { - throw new WslCloneException("Clone in WSL finished but did not report where the repository was placed."); - } + string stdout = result.StandardOutput.Trim(); + if(result.ExitCode == 0 && stdout.StartsWith("git version", StringComparison.Ordinal)) + { + return new WslAvailability(true, stdout); + } - string distribution = lines[^2]; - string linuxPath = lines[^1]; - string windowsPath = @"\\wsl.localhost\" + distribution + linuxPath.Replace('/', '\\'); - return new WslCloneResult(distribution, linuxPath, windowsPath); - } + string reason = FirstLine(result.StandardError) ?? FirstLine(stdout) ?? $"exit code {result.ExitCode}"; + return new WslAvailability(false, result.ExitCode == 0 + ? $"git is not installed in the default WSL distribution ({reason})." + : $"the default WSL distribution did not run git: {reason}"); + } - private static string? FirstLine(string text) - => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); - private static string? LastLine(string text) - => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault(); -} -/// -/// The real . 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). -/// -[ExcludeFromCodeCoverage(Justification = "Spawns real processes; WslCloner is covered through a fake runner.")] -public sealed class ProcessRunner : IProcessRunner -{ - /// - public async Task RunAsync( - string fileName, - IReadOnlyList arguments, - IReadOnlyDictionary? 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 pair in environment) - { - start.Environment[pair.Key] = pair.Value; - } - } + /// + public async Task CloneAsync(string url, string organization, string repositoryName, string token, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + ArgumentException.ThrowIfNullOrWhiteSpace(organization); + ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName); + ArgumentNullException.ThrowIfNull(token); - using var process = new Process { StartInfo = start }; - process.Start(); + var environment = new Dictionary(StringComparer.Ordinal) + { + [TokenVariable] = token, + ["GIT_TERMINAL_PROMPT"] = "0", + // Forward both into the distribution (WSLENV is a colon-separated list). + ["WSLENV"] = TokenVariable + ":GIT_TERMINAL_PROMPT", + }; - 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(); + ProcessResult result; + try + { + result = await _runner.RunAsync( + _wslExe, + ["-e", "sh", "-s", organization, repositoryName, url], + environment, + CloneScript, + cancellationToken).ConfigureAwait(false); + } + catch(OperationCanceledException) + { + throw; + } + catch(Exception ex) + { + throw new WslCloneException($"wsl.exe could not be started: {ex.Message}"); + } - 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; - } + if(result.ExitCode != 0) + { + string reason = LastLine(result.StandardError) ?? LastLine(result.StandardOutput) ?? $"exit code {result.ExitCode}"; + throw new WslCloneException($"Clone in WSL failed: {reason}"); + } - return new ProcessResult(process.ExitCode, Decode(stdout.ToArray()), Decode(stderr.ToArray())); - } + string[] lines = result.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if(lines.Length < 2 || lines[^1].Length == 0 || lines[^2].Length == 0) + { + throw new WslCloneException("Clone in WSL finished but did not report where the repository was placed."); + } - 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); + string distribution = lines[^2]; + string linuxPath = lines[^1]; + string windowsPath = @"\\wsl.localhost\" + distribution + linuxPath.Replace('/', '\\'); + return new WslCloneResult(distribution, linuxPath, windowsPath); + } + + + + private static string? FirstLine(string text) + => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).FirstOrDefault(); + + + + private static string? LastLine(string text) + => text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault(); } diff --git a/gclo.Engine/gclo.Engine.csproj b/gclo.Engine/gclo.Engine.csproj index 26433a6..779685b 100644 --- a/gclo.Engine/gclo.Engine.csproj +++ b/gclo.Engine/gclo.Engine.csproj @@ -30,8 +30,8 @@ - - + + diff --git a/gclo.Engine/packages.lock.json b/gclo.Engine/packages.lock.json index d3db6db..c4fe9b0 100644 --- a/gclo.Engine/packages.lock.json +++ b/gclo.Engine/packages.lock.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "dependencies": { "net10.0": { "LibGit2Sharp": { @@ -11,6 +11,12 @@ "LibGit2Sharp.NativeBinaries": "[2.0.324]" } }, + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.296, )", + "resolved": "3.0.296", + "contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg==" + }, "Octokit": { "type": "Direct", "requested": "[14.0.0, )", diff --git a/gclo.UiTests/AppSession.cs b/gclo.UiTests/AppSession.cs index 7444ef4..c5a8c41 100644 --- a/gclo.UiTests/AppSession.cs +++ b/gclo.UiTests/AppSession.cs @@ -1,14 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Diagnostics; using FlaUI.Core; using FlaUI.Core.AutomationElements; +using FlaUI.Core.Capturing; using FlaUI.Core.Conditions; using FlaUI.Core.Definitions; using FlaUI.Core.Input; +using FlaUI.Core.Patterns; using FlaUI.Core.Tools; using FlaUI.UIA3; + namespace gclo.UiTests; + /// /// One launched gclo.exe plus the UIA3 automation that drives it, shared by every /// UI test in the collection (see ). @@ -24,518 +33,566 @@ namespace gclo.UiTests; /// public sealed class AppSession : IDisposable { - private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); - private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10); - /// Launches gclo against a fresh temp GCLO_DATA_DIR and waits for its main window. - public AppSession() - { - ExePath = ResolveExePath(); - DataDirectory = Path.Combine(Path.GetTempPath(), "gclo-uitests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(DataDirectory); + private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30); - var startInfo = new ProcessStartInfo(ExePath) - { - WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "", - }; - startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory; - // Enables the offline fixture for the one magic token only (see UiTestFixture). - startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1"; + private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250); - Automation = new UIA3Automation(); - try - { - App = Application.Launch(startInfo); - MainWindow = WaitForMainWindow(); - // Snapshot the launch state before any test types: the Quick Sync page is - // up once its connect card is, and a pre-fill logs synchronously on the way. - WaitForElement("ConnectTokenBox", TimeSpan.FromSeconds(30)); - TokenPrefilledAtLaunch = LogContains("Token entered in workspace"); - } - catch - { - // A half-launched session must not leak the process, the automation, - // or the temp directory; xunit will surface the original exception. - Dispose(); - throw; - } - } - /// The gclo.exe under test. - public string ExePath { get; } - /// The temp directory the app sees as its data root (via GCLO_DATA_DIR). - public string DataDirectory { get; } + /// Launches gclo against a fresh temp GCLO_DATA_DIR and waits for its main window. + public AppSession() + { + ExePath = ResolveExePath(); + DataDirectory = Path.Combine(Path.GetTempPath(), "gclo-uitests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DataDirectory); - /// - /// True when the app had logged a token being entered by the time its Quick Sync - /// page was up, before any test typed one: the workspace was pre-filled from a - /// default token the developer saved in their real profile. The vault is scoped - /// per data directory precisely so this never happens (#60); the fresh-data-dir - /// smoke test asserts it. - /// - public bool TokenPrefilledAtLaunch { get; } + var startInfo = new ProcessStartInfo(ExePath) + { + WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "", + }; + startInfo.Environment["GCLO_DATA_DIR"] = DataDirectory; + // Enables the offline fixture for the one magic token only (see UiTestFixture). + startInfo.Environment["GCLO_UITEST_FIXTURE"] = "1"; - private bool LogContains(string fragment) - { - string logs = Path.Combine(DataDirectory, "logs"); - if (!Directory.Exists(logs)) - { - return false; - } - foreach (string file in Directory.EnumerateFiles(logs, "*.log")) - { - try + Automation = new UIA3Automation(); + try + { + App = Application.Launch(startInfo); + MainWindow = WaitForMainWindow(); + // Snapshot the launch state before any test types: the Quick Sync page is + // up once its connect card is, and a pre-fill logs synchronously on the way. + WaitForElement("ConnectTokenBox", TimeSpan.FromSeconds(30)); + TokenPrefilledAtLaunch = LogContains("Token entered in workspace"); + } + catch + { + // A half-launched session must not leak the process, the automation, + // or the temp directory; xunit will surface the original exception. + Dispose(); + throw; + } + } + + + + /// The gclo.exe under test. + public string ExePath { get; } + + /// The temp directory the app sees as its data root (via GCLO_DATA_DIR). + public string DataDirectory { get; } + + /// + /// True when the app had logged a token being entered by the time its Quick Sync + /// page was up, before any test typed one: the workspace was pre-filled from a + /// default token the developer saved in their real profile. The vault is scoped + /// per data directory precisely so this never happens (#60); the fresh-data-dir + /// smoke test asserts it. + /// + public bool TokenPrefilledAtLaunch { get; } + + + + private bool LogContains(string fragment) + { + string logs = Path.Combine(DataDirectory, "logs"); + if(!Directory.Exists(logs)) + { + return false; + } + foreach(string file in Directory.EnumerateFiles(logs, "*.log")) + { + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + if(reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal)) { - using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - using var reader = new StreamReader(stream); - if (reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal)) - { - return true; - } + return true; } - catch (IOException) - { - // mid-write; treat as not found - } - } - return false; - } + } + catch(IOException) + { + // mid-write; treat as not found + } + } + return false; + } - /// The UIA3 automation used for every lookup and pattern call. - public UIA3Automation Automation { get; } - /// The launched application (never null after construction). - public Application App { get; } - /// The app's main window, resolved once at launch. - public Window MainWindow { get; } + /// The UIA3 automation used for every lookup and pattern call. + public UIA3Automation Automation { get; } - // ---------------------------------------------------------------- lookups + /// The launched application (never null after construction). + public Application App { get; } - /// - /// Finds the first element matching in the main - /// window — or in any other top-level window of the app's process. The second - /// leg matters because WinAppSDK menu flyouts open in windowed popups (separate - /// top-level HWNDs), so their items are not descendants of the main window. - /// - public AutomationElement? FindInApp(Func condition) - { - AutomationElement? found = MainWindow.FindFirstDescendant(condition); - if (found is not null) - { + /// The app's main window, resolved once at launch. + public Window MainWindow { get; } + + + + // ---------------------------------------------------------------- lookups + + /// + /// Finds the first element matching in the main + /// window — or in any other top-level window of the app's process. The second + /// leg matters because WinAppSDK menu flyouts open in windowed popups (separate + /// top-level HWNDs), so their items are not descendants of the main window. + /// + public AutomationElement? FindInApp(Func condition) + { + AutomationElement? found = MainWindow.FindFirstDescendant(condition); + if(found is not null) + { + return found; + } + + foreach(AutomationElement topLevel in Automation.GetDesktop() + .FindAllChildren(cf => cf.ByProcessId(App.ProcessId))) + { + if(topLevel.Equals(MainWindow)) + { + continue; + } + found = topLevel.FindFirstDescendant(condition); + if(found is not null) + { return found; - } + } + } + return null; + } - foreach (AutomationElement topLevel in Automation.GetDesktop() - .FindAllChildren(cf => cf.ByProcessId(App.ProcessId))) - { - if (topLevel.Equals(MainWindow)) - { - continue; - } - found = topLevel.FindFirstDescendant(condition); - if (found is not null) - { - return found; - } - } - return null; - } - /// - /// Retries until it returns an element, throwing a - /// naming when the - /// timeout (default 10s) elapses first. Exceptions inside the lookup (stale - /// elements mid-transition) count as "not found yet" and are retried. - /// - public AutomationElement WaitFor( - Func lookup, string description, TimeSpan? timeout = null) - { - ArgumentNullException.ThrowIfNull(lookup); - TimeSpan effective = timeout ?? DefaultTimeout; - RetryResult result = Retry.WhileNull( - lookup, timeout: effective, interval: PollInterval, ignoreException: true); - if (result.Result is { } element) - { - return element; - } - throw new TimeoutException( - $"Timed out after {effective.TotalSeconds:0}s waiting for {description}. " - + $"(app exited: {App.HasExited})"); - } - /// - /// Retries until returns null (the element left the - /// tree), throwing a naming - /// when it is still present at the timeout. - /// - public static void WaitUntilGone( - Func lookup, string description, TimeSpan? timeout = null) - { - ArgumentNullException.ThrowIfNull(lookup); - TimeSpan effective = timeout ?? DefaultTimeout; - RetryResult result = Retry.WhileTrue( - () => lookup() is not null, timeout: effective, interval: PollInterval, ignoreException: true); - if (!result.Success) - { - throw new TimeoutException( - $"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away."); - } - } + /// + /// Retries until it returns an element, throwing a + /// naming when the + /// timeout (default 10s) elapses first. Exceptions inside the lookup (stale + /// elements mid-transition) count as "not found yet" and are retried. + /// + public AutomationElement WaitFor( + Func lookup, string description, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(lookup); + TimeSpan effective = timeout ?? s_defaultTimeout; + RetryResult result = Retry.WhileNull( + lookup, timeout: effective, interval: s_pollInterval, ignoreException: true); + if(result.Result is { } element) + { + return element; + } + throw new TimeoutException( + $"Timed out after {effective.TotalSeconds:0}s waiting for {description}. " + + $"(app exited: {App.HasExited})"); + } - /// Waits for the element carrying . - public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null) - => WaitFor( - () => FindInApp(cf => cf.ByAutomationId(automationId)), - $"element '{automationId}'", - timeout); - /// Waits until no element carries anymore. - public void WaitForElementGone(string automationId, TimeSpan? timeout = null) - => WaitUntilGone( - () => FindInApp(cf => cf.ByAutomationId(automationId)), - $"element '{automationId}'", - timeout); - /// Waits for a text element whose name is exactly . - public AutomationElement WaitForText(string text, TimeSpan? timeout = null) - => WaitFor( - () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))), - $"text '{text}'", - timeout); + /// + /// Retries until returns null (the element left the + /// tree), throwing a naming + /// when it is still present at the timeout. + /// + public static void WaitUntilGone( + Func lookup, string description, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(lookup); + TimeSpan effective = timeout ?? s_defaultTimeout; + RetryResult result = Retry.WhileTrue( + () => lookup() is not null, timeout: effective, interval: s_pollInterval, ignoreException: true); + if(!result.Success) + { + throw new TimeoutException( + $"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away."); + } + } - /// Waits until no text element named remains. - public void WaitForTextGone(string text, TimeSpan? timeout = null) - => WaitUntilGone( - () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))), - $"text '{text}'", - timeout); - // ---------------------------------------------------------------- actions - /// - /// Opens the menu bar item named (e.g. "File") - /// and invokes the flyout item carrying . - /// The bar item is opened via its ExpandCollapse pattern (WinUI's MenuBarItem - /// exposes it) with Invoke as fallback; the flyout item is then looked up across - /// the app's windows because menu popups are windowed in WinAppSDK. - /// - public void InvokeMenuItem(string menuBarItemName, string itemAutomationId) - { - AutomationElement barItem = WaitFor( - () => MainWindow.FindFirstDescendant(cf => - cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuBarItemName))) - ?? MainWindow.FindFirstDescendant(cf => cf.ByName(menuBarItemName)), - $"menu bar item '{menuBarItemName}'"); + /// Waits for the element carrying . + public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null) + => WaitFor( + () => FindInApp(cf => cf.ByAutomationId(automationId)), + $"element '{automationId}'", + timeout); - if (barItem.Patterns.ExpandCollapse.TryGetPattern(out var expandCollapse)) - { - expandCollapse.Expand(); - Wait.UntilInputIsProcessed(); - } - else - { - InvokeElement(barItem); - } - AutomationElement item = WaitFor( - () => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), - $"menu item '{itemAutomationId}' under '{menuBarItemName}'"); - InvokeElement(item); - } - /// - /// Activates a NavigationView item by AutomationId. NavigationViewItem exposes - /// Invoke (preferred, it routes through the pane's selection logic) and - /// SelectionItem; a raw click is the last resort. - /// - public void InvokeNavItem(string automationId) - => InvokeElement(WaitForElement(automationId)); + /// Waits until no element carries anymore. + public void WaitForElementGone(string automationId, TimeSpan? timeout = null) + => WaitUntilGone( + () => FindInApp(cf => cf.ByAutomationId(automationId)), + $"element '{automationId}'", + timeout); - /// - /// Invokes the XAML button named . The ClassName filter - /// ("Button") keeps ContentDialog buttons (real XAML buttons named by their - /// *ButtonText) from colliding with the window's non-client caption buttons, - /// whose UIA names ("Close", ...) overlap dialog button captions. - /// - public void ClickButton(string name, TimeSpan? timeout = null) - { - AutomationElement button = WaitFor( - () => FindInApp(cf => cf.ByControlType(ControlType.Button) - .And(cf.ByName(name)) - .And(cf.ByClassName("Button"))), - $"button '{name}'", - timeout); - InvokeElement(button); - } - /// - /// Best-effort variant of for finally cleanup: - /// clicks the button when it is currently present and swallows every failure, - /// so closing a leftover dialog can never mask the test's own exception. - /// - public void TryClickButton(string name) - { - try - { - AutomationElement? button = FindInApp(cf => cf.ByControlType(ControlType.Button) - .And(cf.ByName(name)) - .And(cf.ByClassName("Button"))); - if (button is not null) - { - InvokeElement(button); - } - } - catch - { - // Cleanup only — the test outcome was decided in the try body. - } - } - /// - /// Writes into : the UIA Value - /// pattern when available and writable (works without window focus), otherwise - /// focus plus real keystrokes via . - /// - public void EnterText(AutomationElement element, string text) - { - ArgumentNullException.ThrowIfNull(element); - try - { - if (element.Patterns.Value.TryGetPattern(out var value) - && !value.IsReadOnly.ValueOrDefault) - { - value.SetValue(text); - return; - } - } - catch - { - // Some boxes reject SetValue (or the element re-rendered); type instead. - } + /// Waits for a text element whose name is exactly . + public AutomationElement WaitForText(string text, TimeSpan? timeout = null) + => WaitFor( + () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))), + $"text '{text}'", + timeout); - MainWindow.Focus(); - element.Focus(); - Keyboard.Type(text); - Wait.UntilInputIsProcessed(); - } - /// Invoke pattern first, SelectionItem second, mouse click as last resort. - private static void InvokeElement(AutomationElement element) - { - if (element.Patterns.Invoke.TryGetPattern(out var invoke)) - { - invoke.Invoke(); - } - else if (element.Patterns.SelectionItem.TryGetPattern(out var selectionItem)) - { - selectionItem.Select(); - } - else - { - element.Click(); - } - Wait.UntilInputIsProcessed(); - } - // ---------------------------------------------------------------- lifecycle + /// Waits until no text element named remains. + public void WaitForTextGone(string text, TimeSpan? timeout = null) + => WaitUntilGone( + () => FindInApp(cf => cf.ByControlType(ControlType.Text).And(cf.ByName(text))), + $"text '{text}'", + timeout); - private Window WaitForMainWindow() - { - RetryResult result = Retry.WhileNull( - () => - { - if (App.HasExited) - { - throw new InvalidOperationException( - $"gclo exited while waiting for its main window (exit code {App.ExitCode}). " - + $"Exe: {ExePath}"); - } - try - { - return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500)); - } - catch - { - return null; // not ready yet; retried until the outer timeout - } - }, - timeout: LaunchTimeout, - interval: PollInterval); - if (result.Result is { } window) - { - return window; - } - throw new TimeoutException( - $"gclo's main window did not appear within {LaunchTimeout.TotalSeconds:0}s. Exe: {ExePath}"); - } - /// - /// The exe to drive: GCLO_UITEST_EXE when set, else the repo's Debug/x64 output, - /// located by walking up from the test assembly to the folder holding gclo.slnx. - /// - private static string ResolveExePath() - { - string? fromEnv = Environment.GetEnvironmentVariable("GCLO_UITEST_EXE"); - if (!string.IsNullOrWhiteSpace(fromEnv)) - { - if (!File.Exists(fromEnv)) - { - throw new FileNotFoundException( - $"GCLO_UITEST_EXE points at '{fromEnv}', which does not exist.", fromEnv); - } - return fromEnv; - } + // ---------------------------------------------------------------- actions - for (DirectoryInfo? current = new(AppContext.BaseDirectory); - current is not null; - current = current.Parent) - { - if (!File.Exists(Path.Combine(current.FullName, "gclo.slnx"))) - { - continue; - } + /// + /// Opens the menu bar item named (e.g. "File") + /// and invokes the flyout item carrying . + /// The bar item is opened via its ExpandCollapse pattern (WinUI's MenuBarItem + /// exposes it) with Invoke as fallback; the flyout item is then looked up across + /// the app's windows because menu popups are windowed in WinAppSDK. + /// + public void InvokeMenuItem(string menuBarItemName, string itemAutomationId) + { + AutomationElement barItem = WaitFor( + () => MainWindow.FindFirstDescendant(cf => + cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuBarItemName))) + ?? MainWindow.FindFirstDescendant(cf => cf.ByName(menuBarItemName)), + $"menu bar item '{menuBarItemName}'"); - string exePath = Path.Combine( - current.FullName, "gclo", "bin", "x64", "Debug", - "net10.0-windows10.0.19041.0", "win-x64", "gclo.exe"); - if (!File.Exists(exePath)) - { - throw new FileNotFoundException( - $"gclo.exe not found at '{exePath}'. Build the app first: " - + "dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None " - + "— or set GCLO_UITEST_EXE to the exe to test.", - exePath); - } - return exePath; - } + if(barItem.Patterns.ExpandCollapse.TryGetPattern(out IExpandCollapsePattern? expandCollapse)) + { + expandCollapse.Expand(); + Wait.UntilInputIsProcessed(); + } + else + { + InvokeElement(barItem); + } - throw new FileNotFoundException( - $"Could not find the repo root (no gclo.slnx above '{AppContext.BaseDirectory}'). " - + "Set GCLO_UITEST_EXE to the gclo.exe to test."); - } + AutomationElement item = WaitFor( + () => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), + $"menu item '{itemAutomationId}' under '{menuBarItemName}'"); + InvokeElement(item); + } - private static int _sessionCounter; - /// - /// Waits until the app's activity log (under the session's data directory) - /// contains . The log is the one deterministic signal - /// for "the app handled that command" when the UI shows no change by design. - /// - public void WaitForLogLine(string fragment, TimeSpan? timeout = null) - { - var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); - string logs = Path.Combine(DataDirectory, "logs"); - while (DateTime.UtcNow < deadline) - { - if (Directory.Exists(logs)) - { - foreach (string file in Directory.EnumerateFiles(logs, "*.log")) - { - try - { - using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream); - if (reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal)) - { - return; - } - } - catch (IOException) - { - // Mid-write; try again. - } - } - } - Thread.Sleep(50); - } - throw new TimeoutException($"Timed out waiting for the activity log to contain '{fragment}'."); - } - /// - /// When GCLO_UITEST_ARTIFACTS names a directory (CI does), saves a screenshot of - /// the screen as the session ends and copies the app's activity log beside it, so - /// a failure on a hosted runner has something to diagnose with. Best effort. - /// - private void TryCaptureArtifacts() - { - string? root = Environment.GetEnvironmentVariable("GCLO_UITEST_ARTIFACTS"); - if (string.IsNullOrWhiteSpace(root)) - { + /// + /// Activates a NavigationView item by AutomationId. NavigationViewItem exposes + /// Invoke (preferred, it routes through the pane's selection logic) and + /// SelectionItem; a raw click is the last resort. + /// + public void InvokeNavItem(string automationId) + => InvokeElement(WaitForElement(automationId)); + + + + /// + /// Invokes the XAML button named . The ClassName filter + /// ("Button") keeps ContentDialog buttons (real XAML buttons named by their + /// *ButtonText) from colliding with the window's non-client caption buttons, + /// whose UIA names ("Close", ...) overlap dialog button captions. + /// + public void ClickButton(string name, TimeSpan? timeout = null) + { + AutomationElement button = WaitFor( + () => FindInApp(cf => cf.ByControlType(ControlType.Button) + .And(cf.ByName(name)) + .And(cf.ByClassName("Button"))), + $"button '{name}'", + timeout); + InvokeElement(button); + } + + + + /// + /// Best-effort variant of for finally cleanup: + /// clicks the button when it is currently present and swallows every failure, + /// so closing a leftover dialog can never mask the test's own exception. + /// + public void TryClickButton(string name) + { + try + { + AutomationElement? button = FindInApp(cf => cf.ByControlType(ControlType.Button) + .And(cf.ByName(name)) + .And(cf.ByClassName("Button"))); + if(button is not null) + { + InvokeElement(button); + } + } + catch + { + // Cleanup only — the test outcome was decided in the try body. + } + } + + + + /// + /// Writes into : the UIA Value + /// pattern when available and writable (works without window focus), otherwise + /// focus plus real keystrokes via . + /// + public void EnterText(AutomationElement element, string text) + { + ArgumentNullException.ThrowIfNull(element); + try + { + if(element.Patterns.Value.TryGetPattern(out IValuePattern? value) + && !value.IsReadOnly.ValueOrDefault) + { + value.SetValue(text); return; - } + } + } + catch + { + // Some boxes reject SetValue (or the element re-rendered); type instead. + } - int number = Interlocked.Increment(ref _sessionCounter); - string stamp = DateTime.Now.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); - try - { - Directory.CreateDirectory(root); - using var image = FlaUI.Core.Capturing.Capture.Screen(); - image.ToFile(Path.Combine(root, $"session-{number:D2}-{stamp}.png")); - } - catch - { - // A missing desktop session or a disposed automation must not fail the test. - } + MainWindow.Focus(); + element.Focus(); + Keyboard.Type(text); + Wait.UntilInputIsProcessed(); + } - try - { - string logs = Path.Combine(DataDirectory, "logs"); - if (Directory.Exists(logs)) + + + /// Invoke pattern first, SelectionItem second, mouse click as last resort. + private static void InvokeElement(AutomationElement element) + { + if(element.Patterns.Invoke.TryGetPattern(out IInvokePattern? invoke)) + { + invoke.Invoke(); + } + else if(element.Patterns.SelectionItem.TryGetPattern(out ISelectionItemPattern? selectionItem)) + { + selectionItem.Select(); + } + else + { + element.Click(); + } + Wait.UntilInputIsProcessed(); + } + + + + // ---------------------------------------------------------------- lifecycle + + private Window WaitForMainWindow() + { + RetryResult result = Retry.WhileNull( + () => + { + if(App.HasExited) + { + throw new InvalidOperationException( + $"gclo exited while waiting for its main window (exit code {App.ExitCode}). " + + $"Exe: {ExePath}"); + } + try + { + return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500)); + } + catch + { + return null; // not ready yet; retried until the outer timeout + } + }, + timeout: s_launchTimeout, + interval: s_pollInterval); + + if(result.Result is { } window) + { + return window; + } + throw new TimeoutException( + $"gclo's main window did not appear within {s_launchTimeout.TotalSeconds:0}s. Exe: {ExePath}"); + } + + + + /// + /// The exe to drive: GCLO_UITEST_EXE when set, else the repo's Debug/x64 output, + /// located by walking up from the test assembly to the folder holding gclo.slnx. + /// + private static string ResolveExePath() + { + string? fromEnv = Environment.GetEnvironmentVariable("GCLO_UITEST_EXE"); + if(!string.IsNullOrWhiteSpace(fromEnv)) + { + if(!File.Exists(fromEnv)) + { + throw new FileNotFoundException( + $"GCLO_UITEST_EXE points at '{fromEnv}', which does not exist.", fromEnv); + } + return fromEnv; + } + + for(DirectoryInfo? current = new(AppContext.BaseDirectory); + current is not null; + current = current.Parent) + { + if(!File.Exists(Path.Combine(current.FullName, "gclo.slnx"))) + { + continue; + } + + string exePath = Path.Combine( + current.FullName, "gclo", "bin", "x64", "Debug", + "net10.0-windows10.0.19041.0", "win-x64", "gclo.exe"); + if(!File.Exists(exePath)) + { + throw new FileNotFoundException( + $"gclo.exe not found at '{exePath}'. Build the app first: " + + "dotnet build gclo/gclo.csproj -p:Platform=x64 -p:WindowsPackageType=None " + + "— or set GCLO_UITEST_EXE to the exe to test.", + exePath); + } + return exePath; + } + + throw new FileNotFoundException( + $"Could not find the repo root (no gclo.slnx above '{AppContext.BaseDirectory}'). " + + "Set GCLO_UITEST_EXE to the gclo.exe to test."); + } + + + + private static int s_sessionCounter; + + + + /// + /// Waits until the app's activity log (under the session's data directory) + /// contains . The log is the one deterministic signal + /// for "the app handled that command" when the UI shows no change by design. + /// + public void WaitForLogLine(string fragment, TimeSpan? timeout = null) + { + DateTime deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + string logs = Path.Combine(DataDirectory, "logs"); + while(DateTime.UtcNow < deadline) + { + if(Directory.Exists(logs)) + { + foreach(string file in Directory.EnumerateFiles(logs, "*.log")) { - foreach (string file in Directory.EnumerateFiles(logs)) - { - File.Copy(file, Path.Combine(root, $"session-{number:D2}-{Path.GetFileName(file)}"), overwrite: true); - } + try + { + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + if(reader.ReadToEnd().Contains(fragment, StringComparison.Ordinal)) + { + return; + } + } + catch(IOException) + { + // Mid-write; try again. + } } - } - catch - { - // Best effort only. - } - } + } + Thread.Sleep(50); + } + throw new TimeoutException($"Timed out waiting for the activity log to contain '{fragment}'."); + } - /// - public void Dispose() - { - TryCaptureArtifacts(); - // Order matters: take the app down first, dispose the automation only after - // the process is gone, then sweep the temp data directory. Every step is - // best-effort so one failure cannot mask the test outcome. - try - { - App?.Close(); // FlaUI kills the process itself if closing stalls - } - catch - { - // The process may already be gone; Kill below double-checks. - } - try - { - if (App is { HasExited: false }) + + /// + /// When GCLO_UITEST_ARTIFACTS names a directory (CI does), saves a screenshot of + /// the screen as the session ends and copies the app's activity log beside it, so + /// a failure on a hosted runner has something to diagnose with. Best effort. + /// + private void TryCaptureArtifacts() + { + string? root = Environment.GetEnvironmentVariable("GCLO_UITEST_ARTIFACTS"); + if(string.IsNullOrWhiteSpace(root)) + { + return; + } + + int number = Interlocked.Increment(ref s_sessionCounter); + string stamp = DateTime.Now.ToString("HHmmss", System.Globalization.CultureInfo.InvariantCulture); + try + { + Directory.CreateDirectory(root); + using CaptureImage image = FlaUI.Core.Capturing.Capture.Screen(); + image.ToFile(Path.Combine(root, $"session-{number:D2}-{stamp}.png")); + } + catch + { + // A missing desktop session or a disposed automation must not fail the test. + } + + try + { + string logs = Path.Combine(DataDirectory, "logs"); + if(Directory.Exists(logs)) + { + foreach(string file in Directory.EnumerateFiles(logs)) { - App.Kill(); + File.Copy(file, Path.Combine(root, $"session-{number:D2}-{Path.GetFileName(file)}"), overwrite: true); } - } - catch - { - // Best effort only. - } - App?.Dispose(); - Automation?.Dispose(); + } + } + catch + { + // Best effort only. + } + } - try - { - Directory.Delete(DataDirectory, recursive: true); - } - catch - { - // A straggling handle keeps the directory; it lives under %TEMP% anyway. - } - GC.SuppressFinalize(this); - } + + + /// + public void Dispose() + { + TryCaptureArtifacts(); + + // Order matters: take the app down first, dispose the automation only after + // the process is gone, then sweep the temp data directory. Every step is + // best-effort so one failure cannot mask the test outcome. + try + { + App?.Close(); // FlaUI kills the process itself if closing stalls + } + catch + { + // The process may already be gone; Kill below double-checks. + } + try + { + if(App is { HasExited: false }) + { + App.Kill(); + } + } + catch + { + // Best effort only. + } + App?.Dispose(); + Automation?.Dispose(); + + try + { + Directory.Delete(DataDirectory, recursive: true); + } + catch + { + // A straggling handle keeps the directory; it lives under %TEMP% anyway. + } + GC.SuppressFinalize(this); + } } diff --git a/gclo.UiTests/RepoTableTests.cs b/gclo.UiTests/RepoTableTests.cs index 524f7be..0811e96 100644 --- a/gclo.UiTests/RepoTableTests.cs +++ b/gclo.UiTests/RepoTableTests.cs @@ -1,9 +1,16 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using FlaUI.Core.AutomationElements; using FlaUI.Core.Input; using FlaUI.Core.WindowsAPI; + namespace gclo.UiTests; + /// /// Drives the repository table (WinUI.TableView, #33) against the app's offline /// fixture: the magic token routes Quick Sync to a fixed organization with three @@ -14,132 +21,148 @@ namespace gclo.UiTests; [Collection(nameof(UiTableTests))] public sealed class RepoTableTests : UiTestBase { - private const string FixtureToken = "gclo-uitest-fixture-token"; + private const string FixtureToken = "gclo-uitest-fixture-token"; - public RepoTableTests(AppSession session) - : base(session) - { - } - // (1) The fixture organization loads into the table: the TableView is reachable - // by its automation id, the three repositories are rendered as cells, and - // the header row (select-all, funnels) is in the UI Automation tree. - [Fact] - public void LoadRepositories_WithFixtureToken_RendersTheTable() - { - EnsureLoaded(); - Assert.NotNull(Session.FindInApp(cf => cf.ByName("alpha"))); - Assert.NotNull(Session.FindInApp(cf => cf.ByName("bravo"))); - Assert.NotNull(Session.FindInApp(cf => cf.ByName("charlie"))); - Assert.NotNull(Session.WaitFor(() => Funnel("StatusFilterButton"), "the Status funnel")); - Assert.NotNull(Session.WaitFor(() => ColumnHeader("Select"), "the select-all header")); - } + public RepoTableTests(AppSession session) + : base(session) + { + } - // (2) Column headers carry composed sort names and the Invoke pattern; invoking - // one sorts through the view model (ascending, then descending, then back to - // ascending on the control's "clear" step), and other columns stay unsorted. - [Fact] - public void Headers_InvokeSortsThroughTheViewModel() - { - EnsureLoaded(); - AutomationElement header = Session.WaitFor(() => ColumnHeader("Branch, not sorted"), "the Branch column header"); - Assert.True(header.Patterns.Invoke.IsSupported, "a sortable header exposes the Invoke pattern"); - header.Patterns.Invoke.Pattern.Invoke(); - Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after one sort"); - Assert.NotNull(ColumnHeader("Name, not sorted")); + // (1) The fixture organization loads into the table: the TableView is reachable + // by its automation id, the three repositories are rendered as cells, and + // the header row (select-all, funnels) is in the UI Automation tree. + [Fact] + public void LoadRepositories_WithFixtureToken_RendersTheTable() + { + EnsureLoaded(); - header.Patterns.Invoke.Pattern.Invoke(); - Session.WaitFor(() => ColumnHeader("Branch, sorted descending"), "the header after a second sort"); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("alpha"))); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("bravo"))); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("charlie"))); + Assert.NotNull(Session.WaitFor(() => Funnel("StatusFilterButton"), "the Status funnel")); + Assert.NotNull(Session.WaitFor(() => ColumnHeader("Select"), "the select-all header")); + } - header.Patterns.Invoke.Pattern.Invoke(); // the control's third step is "clear"; the view model starts over - Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after a third sort"); - } - // (3) The per-column funnel opens its flyout; its text box drives the view - // model's filter (rows vanish, the empty-filter text appears, the funnel - // names the active filter) and Clear restores every row. - [Fact] - public void NameFunnel_FiltersRows_AndClearRestoresThem() - { - EnsureLoaded(); - try - { - Session.WaitFor(() => Funnel("NameFilterButton"), "the Name funnel").AsButton().Invoke(); - AutomationElement box = Session.WaitForElement("NameFilterBox"); - Session.EnterText(box, "zzz"); + // (2) Column headers carry composed sort names and the Invoke pattern; invoking + // one sorts through the view model (ascending, then descending, then back to + // ascending on the control's "clear" step), and other columns stay unsorted. + [Fact] + public void Headers_InvokeSortsThroughTheViewModel() + { + EnsureLoaded(); - Session.WaitFor( - () => Session.FindInApp(cf => cf.ByName("Filter by name, filtering on 'zzz'")), - "the funnel to report the active filter"); - Session.WaitForText("No repositories match this filter."); - AppSession.WaitUntilGone(() => Session.FindInApp(cf => cf.ByName("alpha")), "the filtered-out row"); + AutomationElement header = Session.WaitFor(() => ColumnHeader("Branch, not sorted"), "the Branch column header"); + Assert.True(header.Patterns.Invoke.IsSupported, "a sortable header exposes the Invoke pattern"); - Session.ClickButton("Clear"); - Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("Filter by name")), "the funnel after Clear"); - Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("alpha")), "the row to come back"); - Session.WaitForTextGone("No repositories match this filter."); - } - finally - { - Keyboard.Press(VirtualKeyShort.ESCAPE); // close the flyout if it is still open - } - } + header.Patterns.Invoke.Pattern.Invoke(); + Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after one sort"); + Assert.NotNull(ColumnHeader("Name, not sorted")); - // (4) Keyboard reachability of the funnels (Tab from the select-all checkbox) is - // not asserted here: synthesized Tab keystrokes did not move focus inside the - // TableView header under FlaUI on the development machine, so that remains a - // manual Narrator/keyboard check (see the #33 acceptance notes). + header.Patterns.Invoke.Pattern.Invoke(); + Session.WaitFor(() => ColumnHeader("Branch, sorted descending"), "the header after a second sort"); - /// A column header by its automation name; the header row is in the UIA tree thanks to AccessibleTableView. - private AutomationElement? ColumnHeader(string name) - => Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByName(name)); + header.Patterns.Invoke.Pattern.Invoke(); // the control's third step is "clear"; the view model starts over + Session.WaitFor(() => ColumnHeader("Branch, sorted ascending"), "the header after a third sort"); + } - /// The funnel button carrying , inside its column header. - private AutomationElement? Funnel(string automationId) - => Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); - /// - /// Loads the fixture organization once per session: paste the magic token (the - /// org lookup is answered by the fixture), pick the org, then Load repositories. - /// A session that already shows the table skips straight through. - /// - private void EnsureLoaded() - { - if (Session.FindInApp(cf => cf.ByAutomationId("RepoListView")) is not null) - { - return; - } - AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); - Session.EnterText(tokenBox, FixtureToken); - // The debounced org lookup is answered by the fixture (one organization); the - // log line is the oracle, then the org is picked from the combo box's list so - // the selection commits to the view model's Organization, which enables Load. - Session.WaitForLogLine("Organization lookup finished: 1 organizations", TimeSpan.FromSeconds(20)); - ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox(); - Session.WaitFor( - () => - { - orgBox.Expand(); - return orgBox.Items.FirstOrDefault(i => i.Name == "fixture-org"); - }, - "the fixture org in the organization list"); - orgBox.Select("fixture-org"); - orgBox.Collapse(); + // (3) The per-column funnel opens its flyout; its text box drives the view + // model's filter (rows vanish, the empty-filter text appears, the funnel + // names the active filter) and Clear restores every row. + [Fact] + public void NameFunnel_FiltersRows_AndClearRestoresThem() + { + EnsureLoaded(); - Session.WaitFor( - () => Session.FindInApp(cf => cf.ByName("Load repositories")) is { IsEnabled: true } button ? button : null, - "the Load repositories button to enable"); - Session.ClickButton("Load repositories"); - Session.WaitForElement("RepoListView", TimeSpan.FromSeconds(20)); - Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("charlie")), "the last fixture row"); - } + try + { + Session.WaitFor(() => Funnel("NameFilterButton"), "the Name funnel").AsButton().Invoke(); + AutomationElement box = Session.WaitForElement("NameFilterBox"); + Session.EnterText(box, "zzz"); + + Session.WaitFor( + () => Session.FindInApp(cf => cf.ByName("Filter by name, filtering on 'zzz'")), + "the funnel to report the active filter"); + Session.WaitForText("No repositories match this filter."); + AppSession.WaitUntilGone(() => Session.FindInApp(cf => cf.ByName("alpha")), "the filtered-out row"); + + Session.ClickButton("Clear"); + Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("Filter by name")), "the funnel after Clear"); + Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("alpha")), "the row to come back"); + Session.WaitForTextGone("No repositories match this filter."); + } + finally + { + Keyboard.Press(VirtualKeyShort.ESCAPE); // close the flyout if it is still open + } + } + + + + // (4) Keyboard reachability of the funnels (Tab from the select-all checkbox) is + // not asserted here: synthesized Tab keystrokes did not move focus inside the + // TableView header under FlaUI on the development machine, so that remains a + // manual Narrator/keyboard check (see the #33 acceptance notes). + + /// A column header by its automation name; the header row is in the UIA tree thanks to AccessibleTableView. + private AutomationElement? ColumnHeader(string name) + => Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByName(name)); + + + + /// The funnel button carrying , inside its column header. + private AutomationElement? Funnel(string automationId) + => Session.FindInApp(cf => cf.ByAutomationId("RepoListView"))?.FindFirstDescendant(cf => cf.ByAutomationId(automationId)); + + + + /// + /// Loads the fixture organization once per session: paste the magic token (the + /// org lookup is answered by the fixture), pick the org, then Load repositories. + /// A session that already shows the table skips straight through. + /// + private void EnsureLoaded() + { + if(Session.FindInApp(cf => cf.ByAutomationId("RepoListView")) is not null) + { + return; + } + + AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); + Session.EnterText(tokenBox, FixtureToken); + // The debounced org lookup is answered by the fixture (one organization); the + // log line is the oracle, then the org is picked from the combo box's list so + // the selection commits to the view model's Organization, which enables Load. + Session.WaitForLogLine("Organization lookup finished: 1 organizations", TimeSpan.FromSeconds(20)); + ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox(); + Session.WaitFor( + () => + { + orgBox.Expand(); + return orgBox.Items.FirstOrDefault(i => string.Equals(i.Name, "fixture-org", StringComparison.Ordinal)); + }, + "the fixture org in the organization list"); + orgBox.Select("fixture-org"); + orgBox.Collapse(); + + Session.WaitFor( + () => Session.FindInApp(cf => cf.ByName("Load repositories")) is { IsEnabled: true } button ? button : null, + "the Load repositories button to enable"); + Session.ClickButton("Load repositories"); + Session.WaitForElement("RepoListView", TimeSpan.FromSeconds(20)); + Session.WaitFor(() => Session.FindInApp(cf => cf.ByName("charlie")), "the last fixture row"); + } } + + /// /// A second collection (and therefore a second launched app) for the table tests; /// see for why they cannot share the smoke session. diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs index 9cdca57..d29ede7 100644 --- a/gclo.UiTests/SmokeTests.cs +++ b/gclo.UiTests/SmokeTests.cs @@ -1,8 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.RegularExpressions; using FlaUI.Core.AutomationElements; + namespace gclo.UiTests; + /// /// Token-free, deterministic smoke tests against the launched app: the shell and /// its navigation render, the core dialogs open and close, and offline input does @@ -15,176 +22,192 @@ namespace gclo.UiTests; [Collection(nameof(UiSessionTests))] public sealed partial class SmokeTests : UiTestBase { - /// Small pause after a button invoke whose handler runs behind a dialog deferral. - private static readonly TimeSpan PostInvokeSettle = TimeSpan.FromMilliseconds(300); - public SmokeTests(AppSession session) - : base(session) - { - } + public SmokeTests(AppSession session) + : base(session) + { + } - // (1) The app launches and puts up its shell window. - [Fact] - public void MainWindow_AfterLaunch_TitleContainsGclo() - => Assert.Contains("gclo", Session.MainWindow.Title, StringComparison.Ordinal); - // (2) The navigation pane carries the pinned Quick Sync plus both footer commands. - [Fact] - public void NavigationPane_AfterLaunch_ShowsQuickSyncAddAccountAndSyncAll() - { - Assert.NotNull(Session.WaitForElement("NavQuickSync")); - Assert.NotNull(Session.WaitForElement("NavAddAccount")); - Assert.NotNull(Session.WaitForElement("NavSyncAll")); - } - // (3) With no accounts and no prior load, the Quick Sync workspace shows the - // connect card, and the 'Sync all' footer command has nothing to run. - [Fact] - public void ConnectCard_FreshDataDir_ShowsTokenAndLoadAndDisablesSyncAll() - { - Assert.NotNull(Session.WaitForElement("ConnectTokenBox")); - Assert.NotNull(Session.WaitForElement("LoadReposButton")); - // The fresh data dir must also mean a fresh vault: a default token saved in the - // developer's real profile must not pre-fill Quick Sync (#60). The session - // snapshots this at launch, before any test types a token. - Assert.False( - Session.TokenPrefilledAtLaunch, - "Quick Sync was pre-filled with a token from outside the test data directory."); + // (1) The app launches and puts up its shell window. + [Fact] + public void MainWindow_AfterLaunch_TitleContainsGclo() + => Assert.Contains("gclo", Session.MainWindow.Title, StringComparison.Ordinal); - AutomationElement syncAll = Session.WaitForElement("NavSyncAll"); - Assert.False(syncAll.IsEnabled, "'Sync all' should be disabled while no accounts exist."); - } - // (4) File -> Settings opens the Settings dialog; Cancel closes it. - [Fact] - public void FileMenu_Settings_OpensDialogAndCancelCloses() - { - Session.InvokeMenuItem("File", "MenuSettings"); - try - { - Session.WaitForText("Settings"); // the ContentDialog's title - Session.ClickButton("Cancel"); - Session.WaitForTextGone("Settings"); - } - finally - { - Session.TryClickButton("Cancel"); // no-op when the happy path closed it - } - } - // (5) Help -> About shows the semantic version plus the parenthesized git hash. - [Fact] - public void HelpMenu_About_ShowsSemanticVersionWithCommitHash() - { - Session.InvokeMenuItem("Help", "MenuAbout"); - try - { - // The TextBlock exists as soon as the dialog opens; retry until its - // text has been populated by the AboutDialog constructor. - AutomationElement version = Session.WaitFor( - () => - { - AutomationElement? element = - Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText")); - return element is not null - && element.Name.StartsWith("Version ", StringComparison.Ordinal) - ? element - : null; - }, - "the About dialog's version text"); + // (2) The navigation pane carries the pinned Quick Sync plus both footer commands. + [Fact] + public void NavigationPane_AfterLaunch_ShowsQuickSyncAddAccountAndSyncAll() + { + Assert.NotNull(Session.WaitForElement("NavQuickSync")); + Assert.NotNull(Session.WaitForElement("NavAddAccount")); + Assert.NotNull(Session.WaitForElement("NavSyncAll")); + } - string text = version.Name; - Assert.Matches(VersionPattern(), text); - Assert.Contains("(", text, StringComparison.Ordinal); // "()" - Session.ClickButton("Close"); - Session.WaitForElementGone("AboutVersionText"); - } - finally - { - Session.TryClickButton("Close"); // no-op when the happy path closed it - } - } - // (6) The Add account wizard appears; Next with an empty name keeps it open - // (step 1 refuses to advance); Cancel closes it. - [Fact] - public void AddAccountWizard_NextWithEmptyName_StaysOpenAndCancelCloses() - { - Session.InvokeNavItem("NavAddAccount"); - try - { - Session.WaitForElement("WizardNameBox"); // wizard step 1 is on screen - Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step + // (3) With no accounts and no prior load, the Quick Sync workspace shows the + // connect card, and the 'Sync all' footer command has nothing to run. + [Fact] + public void ConnectCard_FreshDataDir_ShowsTokenAndLoadAndDisablesSyncAll() + { + Assert.NotNull(Session.WaitForElement("ConnectTokenBox")); + Assert.NotNull(Session.WaitForElement("LoadReposButton")); + // The fresh data dir must also mean a fresh vault: a default token saved in the + // developer's real profile must not pre-fill Quick Sync (#60). The session + // snapshots this at launch, before any test types a token. + Assert.False( + Session.TokenPrefilledAtLaunch, + "Quick Sync was pre-filled with a token from outside the test data directory."); - Session.ClickButton("Next"); + AutomationElement syncAll = Session.WaitForElement("NavSyncAll"); + Assert.False(syncAll.IsEnabled, "'Sync all' should be disabled while no accounts exist."); + } - // Positive oracle: the step-1 validation message appearing proves the - // Next handler ran and rejected; only then is "still on step 1" meaningful. - Session.WaitForText("Enter a name for this account."); - Assert.NotNull(Session.FindInApp(cf => cf.ByName("Step 1 of 4: Identity"))); - Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open - Assert.False(Session.App.HasExited); - Session.ClickButton("Cancel"); - Session.WaitForElementGone("WizardNameBox"); - } - finally - { - Session.TryClickButton("Cancel"); // no-op when the happy path closed it - } - } - // (7) Typing into the token box (which kicks the offline org-listing path) does - // not crash the app, and the org combo stays present. No token validation - // is asserted — these tests run without network access or credentials. - [Fact] - public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent() - { - AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); - Session.EnterText(tokenBox, "ghp_uitests_offline_dummy_token"); + // (4) File -> Settings opens the Settings dialog; Cancel closes it. + [Fact] + public void FileMenu_Settings_OpensDialogAndCancelCloses() + { + Session.InvokeMenuItem("File", "MenuSettings"); + try + { + Session.WaitForText("Settings"); // the ContentDialog's title + Session.ClickButton("Cancel"); + Session.WaitForTextGone("Settings"); + } + finally + { + Session.TryClickButton("Cancel"); // no-op when the happy path closed it + } + } - Assert.NotNull(Session.WaitForElement("ConnectOrgBox")); - Assert.False(Session.App.HasExited); - } - // (8) Regression for the dialog-collision crash: WinUI allows one open - // ContentDialog per XamlRoot, and a second ShowAsync throws a COMException - // that — escaping an async void handler — used to kill the whole process - // (0xc000027b). Commanding 'Add account' while About is open must now be - // ignored: the app stays alive, About stays up, and no wizard appears. - [Fact] - public void AddAccountWhileAboutDialogOpen_IsIgnoredAndAppSurvives() - { - Session.InvokeMenuItem("Help", "MenuAbout"); - try - { - Session.WaitForElement("AboutVersionText"); - Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op + // (5) Help -> About shows the semantic version plus the parenthesized git hash. + [Fact] + public void HelpMenu_About_ShowsSemanticVersionWithCommitHash() + { + Session.InvokeMenuItem("Help", "MenuAbout"); + try + { + // The TextBlock exists as soon as the dialog opens; retry until its + // text has been populated by the AboutDialog constructor. + AutomationElement version = Session.WaitFor( + () => + { + AutomationElement? element = + Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText")); + return element is not null + && element.Name.StartsWith("Version ", StringComparison.Ordinal) + ? element + : null; + }, + "the About dialog's version text"); - // Positive oracle: the shell logs that it declined to open the wizard, so - // the negative assertions below are checked after the command was handled, - // not after an arbitrary pause. - Session.WaitForLogLine("Account wizard not opened: another dialog is open."); + string text = version.Name; + Assert.Matches(VersionPattern(), text); + Assert.Contains("(", text, StringComparison.Ordinal); // "()" - Assert.False(Session.App.HasExited); - Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox"))); - Assert.NotNull(Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText"))); + Session.ClickButton("Close"); + Session.WaitForElementGone("AboutVersionText"); + } + finally + { + Session.TryClickButton("Close"); // no-op when the happy path closed it + } + } - Session.ClickButton("Close"); - Session.WaitForElementGone("AboutVersionText"); - } - finally - { - Session.TryClickButton("Close"); // no-op when the happy path closed it - } - } - // (9) Resizing below the minimum window size is intentionally not covered: - // driving OS-level resize through UIA transforms is flaky across DPI - // scales, and the minimum is already enforced by OverlappedPresenter. - [GeneratedRegex(@"^Version \d+\.\d+\.\d+")] - private static partial Regex VersionPattern(); + // (6) The Add account wizard appears; Next with an empty name keeps it open + // (step 1 refuses to advance); Cancel closes it. + [Fact] + public void AddAccountWizard_NextWithEmptyName_StaysOpenAndCancelCloses() + { + Session.InvokeNavItem("NavAddAccount"); + try + { + Session.WaitForElement("WizardNameBox"); // wizard step 1 is on screen + Session.WaitForText("Step 1 of 4: Identity"); // right dialog, right step + + Session.ClickButton("Next"); + + // Positive oracle: the step-1 validation message appearing proves the + // Next handler ran and rejected; only then is "still on step 1" meaningful. + Session.WaitForText("Enter a name for this account."); + Assert.NotNull(Session.FindInApp(cf => cf.ByName("Step 1 of 4: Identity"))); + Assert.NotNull(Session.WaitForElement("WizardNameBox")); // still open + Assert.False(Session.App.HasExited); + + Session.ClickButton("Cancel"); + Session.WaitForElementGone("WizardNameBox"); + } + finally + { + Session.TryClickButton("Cancel"); // no-op when the happy path closed it + } + } + + + + // (7) Typing into the token box (which kicks the offline org-listing path) does + // not crash the app, and the org combo stays present. No token validation + // is asserted — these tests run without network access or credentials. + [Fact] + public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent() + { + AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); + Session.EnterText(tokenBox, "ghp_uitests_offline_dummy_token"); + + Assert.NotNull(Session.WaitForElement("ConnectOrgBox")); + Assert.False(Session.App.HasExited); + } + + + + // (8) Regression for the dialog-collision crash: WinUI allows one open + // ContentDialog per XamlRoot, and a second ShowAsync throws a COMException + // that — escaping an async void handler — used to kill the whole process + // (0xc000027b). Commanding 'Add account' while About is open must now be + // ignored: the app stays alive, About stays up, and no wizard appears. + [Fact] + public void AddAccountWhileAboutDialogOpen_IsIgnoredAndAppSurvives() + { + Session.InvokeMenuItem("Help", "MenuAbout"); + try + { + Session.WaitForElement("AboutVersionText"); + + Session.InvokeNavItem("NavAddAccount"); // second dialog: must be a no-op + + // Positive oracle: the shell logs that it declined to open the wizard, so + // the negative assertions below are checked after the command was handled, + // not after an arbitrary pause. + Session.WaitForLogLine("Account wizard not opened: another dialog is open."); + + Assert.False(Session.App.HasExited); + Assert.Null(Session.FindInApp(cf => cf.ByAutomationId("WizardNameBox"))); + Assert.NotNull(Session.FindInApp(cf => cf.ByAutomationId("AboutVersionText"))); + + Session.ClickButton("Close"); + Session.WaitForElementGone("AboutVersionText"); + } + finally + { + Session.TryClickButton("Close"); // no-op when the happy path closed it + } + } + + + + // (9) Resizing below the minimum window size is intentionally not covered: + // driving OS-level resize through UIA transforms is flaky across DPI + // scales, and the minimum is already enforced by OverlappedPresenter. + + [GeneratedRegex(@"^Version \d+\.\d+\.\d+", RegexOptions.None, 1000)] + private static partial Regex VersionPattern(); } diff --git a/gclo.UiTests/UiSessionTests.cs b/gclo.UiTests/UiSessionTests.cs index 19c5d90..68b06f2 100644 --- a/gclo.UiTests/UiSessionTests.cs +++ b/gclo.UiTests/UiSessionTests.cs @@ -1,9 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + // The table tests launch a second app instance in their own collection; two FlaUI // sessions must never drive input at the same time, so collections run one at a time. [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace gclo.UiTests; + /// /// The single xunit collection every UI test belongs to (referenced via /// nameof(UiSessionTests)). All UI tests share one launched gclo instance @@ -16,6 +22,8 @@ public sealed class UiSessionTests : ICollectionFixture { } + + /// /// Base class for UI tests: holds the shared so test /// bodies read as session-relative steps. Derived classes must carry @@ -23,13 +31,15 @@ public sealed class UiSessionTests : ICollectionFixture /// public abstract class UiTestBase { - /// Captures the collection fixture xunit injects per test class. - protected UiTestBase(AppSession session) - { - ArgumentNullException.ThrowIfNull(session); - Session = session; - } + /// Captures the collection fixture xunit injects per test class. + protected UiTestBase(AppSession session) + { + ArgumentNullException.ThrowIfNull(session); + Session = session; + } - /// The app instance shared by every test in the collection. - protected AppSession Session { get; } + + + /// The app instance shared by every test in the collection. + protected AppSession Session { get; } } diff --git a/gclo.UiTests/gclo.UiTests.csproj b/gclo.UiTests/gclo.UiTests.csproj index 9855dd8..3e628a1 100644 --- a/gclo.UiTests/gclo.UiTests.csproj +++ b/gclo.UiTests/gclo.UiTests.csproj @@ -14,10 +14,10 @@ - - - - + + + + diff --git a/gclo.UiTests/packages.lock.json b/gclo.UiTests/packages.lock.json index a6c08ee..b004b87 100644 --- a/gclo.UiTests/packages.lock.json +++ b/gclo.UiTests/packages.lock.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "dependencies": { "net10.0-windows10.0.19041": { "FlaUI.UIA3": { @@ -12,6 +12,12 @@ "Interop.UIAutomationClient": "10.19041.0" } }, + "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, )", diff --git a/gclo.ViewModels/Account.cs b/gclo.ViewModels/Account.cs index 0cdb693..a92d55d 100644 --- a/gclo.ViewModels/Account.cs +++ b/gclo.ViewModels/Account.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// /// A saved connection profile: which GitHub organization to sync, where its clones /// live on disk, and how the sync runs. Metadata only — the access token is held @@ -8,33 +14,33 @@ namespace gclo.ViewModels; /// public sealed record Account { - /// Stable identity: keys the vault entry and survives renames. - public required Guid Id { get; init; } + /// Stable identity: keys the vault entry and survives renames. + public required Guid Id { get; init; } - /// Display name; unique across accounts (case-insensitive). - public required string Name { get; init; } + /// Display name; unique across accounts (case-insensitive). + public required string Name { get; init; } - /// Optional free-form note about what the account is for. - public string Description { get; init; } = ""; + /// Optional free-form note about what the account is for. + public string Description { get; init; } = ""; - /// GitHub organization (or user) whose repositories are synced. - public required string Organization { get; init; } + /// GitHub organization (or user) whose repositories are synced. + public required string Organization { get; init; } - /// Folder the sync targets; see . - public required string TargetRoot { get; init; } + /// Folder the sync targets; see . + public required string TargetRoot { get; init; } - /// - /// When true the effective sync target is \ - /// rather than itself. - /// - public bool CreateOrgSubfolder { get; init; } + /// + /// When true the effective sync target is \ + /// rather than itself. + /// + public bool CreateOrgSubfolder { get; init; } - /// Parallel clone/pull count used when syncing this account. - public int MaxConcurrency { get; init; } = AppSettings.DefaultConcurrency; + /// Parallel clone/pull count used when syncing this account. + public int MaxConcurrency { get; init; } = AppSettings.DefaultConcurrency; - /// When the account's last sync finished, or null if it never ran. - public DateTimeOffset? LastSyncUtc { get; init; } + /// When the account's last sync finished, or null if it never ran. + public DateTimeOffset? LastSyncUtc { get; init; } - /// One-line outcome of the last sync, or null if it never ran. - public string? LastSyncSummary { get; init; } + /// One-line outcome of the last sync, or null if it never ran. + public string? LastSyncSummary { get; init; } } diff --git a/gclo.ViewModels/AccountConsistencyException.cs b/gclo.ViewModels/AccountConsistencyException.cs index e15a04d..d42e693 100644 --- a/gclo.ViewModels/AccountConsistencyException.cs +++ b/gclo.ViewModels/AccountConsistencyException.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// /// Thrown by when an operation failed AND the /// compensating step that would have restored consistency between accounts.json and @@ -10,13 +16,15 @@ namespace gclo.ViewModels; /// public sealed class AccountConsistencyException : Exception { - /// The failure that occurred while undoing the partial operation. - public Exception CompensationException { get; } + /// The failure that occurred while undoing the partial operation. + public Exception CompensationException { get; } - /// Creates the exception from the original and compensating failures. - public AccountConsistencyException(string message, Exception original, Exception compensation) - : base(message, original) - { - CompensationException = compensation; - } + + + /// Creates the exception from the original and compensating failures. + public AccountConsistencyException(string message, Exception original, Exception compensation) + : base(message, original) + { + CompensationException = compensation; + } } diff --git a/gclo.ViewModels/AccountWizardSeed.cs b/gclo.ViewModels/AccountWizardSeed.cs index c827c4e..bc3f506 100644 --- a/gclo.ViewModels/AccountWizardSeed.cs +++ b/gclo.ViewModels/AccountWizardSeed.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// /// The connection values a Quick Sync workspace hands to the account wizard so a /// working ad-hoc setup can be saved as an account without re-entering anything @@ -17,8 +23,8 @@ public sealed record AccountWizardSeed( bool CreateOrgSubfolder, int MaxConcurrency) { - /// Redacts the token: this record must never print it. - public override string ToString() => - $"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, " - + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}"; + /// Redacts the token: this record must never print it. + public override string ToString() => + $"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, " + + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}"; } diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs index 4348d9b..e214647 100644 --- a/gclo.ViewModels/AccountWizardViewModel.cs +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -1,9 +1,16 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using gclo.Engine; + namespace gclo.ViewModels; + /// /// Drives the four-step add/edit account wizard: identity (name + description), token /// (validated by listing the organizations it can see), organization, and destination. @@ -13,309 +20,340 @@ namespace gclo.ViewModels; /// public sealed partial class AccountWizardViewModel : ObservableObject { - private readonly AccountsStore _store; - private readonly IOrganizationLister _orgLister; - private readonly IActivityLog _log; - private readonly Account? _existing; + private readonly AccountsStore _store; - /// - /// For edits: fetches the account's stored token from the vault, called only when - /// a step actually needs to transmit it (validating on step 2 with the box left - /// empty). The token is never copied into , so the dialog never - /// holds or shows it; an empty box means "keep the stored token" (#32). - /// - private readonly Func? _storedToken; + private readonly IOrganizationLister _orgLister; - /// - /// A wizard for a new account seeded from , or — when - /// is given — an edit wizard seeded from that account. - /// fetches the vault's current token on demand for - /// an edit (null, or returning null, when the vault has no entry). - /// - public AccountWizardViewModel( - AccountsStore store, - IOrganizationLister orgLister, - AppSettings defaults, - Account? existing = null, - Func? storedToken = null, - IActivityLog? log = null) - : this(store, orgLister, existing, storedToken, log) - { - ArgumentNullException.ThrowIfNull(defaults); + private readonly IActivityLog _log; - if (existing is null) - { - Name = ""; - Description = ""; - Organization = ""; - TargetRoot = defaults.DefaultTargetFolder; - CreateOrgSubfolder = false; - MaxConcurrency = defaults.DefaultMaxConcurrency; - } - else - { - Name = existing.Name; - Description = existing.Description; - Organization = existing.Organization; - TargetRoot = existing.TargetRoot; - CreateOrgSubfolder = existing.CreateOrgSubfolder; - MaxConcurrency = existing.MaxConcurrency; - } - } + private readonly Account? _existing; - /// - /// A wizard for a NEW account seeded from a working Quick Sync connection - /// (): token, organization, folder, subfolder preference, - /// and parallelism are carried over so "save this connection as an account" is a - /// matter of naming it (#30). The token is treated as freshly typed, so it is - /// always written to the vault on save. - /// - public AccountWizardViewModel( - AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null) - : this(store, orgLister, existing: null, storedToken: null, log) - { - ArgumentNullException.ThrowIfNull(seed); - Name = ""; - Description = ""; - Token = seed.Token; - Organization = seed.Organization; - TargetRoot = seed.TargetRoot; - CreateOrgSubfolder = seed.CreateOrgSubfolder; - MaxConcurrency = seed.MaxConcurrency; - } + /// + /// For edits: fetches the account's stored token from the vault, called only when + /// a step actually needs to transmit it (validating on step 2 with the box left + /// empty). The token is never copied into , so the dialog never + /// holds or shows it; an empty box means "keep the stored token" (#32). + /// + private readonly Func? _storedToken; - private AccountWizardViewModel( - AccountsStore store, IOrganizationLister orgLister, Account? existing, Func? storedToken, IActivityLog? log) - { - ArgumentNullException.ThrowIfNull(store); - ArgumentNullException.ThrowIfNull(orgLister); - _store = store; - _orgLister = orgLister; - _log = log ?? new NullActivityLog(); - _existing = existing; - _storedToken = existing is null ? null : storedToken; - Step = 1; - NameError = ""; - TokenError = ""; - OrganizationError = ""; - TargetError = ""; - Token = ""; - Name = ""; - Description = ""; - Organization = ""; - TargetRoot = ""; - } - /// Current wizard step, 1 (identity) through 4 (destination). - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsFirstStep))] - [NotifyPropertyChangedFor(nameof(IsLastStep))] - public partial int Step { get; set; } + /// + /// A wizard for a new account seeded from , or — when + /// is given — an edit wizard seeded from that account. + /// fetches the vault's current token on demand for + /// an edit (null, or returning null, when the vault has no entry). + /// + public AccountWizardViewModel( + AccountsStore store, + IOrganizationLister orgLister, + AppSettings defaults, + Account? existing = null, + Func? storedToken = null, + IActivityLog? log = null) + : this(store, orgLister, existing, storedToken, log) + { + ArgumentNullException.ThrowIfNull(defaults); - /// Display name for the account; required and unique across accounts. - [ObservableProperty] - public partial string Name { get; set; } + if(existing is null) + { + Name = ""; + Description = ""; + Organization = ""; + TargetRoot = defaults.DefaultTargetFolder; + CreateOrgSubfolder = false; + MaxConcurrency = defaults.DefaultMaxConcurrency; + } + else + { + Name = existing.Name; + Description = existing.Description; + Organization = existing.Organization; + TargetRoot = existing.TargetRoot; + CreateOrgSubfolder = existing.CreateOrgSubfolder; + MaxConcurrency = existing.MaxConcurrency; + } + } - /// Optional free-form note about what the account is for. - [ObservableProperty] - public partial string Description { get; set; } - /// - /// GitHub personal access token as typed; validated when leaving step 2. For an - /// edit it starts empty and stays empty unless the user types a replacement — - /// the stored token is never loaded into it. - /// - [ObservableProperty] - public partial string Token { get; set; } - /// Organization (or user) to sync; picked from or typed. - [ObservableProperty] - public partial string Organization { get; set; } + /// + /// A wizard for a NEW account seeded from a working Quick Sync connection + /// (): token, organization, folder, subfolder preference, + /// and parallelism are carried over so "save this connection as an account" is a + /// matter of naming it (#30). The token is treated as freshly typed, so it is + /// always written to the vault on save. + /// + public AccountWizardViewModel( + AccountsStore store, IOrganizationLister orgLister, AccountWizardSeed seed, IActivityLog? log = null) + : this(store, orgLister, existing: null, storedToken: null, log) + { + ArgumentNullException.ThrowIfNull(seed); + Name = ""; + Description = ""; + Token = seed.Token; + Organization = seed.Organization; + TargetRoot = seed.TargetRoot; + CreateOrgSubfolder = seed.CreateOrgSubfolder; + MaxConcurrency = seed.MaxConcurrency; + } - /// Folder the sync targets; see . - [ObservableProperty] - public partial string TargetRoot { get; set; } - /// When set, clones land under TargetRoot\Organization rather than TargetRoot. - [ObservableProperty] - public partial bool CreateOrgSubfolder { get; set; } - /// Parallel clone/pull count; kept within the range. - [ObservableProperty] - public partial int MaxConcurrency { get; set; } + private AccountWizardViewModel( + AccountsStore store, IOrganizationLister orgLister, Account? existing, Func? storedToken, IActivityLog? log) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(orgLister); + _store = store; + _orgLister = orgLister; + _log = log ?? new NullActivityLog(); + _existing = existing; + _storedToken = existing is null ? null : storedToken; - /// True while step 2 is validating the token against the organization lister. - [ObservableProperty] - public partial bool IsValidatingToken { get; set; } + Step = 1; + NameError = ""; + TokenError = ""; + OrganizationError = ""; + TargetError = ""; + Token = ""; + Name = ""; + Description = ""; + Organization = ""; + TargetRoot = ""; + } - /// Step 1's validation message; empty when the name is acceptable. - [ObservableProperty] - public partial string NameError { get; set; } - /// Step 2's validation message; empty when the token was accepted. - [ObservableProperty] - public partial string TokenError { get; set; } - /// Step 3's validation message; empty once an organization is chosen. - [ObservableProperty] - public partial string OrganizationError { get; set; } + /// Current wizard step, 1 (identity) through 4 (destination). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsFirstStep))] + [NotifyPropertyChangedFor(nameof(IsLastStep))] + public partial int Step { get; set; } - /// Step 4's validation message; empty once a target folder is chosen. - [ObservableProperty] - public partial string TargetError { get; set; } + /// Display name for the account; required and unique across accounts. + [ObservableProperty] + public partial string Name { get; set; } - /// Organizations the validated token can see; feeds step 3's editable dropdown. - public ObservableCollection Organizations { get; } = new(); + /// Optional free-form note about what the account is for. + [ObservableProperty] + public partial string Description { get; set; } - /// True on step 1, where there is no step to go back to. - public bool IsFirstStep => Step == 1; + /// + /// GitHub personal access token as typed; validated when leaving step 2. For an + /// edit it starts empty and stays empty unless the user types a replacement — + /// the stored token is never loaded into it. + /// + [ObservableProperty] + public partial string Token { get; set; } - /// True on step 4, where advancing means saving instead of moving on. - public bool IsLastStep => Step == 4; + /// Organization (or user) to sync; picked from or typed. + [ObservableProperty] + public partial string Organization { get; set; } - /// True when the wizard edits an existing account rather than creating one. - public bool IsEditing => _existing is not null; + /// Folder the sync targets; see . + [ObservableProperty] + public partial string TargetRoot { get; set; } - /// Dialog title matching the wizard's mode. - public string Title => IsEditing ? "Edit account" : "Add account"; + /// When set, clones land under TargetRoot\Organization rather than TargetRoot. + [ObservableProperty] + public partial bool CreateOrgSubfolder { get; set; } - partial void OnMaxConcurrencyChanged(int value) - { - int clamped = Math.Clamp(value, AppSettings.MinConcurrency, AppSettings.MaxConcurrency); - if (clamped != value) - { - MaxConcurrency = clamped; - } - } + /// Parallel clone/pull count; kept within the range. + [ObservableProperty] + public partial int MaxConcurrency { get; set; } - /// - /// Validates the current step. Steps 1-3 advance and return true on success; step 4 - /// returns true without advancing (the host then calls ). On - /// failure the wizard stays put with the step's error message set - /// (, , , - /// ) — a silent no-op on Next reads as a broken button (#30). - /// - public async Task TryAdvanceAsync() - { - switch (Step) - { - case 1: - { - string name = Name.Trim(); - if (name.Length == 0) - { - NameError = "Enter a name for this account."; - return false; - } - Account? clash = _store.FindByName(name); - if (clash is not null && clash.Id != _existing?.Id) - { - NameError = $"An account named '{clash.Name}' already exists."; - return false; - } - NameError = ""; - Step = 2; - return true; - } - case 2: - { - // The lister is the validation: it fails on a rejected or rate-limited - // token and returns the organizations the dropdown offers otherwise. - // An edit with the box left empty validates the STORED token, fetched - // here and used for this one call only. - string candidate = Token.Trim(); - if (candidate.Length == 0 && IsEditing) - { - candidate = _storedToken?.Invoke()?.Trim() ?? ""; - if (candidate.Length == 0) - { - TokenError = "This account has no stored token. Enter one to continue."; - return false; - } - } + /// True while step 2 is validating the token against the organization lister. + [ObservableProperty] + public partial bool IsValidatingToken { get; set; } - IsValidatingToken = true; - _log.Info("Account wizard: validating the token."); - try - { - var organizations = await _orgLister.ListOrganizationsAsync(candidate); - Organizations.Clear(); - foreach (string organization in organizations) - { - Organizations.Add(organization); - } - TokenError = ""; - _log.Info($"Account wizard: token accepted; {organizations.Count} organizations and accounts visible."); - } - catch (Exception ex) - { - TokenError = ex.Message; - _log.Error($"Account wizard: token rejected: {ex.Message}", ex); - return false; - } - finally - { - IsValidatingToken = false; - } - Step = 3; - return true; - } - case 3: - if (string.IsNullOrWhiteSpace(Organization)) - { - // The dropdown allows free text, but not nothing. - OrganizationError = "Choose an organization from the list, or type one."; - return false; - } - OrganizationError = ""; - Step = 4; - return true; - default: - // Step 4: valid means "ready to save"; the host closes via SaveAsync. - if (string.IsNullOrWhiteSpace(TargetRoot)) - { - TargetError = "Choose a target folder."; - return false; - } - TargetError = ""; - return true; - } - } + /// Step 1's validation message; empty when the name is acceptable. + [ObservableProperty] + public partial string NameError { get; set; } - /// Returns to the previous step; a no-op on the first step. - public void GoBack() - { - if (!IsFirstStep) - { - Step--; - } - } + /// Step 2's validation message; empty when the token was accepted. + [ObservableProperty] + public partial string TokenError { get; set; } - /// - /// Persists the wizard's account: a new account gets a fresh id and always writes - /// its token to the vault; an edit keeps the existing id and last-sync fields and - /// touches the vault only when the token was changed. All string inputs are trimmed. - /// - public Task SaveAsync() - { - _store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null); - return Task.CompletedTask; - } + /// Step 3's validation message; empty once an organization is chosen. + [ObservableProperty] + public partial string OrganizationError { get; set; } - /// New accounts always persist their token; edits only when one was typed. - private bool TokenChanged => !IsEditing || Token.Trim().Length > 0; + /// Step 4's validation message; empty once a target folder is chosen. + [ObservableProperty] + public partial string TargetError { get; set; } - private Account BuildAccount() => new() - { - Id = _existing?.Id ?? Guid.NewGuid(), - Name = Name.Trim(), - Description = Description.Trim(), - Organization = Organization.Trim(), - TargetRoot = TargetRoot.Trim(), - CreateOrgSubfolder = CreateOrgSubfolder, - MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency), - LastSyncUtc = _existing?.LastSyncUtc, - LastSyncSummary = _existing?.LastSyncSummary, - }; + /// Organizations the validated token can see; feeds step 3's editable dropdown. + public ObservableCollection Organizations { get; } = new(); + + + + /// True on step 1, where there is no step to go back to. + public bool IsFirstStep => Step == 1; + + + + /// True on step 4, where advancing means saving instead of moving on. + public bool IsLastStep => Step == 4; + + + + /// True when the wizard edits an existing account rather than creating one. + public bool IsEditing => _existing is not null; + + + + /// Dialog title matching the wizard's mode. + public string Title => IsEditing ? "Edit account" : "Add account"; + + + + partial void OnMaxConcurrencyChanged(int value) + { + int clamped = Math.Clamp(value, AppSettings.MinConcurrency, AppSettings.MaxConcurrency); + if(clamped != value) + { + MaxConcurrency = clamped; + } + } + + + + /// + /// Validates the current step. Steps 1-3 advance and return true on success; step 4 + /// returns true without advancing (the host then calls ). On + /// failure the wizard stays put with the step's error message set + /// (, , , + /// ) — a silent no-op on Next reads as a broken button (#30). + /// + public async Task TryAdvanceAsync() + { + switch(Step) + { + case 1: + { + string name = Name.Trim(); + if(name.Length == 0) + { + NameError = "Enter a name for this account."; + return false; + } + Account? clash = _store.FindByName(name); + if(clash is not null && clash.Id != _existing?.Id) + { + NameError = $"An account named '{clash.Name}' already exists."; + return false; + } + NameError = ""; + Step = 2; + return true; + } + case 2: + { + // The lister is the validation: it fails on a rejected or rate-limited + // token and returns the organizations the dropdown offers otherwise. + // An edit with the box left empty validates the STORED token, fetched + // here and used for this one call only. + string candidate = Token.Trim(); + if(candidate.Length == 0 && IsEditing) + { + candidate = _storedToken?.Invoke()?.Trim() ?? ""; + if(candidate.Length == 0) + { + TokenError = "This account has no stored token. Enter one to continue."; + return false; + } + } + + IsValidatingToken = true; + _log.Info("Account wizard: validating the token."); + try + { + IReadOnlyList organizations = await _orgLister.ListOrganizationsAsync(candidate); + Organizations.Clear(); + foreach(string organization in organizations) + { + Organizations.Add(organization); + } + TokenError = ""; + _log.Info($"Account wizard: token accepted; {organizations.Count} organizations and accounts visible."); + } + catch(Exception ex) + { + TokenError = ex.Message; + _log.Error($"Account wizard: token rejected: {ex.Message}", ex); + return false; + } + finally + { + IsValidatingToken = false; + } + Step = 3; + return true; + } + case 3: + if(string.IsNullOrWhiteSpace(Organization)) + { + // The dropdown allows free text, but not nothing. + OrganizationError = "Choose an organization from the list, or type one."; + return false; + } + OrganizationError = ""; + Step = 4; + return true; + default: + // Step 4: valid means "ready to save"; the host closes via SaveAsync. + if(string.IsNullOrWhiteSpace(TargetRoot)) + { + TargetError = "Choose a target folder."; + return false; + } + TargetError = ""; + return true; + } + } + + + + /// Returns to the previous step; a no-op on the first step. + public void GoBack() + { + if(!IsFirstStep) + { + Step--; + } + } + + + + /// + /// Persists the wizard's account: a new account gets a fresh id and always writes + /// its token to the vault; an edit keeps the existing id and last-sync fields and + /// touches the vault only when the token was changed. All string inputs are trimmed. + /// + public Task SaveAsync() + { + _store.Save(BuildAccount(), TokenChanged ? Token.Trim() : null); + return Task.CompletedTask; + } + + + + /// New accounts always persist their token; edits only when one was typed. + private bool TokenChanged => !IsEditing || Token.Trim().Length > 0; + + + + private Account BuildAccount() => new() + { + Id = _existing?.Id ?? Guid.NewGuid(), + Name = Name.Trim(), + Description = Description.Trim(), + Organization = Organization.Trim(), + TargetRoot = TargetRoot.Trim(), + CreateOrgSubfolder = CreateOrgSubfolder, + MaxConcurrency = Math.Clamp(MaxConcurrency, AppSettings.MinConcurrency, AppSettings.MaxConcurrency), + LastSyncUtc = _existing?.LastSyncUtc, + LastSyncSummary = _existing?.LastSyncSummary, + }; } diff --git a/gclo.ViewModels/AccountsJsonContext.cs b/gclo.ViewModels/AccountsJsonContext.cs new file mode 100644 index 0000000..af1733b --- /dev/null +++ b/gclo.ViewModels/AccountsJsonContext.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.ViewModels; + + +/// +/// Source-generated serializer context so account (de)serialization keeps working in +/// trimmed Release publishes without reflection warnings. +/// +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(List))] +internal sealed partial class AccountsJsonContext : JsonSerializerContext +{ +} diff --git a/gclo.ViewModels/AccountsStore.cs b/gclo.ViewModels/AccountsStore.cs index 277ee3c..f47f5b9 100644 --- a/gclo.ViewModels/AccountsStore.cs +++ b/gclo.ViewModels/AccountsStore.cs @@ -1,9 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.Json; -using System.Text.Json.Serialization; using gclo.Engine; + namespace gclo.ViewModels; + /// /// Persists account profiles as JSON at accounts.json under /// (default %LOCALAPPDATA%\gclo) and their @@ -41,306 +47,318 @@ namespace gclo.ViewModels; /// public sealed class AccountsStore { - private readonly ITokenVault _vault; - private readonly IActivityLog _log; - private readonly string _filePath; - private readonly object _gate = new(); - private List _accounts; + private readonly ITokenVault _vault; - /// - /// Loads existing accounts from (default: - /// ). A missing file simply means no accounts yet; - /// a corrupt or unreadable one is logged (pass the app's to - /// surface that) and treated as empty rather than blocking startup. - /// - public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null) - { - ArgumentNullException.ThrowIfNull(vault); - _vault = vault; - _log = log ?? new NullActivityLog(); - directory ??= GcloPaths.DataRoot; - _filePath = Path.Combine(directory, "accounts.json"); - _accounts = Load(); - } + private readonly IActivityLog _log; - /// All accounts, sorted by (case-insensitive). - public IReadOnlyList GetAll() - { - lock (_gate) - { - return _accounts.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase).ToList(); - } - } + private readonly string _filePath; - /// The account whose name matches case-insensitively, or null. - public Account? FindByName(string name) - { - lock (_gate) - { - return _accounts.FirstOrDefault( - a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); - } - } + private readonly Lock _gate = new(); - /// - /// Inserts or updates (matching by ) and persists - /// immediately. Throws when another account - /// already uses the name (case-insensitive); nothing is persisted in that case. - /// A non-null is written to the vault BEFORE the - /// metadata; if the metadata write then fails, the vault is put back the way it - /// was (previous token restored, or the new entry removed) and the failure - /// propagates. A null token leaves any existing vault entry untouched. - /// See the class remarks for the full consistency contract. - /// - public void Save(Account account, string? token) - { - ArgumentNullException.ThrowIfNull(account); - lock (_gate) - { - bool nameTaken = _accounts.Any(a => - a.Id != account.Id - && string.Equals(a.Name, account.Name, StringComparison.OrdinalIgnoreCase)); - if (nameTaken) - { - throw new ArgumentException( - $"An account named '{account.Name}' already exists.", nameof(account)); - } + private List _accounts; - var updated = new List(_accounts); - int index = updated.FindIndex(a => a.Id == account.Id); - if (index >= 0) - { - updated[index] = account; - } - else - { - updated.Add(account); - } - string verb = index >= 0 ? "updated" : "added"; - if (token is null) - { - Persist(updated); // IO failures propagate; _accounts stays unchanged then. - _accounts = updated; - _log.Info($"Account '{account.Name}' {verb} (token unchanged)."); - return; - } - // Vault first: a failing Store leaves the vault exactly as it was (the - // previous token, if any, is still there), and nothing else has changed. - string? previousToken = _vault.TryRetrieve(account.Id); - _vault.Store(account.Id, token); + /// + /// Loads existing accounts from (default: + /// ). A missing file simply means no accounts yet; + /// a corrupt or unreadable one is logged (pass the app's to + /// surface that) and treated as empty rather than blocking startup. + /// + public AccountsStore(ITokenVault vault, string? directory = null, IActivityLog? log = null) + { + ArgumentNullException.ThrowIfNull(vault); + _vault = vault; + _log = log ?? new NullActivityLog(); + directory ??= GcloPaths.DataRoot; + _filePath = Path.Combine(directory, "accounts.json"); + _accounts = Load(); + } - try - { - Persist(updated); - } - catch (Exception persistFailure) - { - // Undo the vault write so no entry points at metadata that was never saved. - try - { - if (previousToken is null) - { - _vault.Delete(account.Id); - } - else - { - _vault.Store(account.Id, previousToken); - } - } - catch (Exception compensationFailure) - { - string leftover = previousToken is null - ? $"the vault holds a token for account {account.Id:N} ('{account.Name}') that is not in accounts.json; " - + "saving the account again overwrites it, deleting the account removes it" - : $"the vault holds the NEW token for account {account.Id:N} ('{account.Name}') while accounts.json " - + "still has the previous metadata; saving the account again reconciles both"; - _log.Error($"Account save failed and the vault could not be restored: {leftover}.", compensationFailure); - throw new AccountConsistencyException( - $"Saving account '{account.Name}' failed ({persistFailure.Message}), and restoring the token vault " - + $"failed too ({compensationFailure.Message}). Current state: {leftover}.", - persistFailure, compensationFailure); - } - throw; - } + /// All accounts, sorted by (case-insensitive). + public IReadOnlyList GetAll() + { + lock(_gate) + { + return _accounts.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + + + /// The account whose name matches case-insensitively, or null. + public Account? FindByName(string name) + { + lock(_gate) + { + return _accounts.FirstOrDefault( + a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); + } + } + + + + /// + /// Inserts or updates (matching by ) and persists + /// immediately. Throws when another account + /// already uses the name (case-insensitive); nothing is persisted in that case. + /// A non-null is written to the vault BEFORE the + /// metadata; if the metadata write then fails, the vault is put back the way it + /// was (previous token restored, or the new entry removed) and the failure + /// propagates. A null token leaves any existing vault entry untouched. + /// See the class remarks for the full consistency contract. + /// + public void Save(Account account, string? token) + { + ArgumentNullException.ThrowIfNull(account); + lock(_gate) + { + bool nameTaken = _accounts.Any(a => + a.Id != account.Id + && string.Equals(a.Name, account.Name, StringComparison.OrdinalIgnoreCase)); + if(nameTaken) + { + throw new ArgumentException( + $"An account named '{account.Name}' already exists.", nameof(account)); + } + + var updated = new List(_accounts); + int index = updated.FindIndex(a => a.Id == account.Id); + if(index >= 0) + { + updated[index] = account; + } + else + { + updated.Add(account); + } + + string verb = index >= 0 ? "updated" : "added"; + if(token is null) + { + Persist(updated); // IO failures propagate; _accounts stays unchanged then. _accounts = updated; - _log.Info($"Account '{account.Name}' {verb} with a new token."); - } - } + _log.Info($"Account '{account.Name}' {verb} (token unchanged)."); + return; + } - /// - /// Removes the account's vault token and then its metadata; an unknown id is a - /// no-op. A vault failure changes nothing. If the metadata write fails after the - /// token was removed, the token is stored again and the failure propagates, so a - /// listed account never silently loses its credential and a token is never left - /// behind without its account. See the class remarks for the full contract. - /// - public void Delete(Guid id) - { - lock (_gate) - { - var remaining = _accounts.Where(a => a.Id != id).ToList(); - if (remaining.Count == _accounts.Count) - { - return; - } - string name = _accounts.First(a => a.Id == id).Name; + // Vault first: a failing Store leaves the vault exactly as it was (the + // previous token, if any, is still there), and nothing else has changed. + string? previousToken = _vault.TryRetrieve(account.Id); + _vault.Store(account.Id, token); - // Vault first: a failing Delete leaves both stores untouched. Keep the - // token in memory only long enough to put it back if the file write fails. - string? removedToken = _vault.TryRetrieve(id); - _vault.Delete(id); - - try - { - Persist(remaining); - } - catch (Exception persistFailure) - { - if (removedToken is null) - { - throw; // there was no token to restore; the account is simply still listed - } - - try - { - _vault.Store(id, removedToken); - } - catch (Exception compensationFailure) - { - string leftover = $"account {id:N} is still listed in accounts.json but its token is gone from the vault; " - + "deleting it again completes the removal, or edit it and enter the token again to keep it"; - _log.Error($"Account delete failed and the token could not be restored: {leftover}.", compensationFailure); - throw new AccountConsistencyException( - $"Deleting the account failed ({persistFailure.Message}), and restoring its token failed too " - + $"({compensationFailure.Message}). Current state: {leftover}.", - persistFailure, compensationFailure); - } - - throw; - } - - _accounts = remaining; - _log.Info($"Account '{name}' deleted" + (removedToken is null ? " (it had no token)." : " along with its token.")); - } - } - - /// - /// Stamps the outcome of a finished sync onto the account and persists it; every - /// other field is left untouched. An unknown id (account deleted while its sync - /// ran) is logged and ignored. - /// - public void RecordSyncResult(Guid id, DateTimeOffset lastSyncUtc, string summary) - { - lock (_gate) - { - int index = _accounts.FindIndex(a => a.Id == id); - if (index < 0) - { - _log.Error($"Cannot record a sync result: no account with id {id:N}."); - return; - } - - var updated = new List(_accounts) - { - [index] = _accounts[index] with { LastSyncUtc = lastSyncUtc, LastSyncSummary = summary }, - }; + try + { Persist(updated); - _accounts = updated; - _log.Info($"Account '{updated[index].Name}': sync result recorded ({summary})."); - } - } - - private List Load() - { - try - { - if (!File.Exists(_filePath)) + } + catch(Exception persistFailure) + { + // Undo the vault write so no entry points at metadata that was never saved. + try { - return []; + if(previousToken is null) + { + _vault.Delete(account.Id); + } + else + { + _vault.Store(account.Id, previousToken); + } + } + catch(Exception compensationFailure) + { + string leftover = previousToken is null + ? $"the vault holds a token for account {account.Id:N} ('{account.Name}') that is not in accounts.json; " + + "saving the account again overwrites it, deleting the account removes it" + : $"the vault holds the NEW token for account {account.Id:N} ('{account.Name}') while accounts.json " + + "still has the previous metadata; saving the account again reconciles both"; + _log.Error($"Account save failed and the vault could not be restored: {leftover}.", compensationFailure); + throw new AccountConsistencyException( + $"Saving account '{account.Name}' failed ({persistFailure.Message}), and restoring the token vault " + + $"failed too ({compensationFailure.Message}). Current state: {leftover}.", + persistFailure, compensationFailure); } - var loaded = JsonSerializer.Deserialize( - File.ReadAllText(_filePath), AccountsJsonContext.Default.ListAccount); - if (loaded is not null) + throw; + } + + _accounts = updated; + _log.Info($"Account '{account.Name}' {verb} with a new token."); + } + } + + + + /// + /// Removes the account's vault token and then its metadata; an unknown id is a + /// no-op. A vault failure changes nothing. If the metadata write fails after the + /// token was removed, the token is stored again and the failure propagates, so a + /// listed account never silently loses its credential and a token is never left + /// behind without its account. See the class remarks for the full contract. + /// + public void Delete(Guid id) + { + lock(_gate) + { + var remaining = _accounts.Where(a => a.Id != id).ToList(); + if(remaining.Count == _accounts.Count) + { + return; + } + string name = _accounts.First(a => a.Id == id).Name; + + // Vault first: a failing Delete leaves both stores untouched. Keep the + // token in memory only long enough to put it back if the file write fails. + string? removedToken = _vault.TryRetrieve(id); + _vault.Delete(id); + + try + { + Persist(remaining); + } + catch(Exception persistFailure) + { + if(removedToken is null) { - return loaded; + throw; // there was no token to restore; the account is simply still listed } - _log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts."); - PreserveCorruptFile(); - } - catch (Exception ex) - { - _log.Error($"Failed to load accounts from '{_filePath}'; starting with no accounts.", ex); - PreserveCorruptFile(); - } - return []; - } - - /// - /// Moves an unreadable accounts file aside before the empty in-memory list can be - /// persisted over it: the metadata is what associates vault entries with accounts, - /// so clobbering a recoverable file would orphan every stored token. - /// - private void PreserveCorruptFile() - { - try - { - if (File.Exists(_filePath)) + try { - string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"; - File.Move(_filePath, aside, overwrite: true); - _log.Error($"Preserved unreadable accounts file as '{aside}'."); + _vault.Store(id, removedToken); + } + catch(Exception compensationFailure) + { + string leftover = $"account {id:N} is still listed in accounts.json but its token is gone from the vault; " + + "deleting it again completes the removal, or edit it and enter the token again to keep it"; + _log.Error($"Account delete failed and the token could not be restored: {leftover}.", compensationFailure); + throw new AccountConsistencyException( + $"Deleting the account failed ({persistFailure.Message}), and restoring its token failed too " + + $"({compensationFailure.Message}). Current state: {leftover}.", + persistFailure, compensationFailure); } - } - catch (Exception ex) - { - _log.Error("Could not preserve the unreadable accounts file.", ex); - } - } - private void Persist(List accounts) - { - string? directory = Path.GetDirectoryName(_filePath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } + throw; + } - // Atomic replace: a crash or full disk mid-write must never truncate the live - // file — corrupted metadata orphans every vault token (accounts are found by - // Guid, and recreated accounts mint new ones). - string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount); - string tempPath = _filePath + ".tmp"; - using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) - using (var writer = new StreamWriter(stream)) - { - writer.Write(json); - writer.Flush(); - stream.Flush(flushToDisk: true); - } + _accounts = remaining; + _log.Info($"Account '{name}' deleted" + (removedToken is null ? " (it had no token)." : " along with its token.")); + } + } - if (File.Exists(_filePath)) - { - File.Replace(tempPath, _filePath, _filePath + ".bak"); - } - else - { - File.Move(tempPath, _filePath); - } - } -} - -/// -/// Source-generated serializer context so account (de)serialization keeps working in -/// trimmed Release publishes without reflection warnings. -/// -[JsonSourceGenerationOptions(WriteIndented = true)] -[JsonSerializable(typeof(List))] -internal sealed partial class AccountsJsonContext : JsonSerializerContext -{ + + + /// + /// Stamps the outcome of a finished sync onto the account and persists it; every + /// other field is left untouched. An unknown id (account deleted while its sync + /// ran) is logged and ignored. + /// + public void RecordSyncResult(Guid id, DateTimeOffset lastSyncUtc, string summary) + { + lock(_gate) + { + int index = _accounts.FindIndex(a => a.Id == id); + if(index < 0) + { + _log.Error($"Cannot record a sync result: no account with id {id:N}."); + return; + } + + var updated = new List(_accounts) + { + [index] = _accounts[index] with { LastSyncUtc = lastSyncUtc, LastSyncSummary = summary }, + }; + Persist(updated); + _accounts = updated; + _log.Info($"Account '{updated[index].Name}': sync result recorded ({summary})."); + } + } + + + + private List Load() + { + try + { + if(!File.Exists(_filePath)) + { + return []; + } + + List? loaded = JsonSerializer.Deserialize( + File.ReadAllText(_filePath), AccountsJsonContext.Default.ListAccount); + if(loaded is not null) + { + return loaded; + } + _log.Error($"Accounts file '{_filePath}' deserialized to null; starting with no accounts."); + PreserveCorruptFile(); + } + catch(Exception ex) + { + _log.Error($"Failed to load accounts from '{_filePath}'; starting with no accounts.", ex); + PreserveCorruptFile(); + } + + return []; + } + + + + /// + /// Moves an unreadable accounts file aside before the empty in-memory list can be + /// persisted over it: the metadata is what associates vault entries with accounts, + /// so clobbering a recoverable file would orphan every stored token. + /// + private void PreserveCorruptFile() + { + try + { + if(File.Exists(_filePath)) + { + string aside = $"{_filePath}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"; + File.Move(_filePath, aside, overwrite: true); + _log.Error($"Preserved unreadable accounts file as '{aside}'."); + } + } + catch(Exception ex) + { + _log.Error("Could not preserve the unreadable accounts file.", ex); + } + } + + + + private void Persist(List accounts) + { + string? directory = Path.GetDirectoryName(_filePath); + if(!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Atomic replace: a crash or full disk mid-write must never truncate the live + // file — corrupted metadata orphans every vault token (accounts are found by + // Guid, and recreated accounts mint new ones). + string json = JsonSerializer.Serialize(accounts, AccountsJsonContext.Default.ListAccount); + string tempPath = _filePath + ".tmp"; + using(var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + using(var writer = new StreamWriter(stream)) + { + writer.Write(json); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + if(File.Exists(_filePath)) + { + File.Replace(tempPath, _filePath, _filePath + ".bak"); + } + else + { + File.Move(tempPath, _filePath); + } + } } diff --git a/gclo.ViewModels/AnnouncementEventArgs.cs b/gclo.ViewModels/AnnouncementEventArgs.cs new file mode 100644 index 0000000..4adbe13 --- /dev/null +++ b/gclo.ViewModels/AnnouncementEventArgs.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// The message behind : text +/// that assistive technology should read out (a per-repository failure, for example). +/// +public sealed class AnnouncementEventArgs : EventArgs +{ + /// The text to announce. + public AnnouncementEventArgs(string message) + { + ArgumentNullException.ThrowIfNull(message); + Message = message; + } + + + + /// The text to announce. + public string Message { get; } +} diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index e529493..4db55ce 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -1,8 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Text.Json; -using System.Text.Json.Serialization; + namespace gclo.ViewModels; + /// /// User preferences persisted as JSON at settings.json under /// (default %LOCALAPPDATA%\gclo, overridable via @@ -11,114 +17,115 @@ namespace gclo.ViewModels; /// public sealed class AppSettings { - public const int MinConcurrency = 1; - public const int MaxConcurrency = 64; - public const int DefaultConcurrency = 8; + public const int MinConcurrency = 1; - public const int MinSplashMilliseconds = 500; - public const int MaxSplashMilliseconds = 30_000; - public const int DefaultSplashMilliseconds = 5_000; + public const int MaxConcurrency = 64; - /// - /// Vault id under which the optional default GitHub token is stored. The token - /// itself lives only in the (Credential Manager) — - /// never in this settings file. - /// - public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"); + public const int DefaultConcurrency = 8; - /// Folder pre-filled as the sync target. Empty means "no default". - public string DefaultTargetFolder { get; set; } = ""; + public const int MinSplashMilliseconds = 500; - /// Default parallel clone/pull count; clamped to 1..64 on load and save. - public int DefaultMaxConcurrency { get; set; } = DefaultConcurrency; + public const int MaxSplashMilliseconds = 30_000; - /// App theme: "System", "Light", or "Dark". Unknown values fall back to "System". - public string Theme { get; set; } = "System"; + public const int DefaultSplashMilliseconds = 5_000; - /// Whether the branded splash overlay shows at startup. - public bool ShowSplashScreen { get; set; } = true; + /// + /// Vault id under which the optional default GitHub token is stored. The token + /// itself lives only in the (Credential Manager) — + /// never in this settings file. + /// + public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"); - /// How long the splash stays up before fading, clamped to 500..30000 ms. - public int SplashMilliseconds { get; set; } = DefaultSplashMilliseconds; + /// Folder pre-filled as the sync target. Empty means "no default". + public string DefaultTargetFolder { get; set; } = ""; - private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json"); + /// Default parallel clone/pull count; clamped to 1..64 on load and save. + public int DefaultMaxConcurrency { get; set; } = DefaultConcurrency; - /// - /// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file - /// yields defaults, and loaded values are sanitized (concurrency clamped, theme validated). - /// - public static AppSettings Load() - { - try - { - string path = SettingsPath; - if (File.Exists(path)) + /// App theme: "System", "Light", or "Dark". Unknown values fall back to "System". + public string Theme { get; set; } = "System"; + + /// Whether the branded splash overlay shows at startup. + public bool ShowSplashScreen { get; set; } = true; + + /// How long the splash stays up before fading, clamped to 500..30000 ms. + public int SplashMilliseconds { get; set; } = DefaultSplashMilliseconds; + + + + private static string SettingsPath => Path.Combine(GcloPaths.DataRoot, "settings.json"); + + + + /// + /// Loads settings from disk. Never throws: a missing, corrupt, or unreadable file + /// yields defaults, and loaded values are sanitized (concurrency clamped, theme validated). + /// + public static AppSettings Load() + { + try + { + string path = SettingsPath; + if(File.Exists(path)) + { + AppSettings? loaded = JsonSerializer.Deserialize( + File.ReadAllText(path), AppSettingsJsonContext.Default.AppSettings); + if(loaded is not null) { - var loaded = JsonSerializer.Deserialize( - File.ReadAllText(path), AppSettingsJsonContext.Default.AppSettings); - if (loaded is not null) - { - loaded.Sanitize(); - return loaded; - } + loaded.Sanitize(); + return loaded; } - } - catch - { - // Settings must never prevent startup; fall through to defaults. - } + } + } + catch + { + // Settings must never prevent startup; fall through to defaults. + } - return new AppSettings(); - } - - /// - /// Persists settings to disk, creating the directory if needed. Failures (locked file, - /// read-only profile, full disk, ...) are swallowed: losing a preference write must - /// never crash the app. - /// - public void Save() - { - try - { - Sanitize(); - string path = SettingsPath; - string? directory = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - File.WriteAllText(path, JsonSerializer.Serialize(this, AppSettingsJsonContext.Default.AppSettings)); - } - catch - { - // Best effort only. - } - } + return new AppSettings(); + } - private void Sanitize() - { - // The JSON deserializer can assign null despite the non-nullable annotation. - DefaultTargetFolder ??= ""; - DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency); - // 0 means the field was absent (settings.json written by an older version): - // migrate to the default rather than clamping an unset value to the minimum. - SplashMilliseconds = SplashMilliseconds == 0 - ? DefaultSplashMilliseconds - : Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds); - if (Theme is not ("System" or "Light" or "Dark")) - { - Theme = "System"; - } - } -} -/// -/// Source-generated serializer context so settings (de)serialization keeps working in -/// trimmed Release publishes without reflection warnings. -/// -[JsonSourceGenerationOptions(WriteIndented = true)] -[JsonSerializable(typeof(AppSettings))] -internal sealed partial class AppSettingsJsonContext : JsonSerializerContext -{ + /// + /// Persists settings to disk, creating the directory if needed. Failures (locked file, + /// read-only profile, full disk, ...) are swallowed: losing a preference write must + /// never crash the app. + /// + public void Save() + { + try + { + Sanitize(); + string path = SettingsPath; + string? directory = Path.GetDirectoryName(path); + if(!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + File.WriteAllText(path, JsonSerializer.Serialize(this, AppSettingsJsonContext.Default.AppSettings)); + } + catch + { + // Best effort only. + } + } + + + + private void Sanitize() + { + // The JSON deserializer can assign null despite the non-nullable annotation. + DefaultTargetFolder ??= ""; + DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency); + // 0 means the field was absent (settings.json written by an older version): + // migrate to the default rather than clamping an unset value to the minimum. + SplashMilliseconds = SplashMilliseconds == 0 + ? DefaultSplashMilliseconds + : Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds); + if(Theme is not ("System" or "Light" or "Dark")) + { + Theme = "System"; + } + } } diff --git a/gclo.ViewModels/AppSettingsJsonContext.cs b/gclo.ViewModels/AppSettingsJsonContext.cs new file mode 100644 index 0000000..c83ffd6 --- /dev/null +++ b/gclo.ViewModels/AppSettingsJsonContext.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Text.Json.Serialization; + + +namespace gclo.ViewModels; + + +/// +/// Source-generated serializer context so settings (de)serialization keeps working in +/// trimmed Release publishes without reflection warnings. +/// +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(AppSettings))] +internal sealed partial class AppSettingsJsonContext : JsonSerializerContext +{ +} diff --git a/gclo.ViewModels/CredentialManagerVault.cs b/gclo.ViewModels/CredentialManagerVault.cs index a268027..f35850c 100644 --- a/gclo.ViewModels/CredentialManagerVault.cs +++ b/gclo.ViewModels/CredentialManagerVault.cs @@ -1,11 +1,18 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.ComponentModel; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; + namespace gclo.ViewModels; + /// /// An backed by the Windows Credential Manager: each token /// is a generic credential in the per-user store (persisted across reboots) whose @@ -24,182 +31,225 @@ namespace gclo.ViewModels; [SupportedOSPlatform("windows")] public sealed class CredentialManagerVault : ITokenVault { - private const uint CredTypeGeneric = 1; // CRED_TYPE_GENERIC - private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot - private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND + private const uint CredTypeGeneric = 1; // CRED_TYPE_GENERIC - /// Target-name scope for this process's data root; null for the default root. - private readonly string? _scope; + private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot - /// - /// Fails fast on platforms without a Credential Manager. Targets are scoped to the - /// current (unscoped for the default root). - /// - public CredentialManagerVault() - : this(OperatingSystem.IsWindows(), ScopeFor(GcloPaths.DataRoot, GcloPaths.DefaultDataRoot)) - { - } + private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND - /// Test seam: the platform check and the scope are parameters so every arm is coverable. - internal CredentialManagerVault(bool isWindows, string? scope = null) - { - if (!isWindows) - { - throw new PlatformNotSupportedException( - "Account token storage requires the Windows Credential Manager."); - } - _scope = scope; - } + /// Target-name scope for this process's data root; null for the default root. + private readonly string? _scope; - /// - /// The scope segment for a data root: null when it is the default root (existing - /// "gclo:account:<id>" entries keep working), otherwise 16 hex characters of the - /// SHA-256 of the full, case-folded path with any trailing separators removed, so - /// "C:\data", "c:\data\" and "C:/data" all share one scope. - /// - internal static string? ScopeFor(string dataRoot, string defaultDataRoot) - { - string normalized = Normalize(dataRoot); - if (normalized == Normalize(defaultDataRoot)) - { - return null; - } - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); - return Convert.ToHexString(hash, 0, 8).ToLowerInvariant(); - static string Normalize(string path) - => Path.GetFullPath(path).Replace('/', '\\').TrimEnd('\\').ToUpperInvariant(); - } - /// The Credential Manager target name for an account under a scope. - internal static string TargetName(Guid accountId, string? scope) - => scope is null ? $"gclo:account:{accountId:N}" : $"gclo:{scope}:account:{accountId:N}"; + /// + /// Fails fast on platforms without a Credential Manager. Targets are scoped to the + /// current (unscoped for the default root). + /// + public CredentialManagerVault() + : this(OperatingSystem.IsWindows(), ScopeFor(GcloPaths.DataRoot, GcloPaths.DefaultDataRoot)) + { + } - /// - public void Store(Guid accountId, string token) - { - ArgumentNullException.ThrowIfNull(token); - byte[] blob = Encoding.Unicode.GetBytes(token); - var pinned = GCHandle.Alloc(blob, GCHandleType.Pinned); - try - { - var credential = new CredentialW - { - Type = CredTypeGeneric, - TargetName = TargetName(accountId), - CredentialBlobSize = (uint)blob.Length, - CredentialBlob = pinned.AddrOfPinnedObject(), - Persist = CredPersistLocalMachine, - UserName = "gclo", - }; - if (!CredWriteW(ref credential, 0)) - { - int error = Marshal.GetLastWin32Error(); - throw new Win32Exception( - error, $"CredWriteW failed (error {error}) storing the token for account {accountId:N}."); - } - } - finally - { - // The array is pinned, so the GC cannot have copied it elsewhere: clearing - // it removes the one deterministic plaintext copy of the token from the - // heap (crash dumps, pagefile) before unpinning. - Array.Clear(blob); - pinned.Free(); - } - } + /// Test seam: the platform check and the scope are parameters so every arm is coverable. + internal CredentialManagerVault(bool isWindows, string? scope = null) + { + if(!isWindows) + { + throw new PlatformNotSupportedException( + "Account token storage requires the Windows Credential Manager."); + } + _scope = scope; + } - /// - public string? TryRetrieve(Guid accountId) => TryRetrieve(TargetName(accountId), accountId); - /// Test seam: an invalid target name makes the non-not-found error arm coverable. - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", "CA1822:Mark members as static", - Justification = "Instance seam: tests call it on a constructed vault; static would be a CS0176.")] - internal string? TryRetrieve(string targetName, Guid accountId) - { - if (!CredReadW(targetName, CredTypeGeneric, 0, out IntPtr credentialPtr)) - { + + /// + /// The scope segment for a data root: null when it is the default root (existing + /// "gclo:account:<id>" entries keep working), otherwise 16 hex characters of the + /// SHA-256 of the full, case-folded path with any trailing separators removed, so + /// "C:\data", "c:\data\" and "C:/data" all share one scope. + /// + internal static string? ScopeFor(string dataRoot, string defaultDataRoot) + { + string normalized = Normalize(dataRoot); + if(string.Equals(normalized, Normalize(defaultDataRoot), StringComparison.Ordinal)) + { + return null; + } + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(hash, 0, 8).ToLowerInvariant(); + + static string Normalize(string path) + => Path.GetFullPath(path).Replace('/', '\\').TrimEnd('\\').ToUpperInvariant(); + } + + + + /// The Credential Manager target name for an account under a scope. + internal static string TargetName(Guid accountId, string? scope) + => scope is null ? $"gclo:account:{accountId:N}" : $"gclo:{scope}:account:{accountId:N}"; + + + + /// + public void Store(Guid accountId, string token) + { + ArgumentNullException.ThrowIfNull(token); + + byte[] blob = Encoding.Unicode.GetBytes(token); + var pinned = GCHandle.Alloc(blob, GCHandleType.Pinned); + try + { + var credential = new CredentialW + { + Type = CredTypeGeneric, + TargetName = TargetName(accountId), + CredentialBlobSize = (uint)blob.Length, + CredentialBlob = pinned.AddrOfPinnedObject(), + Persist = CredPersistLocalMachine, + UserName = "gclo", + }; + + if(!CredWriteW(ref credential, 0)) + { int error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) - { - return null; - } throw new Win32Exception( - error, $"CredReadW failed (error {error}) reading the token for account {accountId:N}."); - } + error, $"CredWriteW failed (error {error}) storing the token for account {accountId:N}."); + } + } + finally + { + // The array is pinned, so the GC cannot have copied it elsewhere: clearing + // it removes the one deterministic plaintext copy of the token from the + // heap (crash dumps, pagefile) before unpinning. + Array.Clear(blob); + pinned.Free(); + } + } - try - { - var credential = Marshal.PtrToStructure(credentialPtr); - return credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0 - ? "" - : Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2); - } - finally - { - CredFree(credentialPtr); - } - } - /// - public void Delete(Guid accountId) => Delete(TargetName(accountId), accountId); - /// Test seam: an invalid target name makes the non-not-found error arm coverable. - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Performance", "CA1822:Mark members as static", - Justification = "Instance seam: tests call it on a constructed vault; static would be a CS0176.")] - internal void Delete(string targetName, Guid accountId) - { - if (!CredDeleteW(targetName, CredTypeGeneric, 0)) - { - int error = Marshal.GetLastWin32Error(); - if (error != ErrorNotFound) - { - throw new Win32Exception( - error, $"CredDeleteW failed (error {error}) deleting the token for account {accountId:N}."); - } - } - } + /// + public string? TryRetrieve(Guid accountId) => TryRetrieve(TargetName(accountId), accountId); - private string TargetName(Guid accountId) => TargetName(accountId, _scope); - /// - /// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout - /// both fixes the field order for marshaling and tells the compiler the unread - /// fields (filled by CredReadW) are intentional. - /// - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct CredentialW - { - public uint Flags; - public uint Type; - [MarshalAs(UnmanagedType.LPWStr)] public string? TargetName; - [MarshalAs(UnmanagedType.LPWStr)] public string? Comment; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; - public uint CredentialBlobSize; - public IntPtr CredentialBlob; - public uint Persist; - public uint AttributeCount; - public IntPtr Attributes; - [MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias; - [MarshalAs(UnmanagedType.LPWStr)] public string? UserName; - } - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CredWriteW(ref CredentialW credential, uint flags); + /// Test seam: an invalid target name makes the non-not-found error arm coverable. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", "CA1822:Mark members as static", + Justification = "Instance seam: tests call it on a constructed vault; static would be a CS0176.")] + internal string? TryRetrieve(string targetName, Guid accountId) + { + if(!CredReadW(targetName, CredTypeGeneric, 0, out IntPtr credentialPtr)) + { + int error = Marshal.GetLastWin32Error(); + if(error == ErrorNotFound) + { + return null; + } + throw new Win32Exception( + error, $"CredReadW failed (error {error}) reading the token for account {accountId:N}."); + } - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential); + try + { + CredentialW credential = Marshal.PtrToStructure(credentialPtr); + return credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0 + ? "" + : Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2); + } + finally + { + CredFree(credentialPtr); + } + } - [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool CredDeleteW(string targetName, uint type, uint flags); - [DllImport("advapi32.dll", ExactSpelling = true)] - private static extern void CredFree(IntPtr buffer); + + /// + public void Delete(Guid accountId) => Delete(TargetName(accountId), accountId); + + + + /// Test seam: an invalid target name makes the non-not-found error arm coverable. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", "CA1822:Mark members as static", + Justification = "Instance seam: tests call it on a constructed vault; static would be a CS0176.")] + internal void Delete(string targetName, Guid accountId) + { + if(!CredDeleteW(targetName, CredTypeGeneric, 0)) + { + int error = Marshal.GetLastWin32Error(); + if(error != ErrorNotFound) + { + throw new Win32Exception( + error, $"CredDeleteW failed (error {error}) deleting the token for account {accountId:N}."); + } + } + } + + + + private string TargetName(Guid accountId) => TargetName(accountId, _scope); + + + + /// + /// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout + /// both fixes the field order for marshaling and tells the compiler the unread + /// fields (filled by CredReadW) are intentional. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CredentialW + { + public uint Flags; + + public uint Type; + + [MarshalAs(UnmanagedType.LPWStr)] public string? TargetName; + + [MarshalAs(UnmanagedType.LPWStr)] public string? Comment; + + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + + public uint CredentialBlobSize; + + public IntPtr CredentialBlob; + + public uint Persist; + + public uint AttributeCount; + + public IntPtr Attributes; + + [MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias; + + [MarshalAs(UnmanagedType.LPWStr)] public string? UserName; + } + + + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredWriteW(ref CredentialW credential, uint flags); + + + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential); + + + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredDeleteW(string targetName, uint type, uint flags); + + + + [DllImport("advapi32.dll", ExactSpelling = true)] + private static extern void CredFree(IntPtr buffer); } diff --git a/gclo.ViewModels/GcloPaths.cs b/gclo.ViewModels/GcloPaths.cs index 08884a7..a798e9a 100644 --- a/gclo.ViewModels/GcloPaths.cs +++ b/gclo.ViewModels/GcloPaths.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// /// The single seam for where gclo keeps its per-user data (settings, accounts, /// logs). Defaults to %LOCALAPPDATA%\gclo; the GCLO_DATA_DIR environment variable @@ -9,17 +15,19 @@ namespace gclo.ViewModels; /// public static class GcloPaths { - /// - /// Root directory for gclo's per-user data: GCLO_DATA_DIR when set and - /// non-empty, otherwise %LOCALAPPDATA%\gclo. Read on every access so a - /// value set before launch always wins over any cached default. - /// - public static string DataRoot => - Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir - ? dir - : DefaultDataRoot; + /// + /// Root directory for gclo's per-user data: GCLO_DATA_DIR when set and + /// non-empty, otherwise %LOCALAPPDATA%\gclo. Read on every access so a + /// value set before launch always wins over any cached default. + /// + public static string DataRoot => + Environment.GetEnvironmentVariable("GCLO_DATA_DIR") is { Length: > 0 } dir + ? dir + : DefaultDataRoot; - /// %LOCALAPPDATA%\gclo: the root used when GCLO_DATA_DIR is not set. - public static string DefaultDataRoot => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo"); + + + /// %LOCALAPPDATA%\gclo: the root used when GCLO_DATA_DIR is not set. + public static string DefaultDataRoot => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "gclo"); } diff --git a/gclo.ViewModels/ITokenVault.cs b/gclo.ViewModels/ITokenVault.cs index 000976b..3c54616 100644 --- a/gclo.ViewModels/ITokenVault.cs +++ b/gclo.ViewModels/ITokenVault.cs @@ -1,5 +1,11 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// /// Secure storage for account access tokens, keyed by account id. Tokens must never /// reach accounts.json or the activity log; implementations keep them in a platform @@ -8,18 +14,22 @@ namespace gclo.ViewModels; /// public interface ITokenVault { - /// - /// Stores the token for an account, overwriting any existing entry. Throws on - /// failure — silently losing a token would leave the account unusable without - /// any warning to the user. A failed call must leave the vault as it was (the - /// previous entry, if any, still readable): relies on - /// that to keep an account's working credential when a replacement write fails. - /// - void Store(Guid accountId, string token); + /// + /// Stores the token for an account, overwriting any existing entry. Throws on + /// failure — silently losing a token would leave the account unusable without + /// any warning to the user. A failed call must leave the vault as it was (the + /// previous entry, if any, still readable): relies on + /// that to keep an account's working credential when a replacement write fails. + /// + void Store(Guid accountId, string token); - /// The stored token, or null when the vault has no entry for the account. - string? TryRetrieve(Guid accountId); - /// Removes the account's token. Deleting an absent entry is a no-op. - void Delete(Guid accountId); + + /// The stored token, or null when the vault has no entry for the account. + string? TryRetrieve(Guid accountId); + + + + /// Removes the account's token. Deleting an absent entry is a no-op. + void Delete(Guid accountId); } diff --git a/gclo.ViewModels/InMemoryVault.cs b/gclo.ViewModels/InMemoryVault.cs index c76d9ca..b343196 100644 --- a/gclo.ViewModels/InMemoryVault.cs +++ b/gclo.ViewModels/InMemoryVault.cs @@ -1,27 +1,40 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Collections.Concurrent; + namespace gclo.ViewModels; + /// /// Thread-safe in-memory . Used by tests, and available for /// scenarios where tokens must not outlive the process. /// public sealed class InMemoryVault : ITokenVault { - private readonly ConcurrentDictionary _tokens = new(); + private readonly ConcurrentDictionary _tokens = new(); - /// - public void Store(Guid accountId, string token) - { - ArgumentNullException.ThrowIfNull(token); - _tokens[accountId] = token; - } - /// - public string? TryRetrieve(Guid accountId) - => _tokens.TryGetValue(accountId, out string? token) ? token : null; - /// - public void Delete(Guid accountId) - => _tokens.TryRemove(accountId, out _); + /// + public void Store(Guid accountId, string token) + { + ArgumentNullException.ThrowIfNull(token); + _tokens[accountId] = token; + } + + + + /// + public string? TryRetrieve(Guid accountId) + => _tokens.TryGetValue(accountId, out string? token) ? token : null; + + + + /// + public void Delete(Guid accountId) + => _tokens.TryRemove(accountId, out _); } diff --git a/gclo.ViewModels/PathRecoveryDecision.cs b/gclo.ViewModels/PathRecoveryDecision.cs index 8718e22..1888d4d 100644 --- a/gclo.ViewModels/PathRecoveryDecision.cs +++ b/gclo.ViewModels/PathRecoveryDecision.cs @@ -1,21 +1,32 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.ViewModels; + /// /// What the user chose in the path-recovery UI for a repository whose tree has /// Windows-invalid paths. Null (no decision) means the dialog was dismissed. /// public abstract record PathRecoveryDecision { - private PathRecoveryDecision() { } + private PathRecoveryDecision() { } - /// Rename/skip the offending paths and check the repository out on Windows. - public sealed record Apply(PathRecovery Recovery) : PathRecoveryDecision; - /// - /// Leave the Windows copy alone and clone the repository inside WSL instead, where - /// the paths are legal (#8). Only offered when . - /// - public sealed record CloneInWsl : PathRecoveryDecision; + + /// Rename/skip the offending paths and check the repository out on Windows. + public sealed record Apply(PathRecovery Recovery) : PathRecoveryDecision; + + + + /// + /// Leave the Windows copy alone and clone the repository inside WSL instead, where + /// the paths are legal (#8). Only offered when . + /// + public sealed record CloneInWsl : PathRecoveryDecision; } diff --git a/gclo.ViewModels/PathRecoveryPlanner.cs b/gclo.ViewModels/PathRecoveryPlanner.cs index 703d84c..bc0078c 100644 --- a/gclo.ViewModels/PathRecoveryPlanner.cs +++ b/gclo.ViewModels/PathRecoveryPlanner.cs @@ -1,7 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.ViewModels; + /// /// Turns the per-row choices of the path-recovery UI (rename to this, or skip) into a /// , and reports the choices that would make the apply fail @@ -11,55 +18,59 @@ namespace gclo.ViewModels; /// public static class PathRecoveryPlanner { - /// One row's choice. - /// The invalid repository path, as reported. - /// The full replacement path, or null when the name was left as is. - /// True to omit the file or folder from the checkout. - public sealed record Choice(string RepoPath, string? ReplacementPath, bool Skip); + /// One row's choice. + /// The invalid repository path, as reported. + /// The full replacement path, or null when the name was left as is. + /// True to omit the file or folder from the checkout. + public sealed record Choice(string RepoPath, string? ReplacementPath, bool Skip); - /// The recovery to apply plus the reasons it would still fail, if any. - /// Renames and skips, as the engine consumes them. - /// Human-readable problems, one per offending row; empty when the plan is sound. - public sealed record Plan(PathRecovery Recovery, IReadOnlyList Problems); - /// - /// Builds the plan. A row that is neither skipped nor renamed is always a problem - /// (every listed path is invalid by definition), and replacement names go through - /// the same Windows rules as a checkout would, including two rows renamed onto one - /// destination. - /// - public static Plan Build(IReadOnlyList choices) - { - ArgumentNullException.ThrowIfNull(choices); - // Git paths are case-sensitive, so both collections compare ordinally. The same - // repo path can appear in two rows (e.g. an invalid segment that also collides - // by case), which is why renames assign via the indexer instead of Add. - var renames = new Dictionary(StringComparer.Ordinal); - var skipped = new HashSet(StringComparer.Ordinal); - var problems = new List(); + /// The recovery to apply plus the reasons it would still fail, if any. + /// Renames and skips, as the engine consumes them. + /// Human-readable problems, one per offending row; empty when the plan is sound. + public sealed record Plan(PathRecovery Recovery, IReadOnlyList Problems); - foreach (Choice choice in choices) - { - if (choice.Skip) - { - skipped.Add(choice.RepoPath); - } - else if (choice.ReplacementPath is { } replacement) - { - renames[choice.RepoPath] = replacement; - } - else - { - problems.Add($"'{choice.RepoPath}' is still invalid: rename it or mark it Skip."); - } - } - foreach (InvalidPathInfo stillInvalid in WindowsPathValidator.ValidatePaths(renames.Values)) - { - problems.Add($"'{stillInvalid.RepoPath}' cannot be used: {stillInvalid.Reason}."); - } - return new Plan(new PathRecovery(renames, skipped), problems); - } + /// + /// Builds the plan. A row that is neither skipped nor renamed is always a problem + /// (every listed path is invalid by definition), and replacement names go through + /// the same Windows rules as a checkout would, including two rows renamed onto one + /// destination. + /// + public static Plan Build(IReadOnlyList choices) + { + ArgumentNullException.ThrowIfNull(choices); + + // Git paths are case-sensitive, so both collections compare ordinally. The same + // repo path can appear in two rows (e.g. an invalid segment that also collides + // by case), which is why renames assign via the indexer instead of Add. + var renames = new Dictionary(StringComparer.Ordinal); + var skipped = new HashSet(StringComparer.Ordinal); + var problems = new List(); + + foreach(Choice choice in choices) + { + if(choice.Skip) + { + skipped.Add(choice.RepoPath); + } + else if(choice.ReplacementPath is { } replacement) + { + renames[choice.RepoPath] = replacement; + } + else + { + problems.Add($"'{choice.RepoPath}' is still invalid: rename it or mark it Skip."); + } + } + + foreach(InvalidPathInfo stillInvalid in WindowsPathValidator.ValidatePaths(renames.Values)) + { + problems.Add($"'{stillInvalid.RepoPath}' cannot be used: {stillInvalid.Reason}."); + } + + return new Plan(new PathRecovery(renames, skipped), problems); + } } diff --git a/gclo.ViewModels/RepoFilter.cs b/gclo.ViewModels/RepoFilter.cs index 79ec8b5..3912f3a 100644 --- a/gclo.ViewModels/RepoFilter.cs +++ b/gclo.ViewModels/RepoFilter.cs @@ -1,17 +1,23 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// Which repository rows the workspace table shows. public enum RepoFilter { - /// Every loaded repository. - All, + /// Every loaded repository. + All, - /// Rows with a git operation in flight (cloning or pulling). - Active, + /// Rows with a git operation in flight (cloning or pulling). + Active, - /// Rows whose last run failed. - Failed, + /// Rows whose last run failed. + Failed, - /// Rows still queued and waiting for a worker. - Pending, + /// Rows still queued and waiting for a worker. + Pending, } diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index b26c2dc..00fd06d 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -1,120 +1,165 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using CommunityToolkit.Mvvm.ComponentModel; using gclo.Engine; + namespace gclo.ViewModels; + /// One row in the repository table: the descriptor plus selection and live sync state. public sealed partial class RepoItemViewModel : ObservableObject { - public RepoItemViewModel(RepoDescriptor descriptor) - { - ArgumentNullException.ThrowIfNull(descriptor); - Descriptor = descriptor; - Status = SyncStatus.Queued; - IsSelected = true; - } + public RepoItemViewModel(RepoDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + Descriptor = descriptor; + Status = SyncStatus.Queued; + IsSelected = true; + } - /// The repository this row represents; never changes after construction. - public RepoDescriptor Descriptor { get; } - /// Repository name; used as the local folder name. - public string Name => Descriptor.Name; - /// Whether this repository participates in the next sync. - [ObservableProperty] - public partial bool IsSelected { get; set; } + /// The repository this row represents; never changes after construction. + public RepoDescriptor Descriptor { get; } - [ObservableProperty] - public partial SyncStatus Status { get; set; } - [ObservableProperty] - public partial string? Error { get; set; } - [ObservableProperty] - public partial double? Percent { get; set; } + /// Repository name; used as the local folder name. + public string Name => Descriptor.Name; - /// - /// The Windows-invalid paths that made the last sync fail, when that was the cause - /// (set from the engine's failure progress payload); null for any other failure. - /// - [ObservableProperty] - public partial IReadOnlyList? InvalidPaths { get; set; } - /// True when the row failed because of Windows-invalid paths and can be resolved. - public bool HasPathIssue => InvalidPaths is { Count: > 0 }; - /// Default branch name, or empty for an empty repository. - public string BranchText => Descriptor.DefaultBranch ?? ""; + /// Whether this repository participates in the next sync. + [ObservableProperty] + public partial bool IsSelected { get; set; } - /// Whether the repository is archived (still cloneable, read-only). - public bool IsArchived => Descriptor.IsArchived; + [ObservableProperty] + public partial SyncStatus Status { get; set; } - /// True while a git operation is in flight and a progress bar should show. - public bool ShowProgress => Status is SyncStatus.Cloning or SyncStatus.Pulling; + [ObservableProperty] + public partial string? Error { get; set; } - /// True when progress has no percentage (pulls report none). - public bool IsIndeterminate => Status == SyncStatus.Pulling; + [ObservableProperty] + public partial double? Percent { get; set; } - /// Clone progress in [0, 1] for determinate progress bars. - public double ProgressValue => Percent ?? 0; + /// + /// The Windows-invalid paths that made the last sync fail, when that was the cause + /// (set from the engine's failure progress payload); null for any other failure. + /// + [ObservableProperty] + public partial IReadOnlyList? InvalidPaths { get; set; } - /// Human-readable status, including clone percentage when known. - public string StatusText => Status switch - { - SyncStatus.Queued => "Queued", - SyncStatus.Cloning when Percent is double p => $"Cloning {p * 100:0}%", - SyncStatus.Cloning => "Cloning", - SyncStatus.Pulling => "Pulling", - SyncStatus.Done => "Done", - SyncStatus.Failed => "Failed", - SyncStatus.Canceled => "Canceled", - _ => Status.ToString(), - }; - /// True when there is an error message to show inline. - public bool HasError => !string.IsNullOrEmpty(Error); - /// - /// A non-error remark shown under the row, e.g. where a repository was cloned when - /// it lives outside the target folder (in WSL, #8). Cleared by the next run. - /// - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(HasNote))] - [NotifyPropertyChangedFor(nameof(HasDetails))] - public partial string? Note { get; set; } + /// True when the row failed because of Windows-invalid paths and can be resolved. + public bool HasPathIssue => InvalidPaths is { Count: > 0 }; - /// True when there is a remark to show inline. - public bool HasNote => !string.IsNullOrEmpty(Note); - /// True when the row has anything to show under its cells (error, note, or both). - public bool HasDetails => HasError || HasNote; - partial void OnStatusChanged(SyncStatus value) - { - OnPropertyChanged(nameof(StatusText)); - OnPropertyChanged(nameof(ShowProgress)); - OnPropertyChanged(nameof(IsIndeterminate)); - } + /// Default branch name, or empty for an empty repository. + public string BranchText => Descriptor.DefaultBranch ?? ""; - /// - /// Re-raises the status change notification without a status change, so views - /// that derive theme-dependent values from (the row's status - /// brush) re-evaluate — used when the application theme changes mid-session. - /// - public void RefreshPresentation() => OnPropertyChanged(nameof(Status)); - partial void OnPercentChanged(double? value) - { - OnPropertyChanged(nameof(StatusText)); - OnPropertyChanged(nameof(ProgressValue)); - } - partial void OnErrorChanged(string? value) - { - OnPropertyChanged(nameof(HasError)); - OnPropertyChanged(nameof(HasDetails)); - } + /// Whether the repository is archived (still cloneable, read-only). + public bool IsArchived => Descriptor.IsArchived; - partial void OnInvalidPathsChanged(IReadOnlyList? value) - => OnPropertyChanged(nameof(HasPathIssue)); + + + /// True while a git operation is in flight and a progress bar should show. + public bool ShowProgress => Status is SyncStatus.Cloning or SyncStatus.Pulling; + + + + /// True when progress has no percentage (pulls report none). + public bool IsIndeterminate => Status == SyncStatus.Pulling; + + + + /// Clone progress in [0, 1] for determinate progress bars. + public double ProgressValue => Percent ?? 0; + + + + /// Human-readable status, including clone percentage when known. + public string StatusText => Status switch + { + SyncStatus.Queued => "Queued", + SyncStatus.Cloning when Percent is double p => $"Cloning {p * 100:0}%", + SyncStatus.Cloning => "Cloning", + SyncStatus.Pulling => "Pulling", + SyncStatus.Done => "Done", + SyncStatus.Failed => "Failed", + SyncStatus.Canceled => "Canceled", + _ => Status.ToString(), + }; + + + + /// True when there is an error message to show inline. + public bool HasError => !string.IsNullOrEmpty(Error); + + + + /// + /// A non-error remark shown under the row, e.g. where a repository was cloned when + /// it lives outside the target folder (in WSL, #8). Cleared by the next run. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNote))] + [NotifyPropertyChangedFor(nameof(HasDetails))] + public partial string? Note { get; set; } + + + + /// True when there is a remark to show inline. + public bool HasNote => !string.IsNullOrEmpty(Note); + + + + /// True when the row has anything to show under its cells (error, note, or both). + public bool HasDetails => HasError || HasNote; + + + + partial void OnStatusChanged(SyncStatus value) + { + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(ShowProgress)); + OnPropertyChanged(nameof(IsIndeterminate)); + } + + + + /// + /// Re-raises the status change notification without a status change, so views + /// that derive theme-dependent values from (the row's status + /// brush) re-evaluate — used when the application theme changes mid-session. + /// + public void RefreshPresentation() => OnPropertyChanged(nameof(Status)); + + + + partial void OnPercentChanged(double? value) + { + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(ProgressValue)); + } + + + + partial void OnErrorChanged(string? value) + { + OnPropertyChanged(nameof(HasError)); + OnPropertyChanged(nameof(HasDetails)); + } + + + + partial void OnInvalidPathsChanged(IReadOnlyList? value) + => OnPropertyChanged(nameof(HasPathIssue)); } diff --git a/gclo.ViewModels/RunResultKind.cs b/gclo.ViewModels/RunResultKind.cs index 68750f2..18365c4 100644 --- a/gclo.ViewModels/RunResultKind.cs +++ b/gclo.ViewModels/RunResultKind.cs @@ -1,20 +1,26 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + namespace gclo.ViewModels; + /// Outcome of the most recent sync run; drives the results InfoBar's severity. public enum RunResultKind { - /// No result to show: nothing has run yet, or a new run/load reset it. - None, + /// No result to show: nothing has run yet, or a new run/load reset it. + None, - /// The run finished and every repository succeeded. - Success, + /// The run finished and every repository succeeded. + Success, - /// The run finished but at least one repository failed. - PartialFailure, + /// The run finished but at least one repository failed. + PartialFailure, - /// The run was canceled before every repository finished. - Canceled, + /// The run was canceled before every repository finished. + Canceled, - /// The run itself faulted (for example, the target root could not be created). - Error, + /// The run itself faulted (for example, the target root could not be created). + Error, } diff --git a/gclo.ViewModels/SyncAllAccountState.cs b/gclo.ViewModels/SyncAllAccountState.cs new file mode 100644 index 0000000..e6809d0 --- /dev/null +++ b/gclo.ViewModels/SyncAllAccountState.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// Where one account stands within a sync-all pass; feeds the sidebar badges. +public enum SyncAllAccountState +{ + /// Scheduled in this pass, waiting its turn. + Queued, + + /// The one account whose load/sync is in flight right now. + Running, + + /// Finished with no failed repositories. + Succeeded, + + /// Finished, but at least one repository failed. + Failed, + + /// Not run: busy, unloadable, nothing to sync, or the queue was canceled. + Skipped, +} diff --git a/gclo.ViewModels/SyncAllCoordinator.cs b/gclo.ViewModels/SyncAllCoordinator.cs index 5d2a706..6c47481 100644 --- a/gclo.ViewModels/SyncAllCoordinator.cs +++ b/gclo.ViewModels/SyncAllCoordinator.cs @@ -1,33 +1,13 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; + namespace gclo.ViewModels; -/// -/// Outcome of one sync-all pass over the account workspaces. -/// -/// Accounts whose sync ran to completion (successfully or not). -/// Accounts skipped because they were busy, could not load, or had nothing to sync. -/// True when cancellation stopped the queue before every account was processed. -public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled); - -/// Where one account stands within a sync-all pass; feeds the sidebar badges. -public enum SyncAllAccountState -{ - /// Scheduled in this pass, waiting its turn. - Queued, - - /// The one account whose load/sync is in flight right now. - Running, - - /// Finished with no failed repositories. - Succeeded, - - /// Finished, but at least one repository failed. - Failed, - - /// Not run: busy, unloadable, nothing to sync, or the queue was canceled. - Skipped, -} /// /// Runs every account workspace's sync strictly one at a time, in the order given. @@ -39,104 +19,108 @@ public enum SyncAllAccountState /// Sink for per-account start/finish/skip events. Never receives tokens. public sealed class SyncAllCoordinator(IActivityLog log) { - private readonly IActivityLog _log = log ?? throw new ArgumentNullException(nameof(log)); + private readonly IActivityLog _log = log ?? throw new ArgumentNullException(nameof(log)); - /// - /// Per-account queue-state announcements for this pass, keyed by the workspace's - /// account id. Every scheduled account is announced Queued up front, Running when - /// its turn starts, then exactly one terminal state (Succeeded, Failed, Skipped). - /// Raised on the caller's context; the view maps these to sidebar badges. - /// - public Action? AccountStateChanged { get; set; } + /// + /// Per-account queue-state announcements for this pass, keyed by the workspace's + /// account id. Every scheduled account is announced Queued up front, Running when + /// its turn starts, then exactly one terminal state (Succeeded, Failed, Skipped). + /// Raised on the caller's context; the view maps these to sidebar badges. + /// + public Action? AccountStateChanged { get; set; } - /// - /// Syncs each workspace of in list order, - /// loading its repositories first when needed. A workspace is skipped (counted, - /// logged, never fatal) when it is already busy, when its repositories cannot be - /// loaded, or when its sync command cannot run. Canceling - /// stops the queue after the in-flight account rather than canceling its sync. - /// - public async Task RunAsync( - IReadOnlyList accountWorkspaces, CancellationToken ct) - { - ArgumentNullException.ThrowIfNull(accountWorkspaces); - foreach (WorkspaceViewModel workspace in accountWorkspaces) - { - Announce(workspace, SyncAllAccountState.Queued); - } - _log.Info($"Sync all: queued {accountWorkspaces.Count} accounts: " - + string.Join(", ", accountWorkspaces.Select(w => $"'{w.DisplayName}'")) + "."); - int ran = 0; - int skipped = 0; + /// + /// Syncs each workspace of in list order, + /// loading its repositories first when needed. A workspace is skipped (counted, + /// logged, never fatal) when it is already busy, when its repositories cannot be + /// loaded, or when its sync command cannot run. Canceling + /// stops the queue after the in-flight account rather than canceling its sync. + /// + public async Task RunAsync( + IReadOnlyList accountWorkspaces, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(accountWorkspaces); - for (int i = 0; i < accountWorkspaces.Count; i++) - { - WorkspaceViewModel workspace = accountWorkspaces[i]; + foreach(WorkspaceViewModel workspace in accountWorkspaces) + { + Announce(workspace, SyncAllAccountState.Queued); + } + _log.Info($"Sync all: queued {accountWorkspaces.Count} accounts: " + + string.Join(", ", accountWorkspaces.Select(w => $"'{w.DisplayName}'")) + "."); - if (ct.IsCancellationRequested) + int ran = 0; + int skipped = 0; + + for(int i = 0; i < accountWorkspaces.Count; i++) + { + WorkspaceViewModel workspace = accountWorkspaces[i]; + + if(ct.IsCancellationRequested) + { + // Unstarted accounts leave the pass as Skipped badges, but the result's + // Skipped count keeps its meaning: accounts actively skipped, not the + // abandoned remainder of a canceled queue. + for(int rest = i; rest < accountWorkspaces.Count; rest++) { - // Unstarted accounts leave the pass as Skipped badges, but the result's - // Skipped count keeps its meaning: accounts actively skipped, not the - // abandoned remainder of a canceled queue. - for (int rest = i; rest < accountWorkspaces.Count; rest++) - { - Announce(accountWorkspaces[rest], SyncAllAccountState.Skipped); - } - _log.Info("Sync all: canceled — remaining accounts were not started."); - return new SyncAllResult(ran, skipped, WasCanceled: true); + Announce(accountWorkspaces[rest], SyncAllAccountState.Skipped); } + _log.Info("Sync all: canceled — remaining accounts were not started."); + return new SyncAllResult(ran, skipped, WasCanceled: true); + } - if (workspace.IsRunning || workspace.IsLoadingRepos || workspace.IsResolvingPaths) + if(workspace.IsRunning || workspace.IsLoadingRepos || workspace.IsResolvingPaths) + { + skipped++; + Announce(workspace, SyncAllAccountState.Skipped); + _log.Info($"Sync all: skipped '{workspace.DisplayName}' — it is busy."); + continue; + } + + Announce(workspace, SyncAllAccountState.Running); + + if(!workspace.HasLoadedRepos) + { + _log.Info($"Sync all: loading repositories for '{workspace.DisplayName}'."); + await workspace.LoadReposCommand.ExecuteAsync(null); + if(!workspace.HasLoadedRepos) { - skipped++; - Announce(workspace, SyncAllAccountState.Skipped); - _log.Info($"Sync all: skipped '{workspace.DisplayName}' — it is busy."); - continue; + skipped++; + Announce(workspace, SyncAllAccountState.Skipped); + _log.Info( + $"Sync all: skipped '{workspace.DisplayName}' — its repositories could not be loaded."); + continue; } + } - Announce(workspace, SyncAllAccountState.Running); + if(!workspace.SyncCommand.CanExecute(null)) + { + skipped++; + Announce(workspace, SyncAllAccountState.Skipped); + _log.Info($"Sync all: skipped '{workspace.DisplayName}' — it has nothing to sync."); + continue; + } - if (!workspace.HasLoadedRepos) - { - _log.Info($"Sync all: loading repositories for '{workspace.DisplayName}'."); - await workspace.LoadReposCommand.ExecuteAsync(null); - if (!workspace.HasLoadedRepos) - { - skipped++; - Announce(workspace, SyncAllAccountState.Skipped); - _log.Info( - $"Sync all: skipped '{workspace.DisplayName}' — its repositories could not be loaded."); - continue; - } - } + _log.Info($"Sync all: syncing '{workspace.DisplayName}'."); + await workspace.SyncCommand.ExecuteAsync(null); + ran++; + Announce( + workspace, + workspace.HasFailedRepos ? SyncAllAccountState.Failed : SyncAllAccountState.Succeeded); + _log.Info($"Sync all: finished '{workspace.DisplayName}'."); + } - if (!workspace.SyncCommand.CanExecute(null)) - { - skipped++; - Announce(workspace, SyncAllAccountState.Skipped); - _log.Info($"Sync all: skipped '{workspace.DisplayName}' — it has nothing to sync."); - continue; - } + return new SyncAllResult(ran, skipped, WasCanceled: false); + } - _log.Info($"Sync all: syncing '{workspace.DisplayName}'."); - await workspace.SyncCommand.ExecuteAsync(null); - ran++; - Announce( - workspace, - workspace.HasFailedRepos ? SyncAllAccountState.Failed : SyncAllAccountState.Succeeded); - _log.Info($"Sync all: finished '{workspace.DisplayName}'."); - } - return new SyncAllResult(ran, skipped, WasCanceled: false); - } - private void Announce(WorkspaceViewModel workspace, SyncAllAccountState state) - { - if (workspace.AccountId is Guid id) - { - AccountStateChanged?.Invoke(id, state); - } - } + private void Announce(WorkspaceViewModel workspace, SyncAllAccountState state) + { + if(workspace.AccountId is Guid id) + { + AccountStateChanged?.Invoke(id, state); + } + } } diff --git a/gclo.ViewModels/SyncAllResult.cs b/gclo.ViewModels/SyncAllResult.cs new file mode 100644 index 0000000..abb92ce --- /dev/null +++ b/gclo.ViewModels/SyncAllResult.cs @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// Outcome of one sync-all pass over the account workspaces. +/// +/// Accounts whose sync ran to completion (successfully or not). +/// Accounts skipped because they were busy, could not load, or had nothing to sync. +/// True when cancellation stopped the queue before every account was processed. +public sealed record SyncAllResult(int Ran, int Skipped, bool WasCanceled); diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index bc019f8..4dca06c 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -1,11 +1,18 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Collections.ObjectModel; using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using gclo.Engine; + namespace gclo.ViewModels; + /// /// Drives one workspace of the org-sync UI in two phases: /// fills the repository table, then clones or updates the @@ -16,1220 +23,1358 @@ namespace gclo.ViewModels; /// public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { - private readonly IRepositoryLister _lister; - private readonly IGitClient _git; - private readonly IWslCloner _wsl; - - /// Result of the one-time WSL probe; null until the first path recovery asks for it. - private WslAvailability? _wslAvailability; - private readonly IOrganizationLister _orgLister; - private readonly Func, IProgress> _progressFactory; - private readonly TimeSpan _orgLookupDebounce; - private readonly IActivityLog _log; - private readonly Account? _account; - private readonly AccountsStore? _accountsStore; - private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase); - private CancellationTokenSource? _orgLoadCts; - - /// - /// Position of every row in ; rebuilt on load and sort. Lets - /// progress-driven membership changes find a row's slot in - /// by binary search instead of rebuilding the whole bound list (#30). - /// - private readonly Dictionary _reposIndex = new(); - - /// Mirror of for O(1) membership checks. - private readonly HashSet _filteredSet = new(); - - /// Canceled on dispose so an in-flight path recovery dies with the workspace. - private readonly CancellationTokenSource _lifetimeCts = new(); - - /// - /// Runs one sync pass; the real by default. A test seam: - /// the engine swallows cancellation into a summary, so the only way to exercise the - /// view model's own handling is to inject a - /// runner that throws it. - /// - internal Func, IProgress, CancellationToken, Task>? SyncRunner - { - get; - set; - } - - /// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other. - private bool _syncingSelection; - - /// False until the constructor finished seeding properties; gates user-action logging. - private readonly bool _constructed; - - /// First repository name from the last load; makes concrete. - private string? _sampleRepoName; - - /// - /// Rows captured for the in-flight (or most recent) sync run. Progress is scoped to - /// this set (#22): / describe the - /// run, not the whole table. Empty until the first run and reset by each load. - /// - private IReadOnlyList _runSet = []; - - /// - /// Production dependencies by default; pass fakes for testing. The default progress - /// factory is , which marshals via the SynchronizationContext - /// captured at construction (the UI thread in the app); tests inject a synchronous one. - /// A non-null seeds the workspace from that profile — its - /// organization, target folder, subfolder preference, and concurrency — and pulls the - /// token from (the Token setter's org lookup fires - /// naturally); finished syncs are then recorded on . - /// - public WorkspaceViewModel( - IRepositoryLister? lister = null, - IGitClient? git = null, - IOrganizationLister? orgLister = null, - Func, IProgress>? progressFactory = null, - TimeSpan? orgLookupDebounce = null, - IActivityLog? log = null, - Account? account = null, - ITokenVault? tokenVault = null, - AccountsStore? accountsStore = null, - IWslCloner? wsl = null) - { - _lister = lister ?? new GitHubRepositoryLister(); - _git = git ?? new LibGit2GitClient(); - _wsl = wsl ?? new WslCloner(); - _orgLister = orgLister ?? new GitHubOrganizationLister(); - _progressFactory = progressFactory ?? (handler => new Progress(handler)); - _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); - _log = log ?? new FileActivityLog(); - _account = account; - _accountsStore = accountsStore; - Organization = ""; - Token = ""; - TargetFolder = ""; - NameFilter = ""; - BranchFilter = ""; - MaxConcurrency = AppSettings.DefaultConcurrency; - StatusText = ""; - ResultMessage = ""; - LoadErrorTitle = ""; - LoadErrorMessage = ""; - AllSelected = true; - CanEditInputs = true; - - if (account is not null) - { - Organization = account.Organization; - TargetFolder = account.TargetRoot; - CreateOrgSubfolder = account.CreateOrgSubfolder; - MaxConcurrency = account.MaxConcurrency; - // The Token setter's existing org lookup fires naturally with the vault token. - Token = tokenVault?.TryRetrieve(account.Id) ?? ""; - } - _constructed = true; - } - - /// Id of the account this workspace was created for, or null for Quick Sync. - public Guid? AccountId => _account?.Id; - - /// Name shown for this workspace in navigation: the account's name, or "Quick Sync". - public string DisplayName => _account?.Name ?? "Quick Sync"; - - public ObservableCollection Repos { get; } = new(); - - /// - /// The rows the table shows: the subset of matching - /// , in the table's current sort order. Rebuilt when the filter - /// changes, a load completes, a run starts or ends, and on terminal row transitions. - /// - public ObservableCollection FilteredRepos { get; } = new(); - - /// - /// Rows with a git operation in flight right now; feeds the pinned active strip. - /// Maintained incrementally from progress reports, so its size is bounded by - /// . Cleared when a run starts and again when it ends. - /// - public ObservableCollection ActiveRepos { get; } = new(); - - /// Organizations discovered from the current token; feeds the org dropdown. - public ObservableCollection Organizations { get; } = new(); - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] - public partial string Organization { get; set; } - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] - public partial string Token { get; set; } - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SyncCommand))] - public partial string TargetFolder { get; set; } - - [ObservableProperty] - public partial int MaxConcurrency { get; set; } - - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(SyncCommand))] - [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] - [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] - [NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))] - public partial bool IsRunning { get; set; } - - [ObservableProperty] - public partial string StatusText { get; set; } - - [ObservableProperty] - public partial int CompletedCount { get; set; } - - [ObservableProperty] - public partial int TotalCount { get; set; } - - [ObservableProperty] - public partial bool IsLoadingOrgs { get; set; } - - /// True while is listing repositories. - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] - [NotifyCanExecuteChangedFor(nameof(SyncCommand))] - [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] - public partial bool IsLoadingRepos { get; set; } - - /// - /// True while a path-recovery checkout runs. Recovery writes a repository's working - /// tree outside a sync run, so every other git entry point is locked out for its - /// duration — two concurrent operations on one directory corrupt it. - /// - [ObservableProperty] - [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] - [NotifyCanExecuteChangedFor(nameof(SyncCommand))] - [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] - [NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))] - public partial bool IsResolvingPaths { get; set; } - - /// When set, repositories are placed under TargetFolder\Organization. - [ObservableProperty] - public partial bool CreateOrgSubfolder { get; set; } - - /// - /// Header checkbox state: setting it checks or unchecks every VISIBLE row (the rows - /// in ), and it reads true when every visible row is - /// selected. With no filter active that is every row; with a filter it makes - /// "filter, then select what you see" the subset workflow (#30). Hidden rows keep - /// their selection and still sync. - /// - [ObservableProperty] - public partial bool AllSelected { get; set; } - - /// - /// Organization whose repositories the table currently holds (trimmed, as loaded); - /// null before the first successful load. - /// - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsTableStale))] - [NotifyPropertyChangedFor(nameof(StaleTableMessage))] - public partial string? LoadedOrganization { get; set; } - - /// - /// True when was edited after a load: the table still - /// shows the previous organization's rows while the chip and target path describe - /// the new one. Sync is blocked until a reload replaces the rows (#30). - /// - public bool IsTableStale - => LoadedOrganization is not null - && !string.Equals(Organization.Trim(), LoadedOrganization, StringComparison.OrdinalIgnoreCase); - - /// One-line explanation shown while ; empty otherwise. - public string StaleTableMessage - => IsTableStale - ? $"The table still shows '{LoadedOrganization}'. Reload to list '{Organization.Trim()}' before syncing." - : ""; - - /// Title of the error surface for a failed load or organization lookup. - [ObservableProperty] - public partial string LoadErrorTitle { get; set; } - - /// Message of the most recent failed load or organization lookup; empty when none. - [ObservableProperty] - public partial string LoadErrorMessage { get; set; } - - /// - /// True while the load-error surface is showing. Set by a failed load or - /// organization lookup, cleared when the next load starts or a lookup succeeds; the - /// page two-way binds it so the user can dismiss the bar (#30). - /// - [ObservableProperty] - public partial bool LoadErrorOpen { get; set; } - - /// True once a sync run has finished (successfully, canceled, or faulted). - [ObservableProperty] - public partial bool HasCompletedRun { get; set; } - - /// True while any row is in the Failed state; drives the navigation badge. - [ObservableProperty] - public partial bool HasFailedRepos { get; set; } - - /// Column the table is currently sorted by, or null for load order. - [ObservableProperty] - public partial string? SortColumn { get; set; } - - [ObservableProperty] - public partial bool SortDescending { get; set; } - - /// Which rows the repository table shows; defaults to . - [ObservableProperty] - public partial RepoFilter Filter { get; set; } - - /// - /// Column filter: case-insensitive substring the repository name must contain. - /// Empty (the default) matches everything. Composes with - /// and the other column filters; selection state is unaffected — hidden rows - /// stay selected and still sync. - /// - [ObservableProperty] - public partial string NameFilter { get; set; } - - /// Column filter: case-insensitive substring the branch name must contain. - [ObservableProperty] - public partial string BranchFilter { get; set; } - - /// Column filter: null shows all, true only archived, false only unarchived. - [ObservableProperty] - public partial bool? ArchivedFilter { get; set; } - - /// Number of rows currently selected for the next sync. - [ObservableProperty] - public partial int SelectedCount { get; set; } - - /// Caption for the primary sync button, pluralized for the current selection. - public string SyncButtonLabel => SelectedCount == 1 ? "Sync 1 repo" : $"Sync {SelectedCount} repos"; - - /// True when the connect inputs may be edited: no load and no run in flight. - [ObservableProperty] - public partial bool CanEditInputs { get; set; } - - /// - /// True once this workspace has completed its first successful repository load; - /// drives the connect-card vs workspace visibility switch. Never reset. - /// - [ObservableProperty] - public partial bool HasLoadedRepos { get; set; } - - /// Outcome of the most recent run; maps to the results InfoBar's severity. - [ObservableProperty] - public partial RunResultKind ResultKind { get; set; } - - /// Summary or error line of the most recent run, shown in the results InfoBar. - [ObservableProperty] - public partial string ResultMessage { get; set; } - - /// - /// True while the results InfoBar is showing: set when a run ends, cleared when the - /// next run or load starts. The page two-way binds it so the user can dismiss the bar. - /// - [ObservableProperty] - public partial bool ResultOpen { get; set; } - - partial void OnFilterChanged(RepoFilter value) => RebuildFilteredRepos(); - - partial void OnNameFilterChanged(string value) => RebuildFilteredRepos(); - - partial void OnBranchFilterChanged(string value) => RebuildFilteredRepos(); - - partial void OnArchivedFilterChanged(bool? value) => RebuildFilteredRepos(); - - partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(SyncButtonLabel)); - - partial void OnIsRunningChanged(bool value) => UpdateCanEditInputs(); - - partial void OnIsLoadingReposChanged(bool value) => UpdateCanEditInputs(); - - partial void OnIsResolvingPathsChanged(bool value) => UpdateCanEditInputs(); - - private void UpdateCanEditInputs() - => CanEditInputs = !IsRunning && !IsLoadingRepos && !IsResolvingPaths; - - /// Clears the previous run's result surface; called when a new run or load starts. - private void ResetRunResult() - { - ResultOpen = false; - ResultKind = RunResultKind.None; - ResultMessage = ""; - } - - /// Root folder repositories are placed under: TargetFolder, or TargetFolder\Organization. - public string EffectiveTargetRoot - { - get - { - string folder = TargetFolder.Trim(); - if (folder.Length == 0) - { - return ""; - } - string org = Organization.Trim(); - return CreateOrgSubfolder && org.Length > 0 ? Path.Combine(folder, org) : folder; - } - } - - /// Example final path for one repository, e.g. C:\src\acme\my-repo. - public string TargetPreview - { - get - { - string root = EffectiveTargetRoot; - return root.Length == 0 ? "" : Path.Combine(root, _sampleRepoName ?? "my-repo"); - } - } - - partial void OnTargetFolderChanged(string value) => NotifyTargetPathChanged(); - - partial void OnOrganizationChanged(string value) - { - NotifyTargetPathChanged(); - OnPropertyChanged(nameof(IsTableStale)); - OnPropertyChanged(nameof(StaleTableMessage)); - SyncCommand.NotifyCanExecuteChanged(); - } - - /// Records a failed load or lookup on the error surface (and the live region). - private void ShowLoadError(string title, string message) - { - StatusText = message; - LoadErrorTitle = title; - LoadErrorMessage = message; - LoadErrorOpen = true; - } - - partial void OnCreateOrgSubfolderChanged(bool value) => NotifyTargetPathChanged(); - - private void NotifyTargetPathChanged() - { - OnPropertyChanged(nameof(EffectiveTargetRoot)); - OnPropertyChanged(nameof(TargetPreview)); - } - - // Runs on the UI thread (Token is only set from UI handlers), so the async - // continuations below stay on the UI thread and may touch Organizations directly. - partial void OnTokenChanged(string value) - { - // The value itself never reaches the log; its length is enough to tell a - // paste from a cleared box when reading back a session (#40). Construction - // seeds the property (empty, or the vault token) and is not a user action. - if (_constructed) - { - _log.Info(value.Length == 0 - ? $"Token cleared in workspace '{DisplayName}'." - : $"Token entered in workspace '{DisplayName}' ({value.Length} characters)."); - } - _ = RefreshOrganizationsAsync(); - } - - /// Records that the user opened the target folder from the results bar (#40). - public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'."); - - /// Asks every row to re-evaluate its theme-dependent presentation (see ). - public void RefreshPresentation() - { - foreach (RepoItemViewModel item in Repos) - { - item.RefreshPresentation(); - } - } - - private bool _disposed; - - /// - /// Stops the in-flight org lookup and path recovery, if any. Idempotent: the shell - /// disposes workspaces on eviction and again on window close, and a second call - /// used to throw ObjectDisposedException from the lifetime token source (#31). - /// - public void Dispose() - { - if (_disposed) - { - return; - } - _disposed = true; - _orgLoadCts?.Cancel(); - _orgLoadCts?.Dispose(); - _orgLoadCts = null; - _lifetimeCts.Cancel(); - _lifetimeCts.Dispose(); - } - - /// Debounced: each token edit cancels the previous lookup. - private async Task RefreshOrganizationsAsync() - { - _orgLoadCts?.Cancel(); - _orgLoadCts?.Dispose(); - var cts = _orgLoadCts = new CancellationTokenSource(); - // Captured before any successor can dispose cts; used everywhere below. - CancellationToken lookupToken = cts.Token; - - string token = Token.Trim(); - if (token.Length < 10) - { - Organizations.Clear(); - // The newest invocation owns the flag: a canceled predecessor deliberately - // leaves it alone, so an early return must clear any spinner it left behind. + private readonly IRepositoryLister _lister; + + private readonly IGitClient _git; + + private readonly IWslCloner _wsl; + + /// Result of the one-time WSL probe; null until the first path recovery asks for it. + private WslAvailability? _wslAvailability; + + private readonly IOrganizationLister _orgLister; + + private readonly Func, IProgress> _progressFactory; + + private readonly TimeSpan _orgLookupDebounce; + + private readonly IActivityLog _log; + + private readonly Account? _account; + + private readonly AccountsStore? _accountsStore; + + private readonly Dictionary _itemsByName = new(StringComparer.OrdinalIgnoreCase); + + private CancellationTokenSource? _orgLoadCts; + + /// + /// Position of every row in ; rebuilt on load and sort. Lets + /// progress-driven membership changes find a row's slot in + /// by binary search instead of rebuilding the whole bound list (#30). + /// + private readonly Dictionary _reposIndex = new(); + + /// Mirror of for O(1) membership checks. + private readonly HashSet _filteredSet = new(); + + /// Canceled on dispose so an in-flight path recovery dies with the workspace. + private readonly CancellationTokenSource _lifetimeCts = new(); + + /// + /// Runs one sync pass; the real by default. A test seam: + /// the engine swallows cancellation into a summary, so the only way to exercise the + /// view model's own handling is to inject a + /// runner that throws it. + /// + internal Func, IProgress, CancellationToken, Task>? SyncRunner + { + get; + set; + } + + /// Breaks the AllSelected <-> item.IsSelected feedback loop while one side updates the other. + private bool _syncingSelection; + + /// False until the constructor finished seeding properties; gates user-action logging. + private readonly bool _constructed; + + /// First repository name from the last load; makes concrete. + private string? _sampleRepoName; + + /// + /// Rows captured for the in-flight (or most recent) sync run. Progress is scoped to + /// this set (#22): / describe the + /// run, not the whole table. Empty until the first run and reset by each load. + /// + private IReadOnlyList _runSet = []; + + + + /// + /// Production dependencies by default; pass fakes for testing. The default progress + /// factory is , which marshals via the SynchronizationContext + /// captured at construction (the UI thread in the app); tests inject a synchronous one. + /// A non-null seeds the workspace from that profile — its + /// organization, target folder, subfolder preference, and concurrency — and pulls the + /// token from (the Token setter's org lookup fires + /// naturally); finished syncs are then recorded on . + /// + public WorkspaceViewModel( + IRepositoryLister? lister = null, + IGitClient? git = null, + IOrganizationLister? orgLister = null, + Func, IProgress>? progressFactory = null, + TimeSpan? orgLookupDebounce = null, + IActivityLog? log = null, + Account? account = null, + ITokenVault? tokenVault = null, + AccountsStore? accountsStore = null, + IWslCloner? wsl = null) + { + _lister = lister ?? new GitHubRepositoryLister(); + _git = git ?? new LibGit2GitClient(); + _wsl = wsl ?? new WslCloner(); + _orgLister = orgLister ?? new GitHubOrganizationLister(); + _progressFactory = progressFactory ?? (handler => new Progress(handler)); + _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); + _log = log ?? new FileActivityLog(); + _account = account; + _accountsStore = accountsStore; + Organization = ""; + Token = ""; + TargetFolder = ""; + NameFilter = ""; + BranchFilter = ""; + MaxConcurrency = AppSettings.DefaultConcurrency; + StatusText = ""; + ResultMessage = ""; + LoadErrorTitle = ""; + LoadErrorMessage = ""; + AllSelected = true; + CanEditInputs = true; + + if(account is not null) + { + Organization = account.Organization; + TargetFolder = account.TargetRoot; + CreateOrgSubfolder = account.CreateOrgSubfolder; + MaxConcurrency = account.MaxConcurrency; + // The Token setter's existing org lookup fires naturally with the vault token. + Token = tokenVault?.TryRetrieve(account.Id) ?? ""; + } + _constructed = true; + } + + + + /// Id of the account this workspace was created for, or null for Quick Sync. + public Guid? AccountId => _account?.Id; + + + + /// Name shown for this workspace in navigation: the account's name, or "Quick Sync". + public string DisplayName => _account?.Name ?? "Quick Sync"; + + + + public ObservableCollection Repos { get; } = new(); + + /// + /// The rows the table shows: the subset of matching + /// , in the table's current sort order. Rebuilt when the filter + /// changes, a load completes, a run starts or ends, and on terminal row transitions. + /// + public ObservableCollection FilteredRepos { get; } = new(); + + /// + /// Rows with a git operation in flight right now; feeds the pinned active strip. + /// Maintained incrementally from progress reports, so its size is bounded by + /// . Cleared when a run starts and again when it ends. + /// + public ObservableCollection ActiveRepos { get; } = new(); + + /// Organizations discovered from the current token; feeds the org dropdown. + public ObservableCollection Organizations { get; } = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + public partial string Organization { get; set; } + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + public partial string Token { get; set; } + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + public partial string TargetFolder { get; set; } + + [ObservableProperty] + public partial int MaxConcurrency { get; set; } + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] + [NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))] + public partial bool IsRunning { get; set; } + + [ObservableProperty] + public partial string StatusText { get; set; } + + [ObservableProperty] + public partial int CompletedCount { get; set; } + + [ObservableProperty] + public partial int TotalCount { get; set; } + + [ObservableProperty] + public partial bool IsLoadingOrgs { get; set; } + + /// True while is listing repositories. + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] + public partial bool IsLoadingRepos { get; set; } + + /// + /// True while a path-recovery checkout runs. Recovery writes a repository's working + /// tree outside a sync run, so every other git entry point is locked out for its + /// duration — two concurrent operations on one directory corrupt it. + /// + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(LoadReposCommand))] + [NotifyCanExecuteChangedFor(nameof(SyncCommand))] + [NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))] + [NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))] + public partial bool IsResolvingPaths { get; set; } + + /// When set, repositories are placed under TargetFolder\Organization. + [ObservableProperty] + public partial bool CreateOrgSubfolder { get; set; } + + /// + /// Header checkbox state: setting it checks or unchecks every VISIBLE row (the rows + /// in ), and it reads true when every visible row is + /// selected. With no filter active that is every row; with a filter it makes + /// "filter, then select what you see" the subset workflow (#30). Hidden rows keep + /// their selection and still sync. + /// + [ObservableProperty] + public partial bool AllSelected { get; set; } + + /// + /// Organization whose repositories the table currently holds (trimmed, as loaded); + /// null before the first successful load. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsTableStale))] + [NotifyPropertyChangedFor(nameof(StaleTableMessage))] + public partial string? LoadedOrganization { get; set; } + + + + /// + /// True when was edited after a load: the table still + /// shows the previous organization's rows while the chip and target path describe + /// the new one. Sync is blocked until a reload replaces the rows (#30). + /// + public bool IsTableStale + => LoadedOrganization is not null + && !string.Equals(Organization.Trim(), LoadedOrganization, StringComparison.OrdinalIgnoreCase); + + + + /// One-line explanation shown while ; empty otherwise. + public string StaleTableMessage + => IsTableStale + ? $"The table still shows '{LoadedOrganization}'. Reload to list '{Organization.Trim()}' before syncing." + : ""; + + + + /// Title of the error surface for a failed load or organization lookup. + [ObservableProperty] + public partial string LoadErrorTitle { get; set; } + + /// Message of the most recent failed load or organization lookup; empty when none. + [ObservableProperty] + public partial string LoadErrorMessage { get; set; } + + /// + /// True while the load-error surface is showing. Set by a failed load or + /// organization lookup, cleared when the next load starts or a lookup succeeds; the + /// page two-way binds it so the user can dismiss the bar (#30). + /// + [ObservableProperty] + public partial bool LoadErrorOpen { get; set; } + + /// True once a sync run has finished (successfully, canceled, or faulted). + [ObservableProperty] + public partial bool HasCompletedRun { get; set; } + + /// True while any row is in the Failed state; drives the navigation badge. + [ObservableProperty] + public partial bool HasFailedRepos { get; set; } + + /// Column the table is currently sorted by, or null for load order. + [ObservableProperty] + public partial string? SortColumn { get; set; } + + [ObservableProperty] + public partial bool SortDescending { get; set; } + + /// Which rows the repository table shows; defaults to . + [ObservableProperty] + public partial RepoFilter Filter { get; set; } + + /// + /// Column filter: case-insensitive substring the repository name must contain. + /// Empty (the default) matches everything. Composes with + /// and the other column filters; selection state is unaffected — hidden rows + /// stay selected and still sync. + /// + [ObservableProperty] + public partial string NameFilter { get; set; } + + /// Column filter: case-insensitive substring the branch name must contain. + [ObservableProperty] + public partial string BranchFilter { get; set; } + + /// Column filter: null shows all, true only archived, false only unarchived. + [ObservableProperty] + public partial bool? ArchivedFilter { get; set; } + + /// Number of rows currently selected for the next sync. + [ObservableProperty] + public partial int SelectedCount { get; set; } + + + + /// Caption for the primary sync button, pluralized for the current selection. + public string SyncButtonLabel => SelectedCount == 1 ? "Sync 1 repo" : $"Sync {SelectedCount} repos"; + + + + /// True when the connect inputs may be edited: no load and no run in flight. + [ObservableProperty] + public partial bool CanEditInputs { get; set; } + + /// + /// True once this workspace has completed its first successful repository load; + /// drives the connect-card vs workspace visibility switch. Never reset. + /// + [ObservableProperty] + public partial bool HasLoadedRepos { get; set; } + + /// Outcome of the most recent run; maps to the results InfoBar's severity. + [ObservableProperty] + public partial RunResultKind ResultKind { get; set; } + + /// Summary or error line of the most recent run, shown in the results InfoBar. + [ObservableProperty] + public partial string ResultMessage { get; set; } + + /// + /// True while the results InfoBar is showing: set when a run ends, cleared when the + /// next run or load starts. The page two-way binds it so the user can dismiss the bar. + /// + [ObservableProperty] + public partial bool ResultOpen { get; set; } + + + + partial void OnFilterChanged(RepoFilter value) => RebuildFilteredRepos(); + + + + partial void OnNameFilterChanged(string value) => RebuildFilteredRepos(); + + + + partial void OnBranchFilterChanged(string value) => RebuildFilteredRepos(); + + + + partial void OnArchivedFilterChanged(bool? value) => RebuildFilteredRepos(); + + + + partial void OnSelectedCountChanged(int value) => OnPropertyChanged(nameof(SyncButtonLabel)); + + + + partial void OnIsRunningChanged(bool value) => UpdateCanEditInputs(); + + + + partial void OnIsLoadingReposChanged(bool value) => UpdateCanEditInputs(); + + + + partial void OnIsResolvingPathsChanged(bool value) => UpdateCanEditInputs(); + + + + private void UpdateCanEditInputs() + => CanEditInputs = !IsRunning && !IsLoadingRepos && !IsResolvingPaths; + + + + /// Clears the previous run's result surface; called when a new run or load starts. + private void ResetRunResult() + { + ResultOpen = false; + ResultKind = RunResultKind.None; + ResultMessage = ""; + } + + + + /// Root folder repositories are placed under: TargetFolder, or TargetFolder\Organization. + public string EffectiveTargetRoot + { + get + { + string folder = TargetFolder.Trim(); + if(folder.Length == 0) + { + return ""; + } + string org = Organization.Trim(); + return CreateOrgSubfolder && org.Length > 0 ? Path.Combine(folder, org) : folder; + } + } + + + + /// Example final path for one repository, e.g. C:\src\acme\my-repo. + public string TargetPreview + { + get + { + string root = EffectiveTargetRoot; + return root.Length == 0 ? "" : Path.Combine(root, _sampleRepoName ?? "my-repo"); + } + } + + + + partial void OnTargetFolderChanged(string value) => NotifyTargetPathChanged(); + + + + partial void OnOrganizationChanged(string value) + { + NotifyTargetPathChanged(); + OnPropertyChanged(nameof(IsTableStale)); + OnPropertyChanged(nameof(StaleTableMessage)); + SyncCommand.NotifyCanExecuteChanged(); + } + + + + /// Records a failed load or lookup on the error surface (and the live region). + private void ShowLoadError(string title, string message) + { + StatusText = message; + LoadErrorTitle = title; + LoadErrorMessage = message; + LoadErrorOpen = true; + } + + + + partial void OnCreateOrgSubfolderChanged(bool value) => NotifyTargetPathChanged(); + + + + private void NotifyTargetPathChanged() + { + OnPropertyChanged(nameof(EffectiveTargetRoot)); + OnPropertyChanged(nameof(TargetPreview)); + } + + + + // Runs on the UI thread (Token is only set from UI handlers), so the async + // continuations below stay on the UI thread and may touch Organizations directly. + partial void OnTokenChanged(string value) + { + // The value itself never reaches the log; its length is enough to tell a + // paste from a cleared box when reading back a session (#40). Construction + // seeds the property (empty, or the vault token) and is not a user action. + if(_constructed) + { + _log.Info(value.Length == 0 + ? $"Token cleared in workspace '{DisplayName}'." + : $"Token entered in workspace '{DisplayName}' ({value.Length} characters)."); + } + _ = RefreshOrganizationsAsync(); + } + + + + /// Records that the user opened the target folder from the results bar (#40). + public void NoteFolderOpened() => _log.Info($"Opened folder '{EffectiveTargetRoot}'."); + + + + /// Asks every row to re-evaluate its theme-dependent presentation (see ). + public void RefreshPresentation() + { + foreach(RepoItemViewModel item in Repos) + { + item.RefreshPresentation(); + } + } + + + + private bool _disposed; + + + + /// + /// Stops the in-flight org lookup and path recovery, if any. Idempotent: the shell + /// disposes workspaces on eviction and again on window close, and a second call + /// used to throw ObjectDisposedException from the lifetime token source (#31). + /// + public void Dispose() + { + if(_disposed) + { + return; + } + _disposed = true; + _orgLoadCts?.Cancel(); + _orgLoadCts?.Dispose(); + _orgLoadCts = null; + _lifetimeCts.Cancel(); + _lifetimeCts.Dispose(); + } + + + + /// Debounced: each token edit cancels the previous lookup. + private async Task RefreshOrganizationsAsync() + { + _orgLoadCts?.Cancel(); + _orgLoadCts?.Dispose(); + CancellationTokenSource cts = _orgLoadCts = new CancellationTokenSource(); + // Captured before any successor can dispose cts; used everywhere below. + CancellationToken lookupToken = cts.Token; + + string token = Token.Trim(); + if(token.Length < 10) + { + Organizations.Clear(); + // The newest invocation owns the flag: a canceled predecessor deliberately + // leaves it alone, so an early return must clear any spinner it left behind. + IsLoadingOrgs = false; + return; // not plausibly a complete PAT yet + } + + try + { + await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes + IsLoadingOrgs = true; + _log.Info("Organization lookup started."); + IReadOnlyList orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); + lookupToken.ThrowIfCancellationRequested(); + _log.Info($"Organization lookup finished: {orgs.Count} organizations and accounts visible."); + + // An editable ComboBox resets its Text when its ItemsSource is mutated, + // and the TwoWay binding would wipe a value that was already set — an + // account workspace seeds Organization in the constructor, and this + // refresh lands ~a second later. Capture, restore, and force a binding + // resync so the seeded (or typed) organization survives the refresh. + string organizationBeforeRefresh = Organization; + Organizations.Clear(); + foreach(string org in orgs) + { + Organizations.Add(org); + } + Organization = organizationBeforeRefresh; + OnPropertyChanged(nameof(Organization)); + LoadErrorOpen = false; // a lookup that works clears a stale lookup failure + // The production lister always lists the token's own account first, so a + // single entry means no organizations were visible. + StatusText = orgs.Count <= 1 + ? "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually." + : $"Found {orgs.Count} organizations and accounts."; + } + catch(OperationCanceledException) + { + // superseded by a newer token edit + } + catch(Exception ex) + { + if(_orgLoadCts == cts) + { + _log.Error($"Organization lookup failed: {ex.Message}", ex); + ShowLoadError("Could not list organizations", ex.Message); + } + } + finally + { + if(_orgLoadCts == cts) + { IsLoadingOrgs = false; - return; // not plausibly a complete PAT yet - } + } + } + } - try - { - await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes - IsLoadingOrgs = true; - _log.Info("Organization lookup started."); - var orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); - lookupToken.ThrowIfCancellationRequested(); - _log.Info($"Organization lookup finished: {orgs.Count} organizations and accounts visible."); - // An editable ComboBox resets its Text when its ItemsSource is mutated, - // and the TwoWay binding would wipe a value that was already set — an - // account workspace seeds Organization in the constructor, and this - // refresh lands ~a second later. Capture, restore, and force a binding - // resync so the seeded (or typed) organization survives the refresh. - string organizationBeforeRefresh = Organization; - Organizations.Clear(); - foreach (string org in orgs) - { - Organizations.Add(org); - } - Organization = organizationBeforeRefresh; - OnPropertyChanged(nameof(Organization)); - LoadErrorOpen = false; // a lookup that works clears a stale lookup failure - // The production lister always lists the token's own account first, so a - // single entry means no organizations were visible. - StatusText = orgs.Count <= 1 - ? "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually." - : $"Found {orgs.Count} organizations and accounts."; - } - catch (OperationCanceledException) - { - // superseded by a newer token edit - } - catch (Exception ex) - { - if (_orgLoadCts == cts) - { - _log.Error($"Organization lookup failed: {ex.Message}", ex); - ShowLoadError("Could not list organizations", ex.Message); - } - } - finally - { - if (_orgLoadCts == cts) - { - IsLoadingOrgs = false; - } - } - } - /// - /// Double-typed view of for NumberBox.Value, which binds a double. - /// - public double MaxConcurrencyValue - { - get => MaxConcurrency; - set - { - int clamped = double.IsNaN(value) - ? AppSettings.DefaultConcurrency - : (int)Math.Clamp(Math.Round(value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency); - if (MaxConcurrency != clamped) - { - MaxConcurrency = clamped; // OnMaxConcurrencyChanged notifies MaxConcurrencyValue too - } - else - { - // The int did not change (e.g. the box was cleared to NaN): push the - // canonical value back so the control redisplays it. - OnPropertyChanged(nameof(MaxConcurrencyValue)); - } - } - } + /// + /// Double-typed view of for NumberBox.Value, which binds a double. + /// + public double MaxConcurrencyValue + { + get => MaxConcurrency; + set + { + int clamped = double.IsNaN(value) + ? AppSettings.DefaultConcurrency + : (int)Math.Clamp(Math.Round(value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency); + if(MaxConcurrency != clamped) + { + MaxConcurrency = clamped; // OnMaxConcurrencyChanged notifies MaxConcurrencyValue too + } + else + { + // The int did not change (e.g. the box was cleared to NaN): push the + // canonical value back so the control redisplays it. + OnPropertyChanged(nameof(MaxConcurrencyValue)); + } + } + } - partial void OnMaxConcurrencyChanged(int value) => OnPropertyChanged(nameof(MaxConcurrencyValue)); - // ---------------------------------------------------------------- selection - /// Applies a header-checkbox change to every row. - partial void OnAllSelectedChanged(bool value) - { - if (_syncingSelection) - { - return; // being recomputed from an item change; do not push back down - } + partial void OnMaxConcurrencyChanged(int value) => OnPropertyChanged(nameof(MaxConcurrencyValue)); - _syncingSelection = true; - try - { - foreach (RepoItemViewModel item in FilteredRepos) - { - item.IsSelected = value; - } - } - finally - { + + + // ---------------------------------------------------------------- selection + + /// Applies a header-checkbox change to every row. + partial void OnAllSelectedChanged(bool value) + { + if(_syncingSelection) + { + return; // being recomputed from an item change; do not push back down + } + + _syncingSelection = true; + try + { + foreach(RepoItemViewModel item in FilteredRepos) + { + item.IsSelected = value; + } + } + finally + { + _syncingSelection = false; + } + RecomputeSelectedCount(); + SyncCommand.NotifyCanExecuteChanged(); + } + + + + private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if(string.Equals(e.PropertyName, nameof(RepoItemViewModel.HasPathIssue), StringComparison.Ordinal)) + { + // Parameterized command: the row's 'Resolve...' button re-queries CanExecute. + ResolvePathsCommand.NotifyCanExecuteChanged(); + return; + } + if(!string.Equals(e.PropertyName, nameof(RepoItemViewModel.IsSelected), StringComparison.Ordinal)) + { + return; + } + if(!_syncingSelection) + { + // A header push (or any other batch) recomputes once after its loop + // instead of per row — including the command re-query, whose CanSync + // scans every row (#30). + RecomputeSelectedCount(); + UpdateAllSelectedFromItems(); + SyncCommand.NotifyCanExecuteChanged(); + } + } + + + + private void RecomputeSelectedCount() => SelectedCount = Repos.Count(r => r.IsSelected); + + + + /// Header state follows the VISIBLE rows; see . + private void UpdateAllSelectedFromItems() + { + bool all = FilteredRepos.All(r => r.IsSelected); + if(AllSelected != all) + { + _syncingSelection = true; + try + { + AllSelected = all; + } + finally + { _syncingSelection = false; - } - RecomputeSelectedCount(); - SyncCommand.NotifyCanExecuteChanged(); - } + } + } + } - private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(RepoItemViewModel.HasPathIssue)) - { - // Parameterized command: the row's 'Resolve...' button re-queries CanExecute. - ResolvePathsCommand.NotifyCanExecuteChanged(); - return; - } - if (e.PropertyName != nameof(RepoItemViewModel.IsSelected)) - { - return; - } - if (!_syncingSelection) - { - // A header push (or any other batch) recomputes once after its loop - // instead of per row — including the command re-query, whose CanSync - // scans every row (#30). - RecomputeSelectedCount(); - UpdateAllSelectedFromItems(); - SyncCommand.NotifyCanExecuteChanged(); - } - } - private void RecomputeSelectedCount() => SelectedCount = Repos.Count(r => r.IsSelected); - /// Header state follows the VISIBLE rows; see . - private void UpdateAllSelectedFromItems() - { - bool all = FilteredRepos.All(r => r.IsSelected); - if (AllSelected != all) - { - _syncingSelection = true; + // ---------------------------------------------------------------- load + + private bool CanLoadRepos() => + !IsRunning + && !IsLoadingRepos + && !IsResolvingPaths + && !string.IsNullOrWhiteSpace(Organization) + && !string.IsNullOrWhiteSpace(Token); + + + + /// Phase one: lists the organization's repositories and fills the table, all selected. + [RelayCommand(CanExecute = nameof(CanLoadRepos))] + private async Task LoadReposAsync() + { + IsLoadingRepos = true; + ResetRunResult(); + LoadErrorOpen = false; + try + { + string organization = Organization.Trim(); + StatusText = "Loading repositories..."; + _log.Info($"Loading repositories for organization '{organization}'."); + + var descriptors = (await _lister + .ListOrganizationRepositoriesAsync(organization, Token.Trim())) + // Names key the row dictionary; a duplicate (possible from pagination + // shifts) must not produce two rows fighting over one folder. + .DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach(RepoItemViewModel existing in Repos) + { + existing.PropertyChanged -= OnItemPropertyChanged; + } + Repos.Clear(); + _itemsByName.Clear(); + + foreach(RepoDescriptor descriptor in descriptors) + { + var item = new RepoItemViewModel(descriptor); + item.PropertyChanged += OnItemPropertyChanged; + _itemsByName[descriptor.Name] = item; + Repos.Add(item); + } + + TotalCount = Repos.Count; + CompletedCount = 0; + _runSet = []; // progress is table-scoped again until the next run + SortColumn = null; + SortDescending = false; + _sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null; + RebuildReposIndex(); + LoadedOrganization = organization; + HasLoadedRepos = true; + StatusText = $"{Repos.Count} repositories loaded."; + _log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'."); + } + catch(Exception ex) + { + // The message is good (the listers translate 401/404/rate limits); the + // surface must be too — an InfoBar with Retry, not caption text at the + // bottom of the window (#30). + ShowLoadError("Could not load repositories", ex.Message); + _log.Error($"Loading repositories failed: {ex.Message}", ex); + } + finally + { + IsLoadingRepos = false; + NotifyTargetPathChanged(); + RecomputeSelectedCount(); + RebuildFilteredRepos(); // also re-derives AllSelected from the visible rows + SyncCommand.NotifyCanExecuteChanged(); + RetryFailedCommand.NotifyCanExecuteChanged(); + RecomputeHasFailedRepos(); + } + } + + + + // ---------------------------------------------------------------- sync + + private bool CanSync() => + !IsRunning + && !IsLoadingRepos + && !IsResolvingPaths + && !IsTableStale + && !string.IsNullOrWhiteSpace(TargetFolder) + && Repos.Any(r => r.IsSelected); + + + + /// Phase two: clones or updates the selected repositories. + [RelayCommand(CanExecute = nameof(CanSync), IncludeCancelCommand = true)] + private async Task SyncAsync(CancellationToken cancellationToken) + { + IsRunning = true; + ResetRunResult(); + ActiveRepos.Clear(); + try + { + var selected = Repos.Where(r => r.IsSelected).ToList(); + foreach(RepoItemViewModel item in selected) + { + item.Status = SyncStatus.Queued; + item.Error = null; + item.Percent = null; + item.InvalidPaths = null; + } + // Progress is run-scoped (#22): the bar and 'N of M' describe this run's + // selection, not the whole table; TotalCount keeps the run size afterwards. + _runSet = selected; + TotalCount = selected.Count; + RecomputeCompletedCount(); + RebuildFilteredRepos(); // every selected row just went back to Queued + + string targetRoot = EffectiveTargetRoot; + StatusText = $"Syncing {selected.Count} repositories..."; + _log.Info($"Sync started: {selected.Count} repositories into '{targetRoot}'."); + + // Constructed on the UI thread: Progress captures the WinUI + // SynchronizationContext, so HandleProgress always runs on the UI thread. + // Failure logging is layered in front of it so the per-entry file I/O + // happens on the engine's worker thread, never on the dispatcher (#30). + var progress = new FailureLoggingProgress(_progressFactory(HandleProgress), _log); + + var request = new SyncRequest( + Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency); + IReadOnlyList descriptors = selected.Select(r => r.Descriptor).ToList(); + + Func, IProgress, CancellationToken, Task> run = + SyncRunner ?? ((req, descs, prog, ct) => new OrgSyncEngine(_lister, _git).SyncAsync(req, descs, prog, ct)); + SyncSummary summary = await run(request, descriptors, progress, cancellationToken); + + string summaryText = (summary.WasCanceled ? "Canceled" : "Finished") + + $": {summary.Cloned} cloned, {summary.Updated} updated, " + + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; + StatusText = summaryText; + _log.Info(summaryText); + + ResultKind = summary.WasCanceled ? RunResultKind.Canceled + : summary.Failed > 0 ? RunResultKind.PartialFailure + : RunResultKind.Success; + ResultMessage = summaryText; + + if(_account is not null && _accountsStore is not null) + { try { - AllSelected = all; + _accountsStore.RecordSyncResult(_account.Id, DateTimeOffset.UtcNow, summaryText); } - finally + catch(Exception ex) { - _syncingSelection = false; + // Bookkeeping only: failing to stamp the account must not turn a + // completed sync into an error. + _log.Error( + $"Failed to record the sync result for account '{_account.Name}': {ex.Message}", ex); } - } - } + } + } + catch(OperationCanceledException) + { + StatusText = "Canceled"; + ResultKind = RunResultKind.Canceled; + ResultMessage = "Canceled"; + _log.Info("Sync canceled."); + } + catch(Exception ex) + { + StatusText = ex.Message; + ResultKind = RunResultKind.Error; + ResultMessage = ex.Message; + _log.Error($"Sync failed: {ex.Message}", ex); + } + finally + { + HasCompletedRun = true; + IsRunning = false; + ActiveRepos.Clear(); + RebuildFilteredRepos(); + ResultOpen = true; + } + } - // ---------------------------------------------------------------- load - private bool CanLoadRepos() => - !IsRunning - && !IsLoadingRepos - && !IsResolvingPaths - && !string.IsNullOrWhiteSpace(Organization) - && !string.IsNullOrWhiteSpace(Token); - /// Phase one: lists the organization's repositories and fills the table, all selected. - [RelayCommand(CanExecute = nameof(CanLoadRepos))] - private async Task LoadReposAsync() - { - IsLoadingRepos = true; - ResetRunResult(); - LoadErrorOpen = false; - try - { - string organization = Organization.Trim(); - StatusText = "Loading repositories..."; - _log.Info($"Loading repositories for organization '{organization}'."); + // ---------------------------------------------------------------- retry - var descriptors = (await _lister - .ListOrganizationRepositoriesAsync(organization, Token.Trim())) - // Names key the row dictionary; a duplicate (possible from pagination - // shifts) must not produce two rows fighting over one folder. - .DistinctBy(r => r.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); + private bool CanRetryFailed() => + !IsRunning + && !IsLoadingRepos + && !IsResolvingPaths + && Repos.Any(r => r.Status == SyncStatus.Failed); - foreach (RepoItemViewModel existing in Repos) - { - existing.PropertyChanged -= OnItemPropertyChanged; - } - Repos.Clear(); - _itemsByName.Clear(); - foreach (RepoDescriptor descriptor in descriptors) - { - var item = new RepoItemViewModel(descriptor); - item.PropertyChanged += OnItemPropertyChanged; - _itemsByName[descriptor.Name] = item; - Repos.Add(item); - } - TotalCount = Repos.Count; - CompletedCount = 0; - _runSet = []; // progress is table-scoped again until the next run - SortColumn = null; - SortDescending = false; - _sampleRepoName = Repos.Count > 0 ? Repos[0].Name : null; - RebuildReposIndex(); - LoadedOrganization = organization; - HasLoadedRepos = true; - StatusText = $"{Repos.Count} repositories loaded."; - _log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'."); - } - catch (Exception ex) - { - // The message is good (the listers translate 401/404/rate limits); the - // surface must be too — an InfoBar with Retry, not caption text at the - // bottom of the window (#30). - ShowLoadError("Could not load repositories", ex.Message); - _log.Error($"Loading repositories failed: {ex.Message}", ex); - } - finally - { - IsLoadingRepos = false; - NotifyTargetPathChanged(); - RecomputeSelectedCount(); - RebuildFilteredRepos(); // also re-derives AllSelected from the visible rows - SyncCommand.NotifyCanExecuteChanged(); - RetryFailedCommand.NotifyCanExecuteChanged(); - RecomputeHasFailedRepos(); - } - } + /// Selects exactly the failed repositories and runs the same sync path again. + [RelayCommand(CanExecute = nameof(CanRetryFailed))] + private async Task RetryFailedAsync() + { + _log.Info($"Retrying {Repos.Count(r => r.Status == SyncStatus.Failed)} failed repositories."); + // One batch: per-row recounts and command re-queries would be O(n^2) (#30). + _syncingSelection = true; + try + { + foreach(RepoItemViewModel item in Repos) + { + item.IsSelected = item.Status == SyncStatus.Failed; + } + } + finally + { + _syncingSelection = false; + } + RecomputeSelectedCount(); + UpdateAllSelectedFromItems(); + SyncCommand.NotifyCanExecuteChanged(); - // ---------------------------------------------------------------- sync + // Executing SyncCommand itself keeps SyncCancelCommand working for retry runs. + await SyncCommand.ExecuteAsync(null); + } - private bool CanSync() => - !IsRunning - && !IsLoadingRepos - && !IsResolvingPaths - && !IsTableStale - && !string.IsNullOrWhiteSpace(TargetFolder) - && Repos.Any(r => r.IsSelected); - /// Phase two: clones or updates the selected repositories. - [RelayCommand(CanExecute = nameof(CanSync), IncludeCancelCommand = true)] - private async Task SyncAsync(CancellationToken cancellationToken) - { - IsRunning = true; - ResetRunResult(); - ActiveRepos.Clear(); - try - { - var selected = Repos.Where(r => r.IsSelected).ToList(); - foreach (RepoItemViewModel item in selected) - { - item.Status = SyncStatus.Queued; - item.Error = null; - item.Percent = null; - item.InvalidPaths = null; - } - // Progress is run-scoped (#22): the bar and 'N of M' describe this run's - // selection, not the whole table; TotalCount keeps the run size afterwards. - _runSet = selected; - TotalCount = selected.Count; - RecomputeCompletedCount(); - RebuildFilteredRepos(); // every selected row just went back to Queued - string targetRoot = EffectiveTargetRoot; - StatusText = $"Syncing {selected.Count} repositories..."; - _log.Info($"Sync started: {selected.Count} repositories into '{targetRoot}'."); + // ---------------------------------------------------------------- path recovery - // Constructed on the UI thread: Progress captures the WinUI - // SynchronizationContext, so HandleProgress always runs on the UI thread. - // Failure logging is layered in front of it so the per-entry file I/O - // happens on the engine's worker thread, never on the dispatcher (#30). - var progress = new FailureLoggingProgress(_progressFactory(HandleProgress), _log); + /// + /// Raised when the user asks to resolve a row's Windows-invalid paths. The view + /// subscribes and shows recovery UI (the VM stays UI-free), returning the user's + /// decision, or null when they cancel. With no subscriber the command does nothing. + /// is settled before the interaction runs, so the + /// view can decide whether to offer "Clone in WSL instead". + /// + public Func>? RecoveryInteraction { get; set; } - var request = new SyncRequest( - Organization.Trim(), Token.Trim(), targetRoot, MaxConcurrency); - IReadOnlyList descriptors = selected.Select(r => r.Descriptor).ToList(); + /// + /// True when a clone into WSL can be offered as a recovery (#8): wsl.exe exists, the + /// default distribution starts, and git runs inside it. Probed once, lazily, the + /// first time path recovery is requested; false until then. + /// + [ObservableProperty] + public partial bool IsWslCloneAvailable { get; private set; } - Func, IProgress, CancellationToken, Task> run = - SyncRunner ?? ((req, descs, prog, ct) => new OrgSyncEngine(_lister, _git).SyncAsync(req, descs, prog, ct)); - SyncSummary summary = await run(request, descriptors, progress, cancellationToken); - string summaryText = (summary.WasCanceled ? "Canceled" : "Finished") - + $": {summary.Cloned} cloned, {summary.Updated} updated, " - + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; - StatusText = summaryText; - _log.Info(summaryText); - ResultKind = summary.WasCanceled ? RunResultKind.Canceled - : summary.Failed > 0 ? RunResultKind.PartialFailure - : RunResultKind.Success; - ResultMessage = summaryText; + /// Why is false (or the git version when true), for the log and the dialog. + public string WslAvailabilityDetail => _wslAvailability?.Detail ?? ""; - if (_account is not null && _accountsStore is not null) - { - try - { - _accountsStore.RecordSyncResult(_account.Id, DateTimeOffset.UtcNow, summaryText); - } - catch (Exception ex) - { - // Bookkeeping only: failing to stamp the account must not turn a - // completed sync into an error. - _log.Error( - $"Failed to record the sync result for account '{_account.Name}': {ex.Message}", ex); - } - } - } - catch (OperationCanceledException) - { - StatusText = "Canceled"; - ResultKind = RunResultKind.Canceled; - ResultMessage = "Canceled"; - _log.Info("Sync canceled."); - } - catch (Exception ex) - { - StatusText = ex.Message; - ResultKind = RunResultKind.Error; - ResultMessage = ex.Message; - _log.Error($"Sync failed: {ex.Message}", ex); - } - finally - { - HasCompletedRun = true; - IsRunning = false; - ActiveRepos.Clear(); - RebuildFilteredRepos(); - ResultOpen = true; - } - } - // ---------------------------------------------------------------- retry - private bool CanRetryFailed() => - !IsRunning - && !IsLoadingRepos - && !IsResolvingPaths - && Repos.Any(r => r.Status == SyncStatus.Failed); + private async Task EnsureWslProbedAsync(CancellationToken cancellationToken) + { + if(_wslAvailability is not null) + { + return; + } + WslAvailability availability = await _wsl.ProbeAsync(cancellationToken); + _wslAvailability = availability; + IsWslCloneAvailable = availability.Available; + _log.Info(availability.Available + ? $"WSL clone available ({availability.Detail})." + : $"WSL clone not available: {availability.Detail}"); + } - /// Selects exactly the failed repositories and runs the same sync path again. - [RelayCommand(CanExecute = nameof(CanRetryFailed))] - private async Task RetryFailedAsync() - { - _log.Info($"Retrying {Repos.Count(r => r.Status == SyncStatus.Failed)} failed repositories."); - // One batch: per-row recounts and command re-queries would be O(n^2) (#30). - _syncingSelection = true; - try - { - foreach (RepoItemViewModel item in Repos) - { - item.IsSelected = item.Status == SyncStatus.Failed; - } - } - finally - { - _syncingSelection = false; - } - RecomputeSelectedCount(); - UpdateAllSelectedFromItems(); - SyncCommand.NotifyCanExecuteChanged(); - // Executing SyncCommand itself keeps SyncCancelCommand working for retry runs. - await SyncCommand.ExecuteAsync(null); - } - // ---------------------------------------------------------------- path recovery + private bool CanResolvePaths(RepoItemViewModel item) + => item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths; - /// - /// Raised when the user asks to resolve a row's Windows-invalid paths. The view - /// subscribes and shows recovery UI (the VM stays UI-free), returning the user's - /// decision, or null when they cancel. With no subscriber the command does nothing. - /// is settled before the interaction runs, so the - /// view can decide whether to offer "Clone in WSL instead". - /// - public Func>? RecoveryInteraction { get; set; } - /// - /// True when a clone into WSL can be offered as a recovery (#8): wsl.exe exists, the - /// default distribution starts, and git runs inside it. Probed once, lazily, the - /// first time path recovery is requested; false until then. - /// - [ObservableProperty] - public partial bool IsWslCloneAvailable { get; private set; } - /// Why is false (or the git version when true), for the log and the dialog. - public string WslAvailabilityDetail => _wslAvailability?.Detail ?? ""; + /// + /// Asks the view (via ) how to rename or skip the + /// row's invalid paths, then applies that recovery and checks the repository out. + /// + [RelayCommand(CanExecute = nameof(CanResolvePaths))] + private async Task ResolvePathsAsync(RepoItemViewModel item) + { + if(item is null || RecoveryInteraction is not { } interaction) + { + return; + } - private async Task EnsureWslProbedAsync(CancellationToken cancellationToken) - { - if (_wslAvailability is not null) - { - return; - } - WslAvailability availability = await _wsl.ProbeAsync(cancellationToken); - _wslAvailability = availability; - IsWslCloneAvailable = availability.Available; - _log.Info(availability.Available - ? $"WSL clone available ({availability.Detail})." - : $"WSL clone not available: {availability.Detail}"); - } + _log.Info($"{item.Name}: path recovery requested ({item.InvalidPaths?.Count ?? 0} invalid paths)."); + await EnsureWslProbedAsync(_lifetimeCts.Token); + PathRecoveryDecision? decision = await interaction(item); + if(decision is null) + { + _log.Info($"{item.Name}: path recovery canceled; the repository stays failed."); + return; // user canceled; the row keeps its Failed state and payload + } - private bool CanResolvePaths(RepoItemViewModel item) - => item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths; + IsResolvingPaths = true; + try + { + if(decision is PathRecoveryDecision.CloneInWsl) + { + await CloneInWslAsync(item); + } + else + { + await ApplyRecoveryAsync(item, ((PathRecoveryDecision.Apply)decision).Recovery); + } + } + finally + { + IsResolvingPaths = false; + } + RecomputeCompletedCount(); + UpdateFilteredMembership(item); + RetryFailedCommand.NotifyCanExecuteChanged(); + } - /// - /// Asks the view (via ) how to rename or skip the - /// row's invalid paths, then applies that recovery and checks the repository out. - /// - [RelayCommand(CanExecute = nameof(CanResolvePaths))] - private async Task ResolvePathsAsync(RepoItemViewModel item) - { - if (item is null || RecoveryInteraction is not { } interaction) - { - return; - } - _log.Info($"{item.Name}: path recovery requested ({item.InvalidPaths?.Count ?? 0} invalid paths)."); - await EnsureWslProbedAsync(_lifetimeCts.Token); - PathRecoveryDecision? decision = await interaction(item); - if (decision is null) - { - _log.Info($"{item.Name}: path recovery canceled; the repository stays failed."); - return; // user canceled; the row keeps its Failed state and payload - } - IsResolvingPaths = true; - try - { - if (decision is PathRecoveryDecision.CloneInWsl) - { - await CloneInWslAsync(item); - } - else - { - await ApplyRecoveryAsync(item, ((PathRecoveryDecision.Apply)decision).Recovery); - } - } - finally - { - IsResolvingPaths = false; - } - RecomputeCompletedCount(); - UpdateFilteredMembership(item); - RetryFailedCommand.NotifyCanExecuteChanged(); - } + private async Task ApplyRecoveryAsync(RepoItemViewModel item, PathRecovery recovery) + { + string path = Path.Combine(EffectiveTargetRoot, item.Name); + try + { + _log.Info($"{item.Name}: applying path recovery " + + $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped)."); + // Pulling shows the row's indeterminate bar while the checkout runs. + item.Status = SyncStatus.Pulling; + await _git.ApplyRecoveryAsync(path, recovery, _lifetimeCts.Token); - private async Task ApplyRecoveryAsync(RepoItemViewModel item, PathRecovery recovery) - { - string path = Path.Combine(EffectiveTargetRoot, item.Name); - try - { - _log.Info($"{item.Name}: applying path recovery " - + $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped)."); - // Pulling shows the row's indeterminate bar while the checkout runs. - item.Status = SyncStatus.Pulling; - await _git.ApplyRecoveryAsync(path, recovery, _lifetimeCts.Token); + item.Status = SyncStatus.Done; + item.Error = null; + item.InvalidPaths = null; + _log.Info($"{item.Name}: path recovery applied; repository checked out."); + } + catch(Exception ex) + { + item.Status = SyncStatus.Failed; + item.Error = ex.Message; + // A still-invalid or colliding mapping keeps the row resolvable with the + // fresh path list; any other failure clears the payload (renaming again + // would not help). + item.InvalidPaths = (ex as InvalidRepositoryPathsException)?.Paths; + _log.Error($"{item.Name}: path recovery failed: {ex.Message}", ex); + } + } - item.Status = SyncStatus.Done; - item.Error = null; - item.InvalidPaths = null; - _log.Info($"{item.Name}: path recovery applied; repository checked out."); - } - catch (Exception ex) - { - item.Status = SyncStatus.Failed; - item.Error = ex.Message; - // A still-invalid or colliding mapping keeps the row resolvable with the - // fresh path list; any other failure clears the payload (renaming again - // would not help). - item.InvalidPaths = (ex as InvalidRepositoryPathsException)?.Paths; - _log.Error($"{item.Name}: path recovery failed: {ex.Message}", ex); - } - } - /// - /// The "Clone in WSL instead" recovery (#8): the repository is cloned by the default - /// WSL distribution's git into ~/gclo/<org>/<repo>, where its paths are - /// legal. The fetched-but-unchecked-out Windows copy is left as it is (a later - /// Windows sync reports the same invalid paths again, by design: nothing on the - /// Windows side changed). On failure the row keeps its payload so the user can pick - /// the other recovery. - /// - private async Task CloneInWslAsync(RepoItemViewModel item) - { - try - { - _log.Info($"{item.Name}: cloning in WSL instead."); - item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here - WslCloneResult result = await _wsl.CloneAsync( - item.Descriptor.CloneUrl, Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token); - item.Status = SyncStatus.Done; - item.Error = null; - item.InvalidPaths = null; - item.Note = $"Cloned in WSL ({result.Distribution}): {result.WindowsPath}"; - _log.Info($"{item.Name}: cloned in WSL ({result.Distribution}) at {result.LinuxPath}."); - } - catch (Exception ex) - { - item.Status = SyncStatus.Failed; - item.Error = ex.Message; - _log.Error($"{item.Name}: clone in WSL failed: {ex.Message}", ex); - } - } + /// + /// The "Clone in WSL instead" recovery (#8): the repository is cloned by the default + /// WSL distribution's git into ~/gclo/<org>/<repo>, where its paths are + /// legal. The fetched-but-unchecked-out Windows copy is left as it is (a later + /// Windows sync reports the same invalid paths again, by design: nothing on the + /// Windows side changed). On failure the row keeps its payload so the user can pick + /// the other recovery. + /// + private async Task CloneInWslAsync(RepoItemViewModel item) + { + try + { + _log.Info($"{item.Name}: cloning in WSL instead."); + item.Status = SyncStatus.Pulling; // indeterminate bar; git in WSL reports no percentage here + WslCloneResult result = await _wsl.CloneAsync( + item.Descriptor.CloneUrl, Organization.Trim(), item.Name, Token.Trim(), _lifetimeCts.Token); - // ---------------------------------------------------------------- sorting + item.Status = SyncStatus.Done; + item.Error = null; + item.InvalidPaths = null; + item.Note = $"Cloned in WSL ({result.Distribution}): {result.WindowsPath}"; + _log.Info($"{item.Name}: cloned in WSL ({result.Distribution}) at {result.LinuxPath}."); + } + catch(Exception ex) + { + item.Status = SyncStatus.Failed; + item.Error = ex.Message; + _log.Error($"{item.Name}: clone in WSL failed: {ex.Message}", ex); + } + } - /// - /// Sorts the table by a column ("Name", "Status", "Branch", or "Archived"); a second - /// click on the same column flips the direction. The re-order is stable and in place. - /// - [RelayCommand] - private void Sort(string column) - { - if (column is not ("Name" or "Status" or "Branch" or "Archived")) - { - return; - } - if (SortColumn == column) - { - SortDescending = !SortDescending; - } - else - { - SortColumn = column; - SortDescending = false; - } - List sorted = column switch - { - "Name" => OrderRepos(r => r.Name, StringComparer.OrdinalIgnoreCase), - "Status" => OrderRepos(r => r.Status, Comparer.Default), - "Branch" => OrderRepos(r => r.BranchText, StringComparer.OrdinalIgnoreCase), - _ => OrderRepos(r => r.IsArchived, Comparer.Default), - }; + // ---------------------------------------------------------------- sorting - // Nothing binds Repos (the table binds FilteredRepos), so a plain reorder is - // enough: O(n) adds instead of the O(n^2) IndexOf/Move choreography that - // used to run here, and the one rebuild below refreshes the bound list (#30). - Repos.Clear(); - foreach (RepoItemViewModel item in sorted) - { - Repos.Add(item); - } - RebuildReposIndex(); + /// + /// Sorts the table by a column ("Name", "Status", "Branch", or "Archived"); a second + /// click on the same column flips the direction. The re-order is stable and in place. + /// + [RelayCommand] + private void Sort(string column) + { + if(column is not ("Name" or "Status" or "Branch" or "Archived")) + { + return; + } - RebuildFilteredRepos(); // the table binds FilteredRepos, which mirrors Repos' order - } + if(string.Equals(SortColumn, column, StringComparison.Ordinal)) + { + SortDescending = !SortDescending; + } + else + { + SortColumn = column; + SortDescending = false; + } - /// OrderBy/OrderByDescending are both stable: equal keys keep their current order. - private List OrderRepos(Func key, IComparer comparer) - => SortDescending - ? Repos.OrderByDescending(key, comparer).ToList() - : Repos.OrderBy(key, comparer).ToList(); + List sorted = column switch + { + "Name" => OrderRepos(r => r.Name, StringComparer.OrdinalIgnoreCase), + "Status" => OrderRepos(r => r.Status, Comparer.Default), + "Branch" => OrderRepos(r => r.BranchText, StringComparer.OrdinalIgnoreCase), + _ => OrderRepos(r => r.IsArchived, Comparer.Default), + }; - // ---------------------------------------------------------------- progress + // Nothing binds Repos (the table binds FilteredRepos), so a plain reorder is + // enough: O(n) adds instead of the O(n^2) IndexOf/Move choreography that + // used to run here, and the one rebuild below refreshes the bound list (#30). + Repos.Clear(); + foreach(RepoItemViewModel item in sorted) + { + Repos.Add(item); + } + RebuildReposIndex(); - /// - /// Raised with a message that assistive technology should announce. Per-repo - /// failures use this channel; run summaries land in , - /// whose live region already announces changes. Raised on the UI thread. - /// - public event Action? AnnouncementRequested; + RebuildFilteredRepos(); // the table binds FilteredRepos, which mirrors Repos' order + } - /// Applies one engine progress report to the table. Runs on the UI thread. - private void HandleProgress(RepoProgress report) - { - if (!_itemsByName.TryGetValue(report.RepoName, out RepoItemViewModel? item)) - { - return; // rows are created at load time; ignore anything unknown - } - SyncStatus statusBefore = item.Status; - if (report.Status != SyncStatus.Queued) - { - item.Status = report.Status; - item.Error = report.Error; - item.Percent = report.Percent; - item.InvalidPaths = report.InvalidPaths; - } - // Active-strip membership is maintained incrementally: a full rescan per report - // would cost O(rows) on every progress tick. Contains/Remove stay cheap because - // the collection is bounded by MaxConcurrency. - bool isActive = item.Status is SyncStatus.Cloning or SyncStatus.Pulling; - if (isActive) - { - if (!ActiveRepos.Contains(item)) - { - ActiveRepos.Add(item); - } - } - else - { - ActiveRepos.Remove(item); - } + /// OrderBy/OrderByDescending are both stable: equal keys keep their current order. + private List OrderRepos(Func key, IComparer comparer) + => SortDescending + ? Repos.OrderByDescending(key, comparer).ToList() + : Repos.OrderBy(key, comparer).ToList(); - if (report.Status == SyncStatus.Failed) - { - // Logged already by FailureLoggingProgress on the worker thread. Failures - // never reach StatusText (which has a live region), so assistive - // technology hears them only through this explicit channel. - AnnouncementRequested?.Invoke($"{report.RepoName} failed. {report.Error}"); - } - if (report.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) - { - RecomputeCompletedCount(); - } - if (statusBefore != item.Status) - { - // Status-dependent filters follow every transition (a row entering Cloning - // leaves Pending and joins Active immediately). Only this one row can have - // changed membership, so it is inserted or removed in place — no O(n) - // rebuild, no Reset notification, no scroll jump (#30). - UpdateFilteredMembership(item); - } - } + // ---------------------------------------------------------------- progress - /// - /// Logs failed reports on the thread that produced them (the engine's worker), then - /// forwards every report to the UI-marshaled progress. FileActivityLog opens, - /// appends, and closes the file per entry; a mass failure (revoked PAT, dropped - /// network) used to put a thousand of those on the UI thread (#30). - /// - private sealed class FailureLoggingProgress(IProgress inner, IActivityLog log) - : IProgress - { - public void Report(RepoProgress value) - { - if (value.Status == SyncStatus.Failed) - { - log.Error($"{value.RepoName} failed: {value.Error}"); - } - inner.Report(value); - } - } + /// + /// Raised with a message that assistive technology should announce. Per-repo + /// failures use this channel; run summaries land in , + /// whose live region already announces changes. Raised on the UI thread. + /// + public event EventHandler? AnnouncementRequested; - /// - /// Recounts terminal rows within the captured run set (#22) — before the first run - /// the set is empty and the count stays 0. Also refreshes the failed-rows flag. - /// - private void RecomputeCompletedCount() - { - int completed = 0; - foreach (RepoItemViewModel repo in _runSet) - { - if (repo.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) - { - completed++; - } - } - CompletedCount = completed; - RecomputeHasFailedRepos(); - } - private void RecomputeHasFailedRepos() - => HasFailedRepos = Repos.Any(r => r.Status == SyncStatus.Failed); - // ---------------------------------------------------------------- filtering + /// Applies one engine progress report to the table. Runs on the UI thread. + private void HandleProgress(RepoProgress report) + { + if(!_itemsByName.TryGetValue(report.RepoName, out RepoItemViewModel? item)) + { + return; // rows are created at load time; ignore anything unknown + } - /// Refreshes the row -> position map after was reloaded or reordered. - private void RebuildReposIndex() - { - _reposIndex.Clear(); - for (int i = 0; i < Repos.Count; i++) - { - _reposIndex[Repos[i]] = i; - } - } + SyncStatus statusBefore = item.Status; + if(report.Status != SyncStatus.Queued) + { + item.Status = report.Status; + item.Error = report.Error; + item.Percent = report.Percent; + item.InvalidPaths = report.InvalidPaths; + } - /// - /// Rebuilds from , preserving the - /// table's current sort order, then re-derives the header checkbox from the - /// visible rows. Used when the filter, the table contents, or the sort order - /// change; single-row status transitions go through - /// instead. A no-op when the visible set - /// is already correct, so list controls keep their scroll position. - /// - private void RebuildFilteredRepos() - { - List desired = Repos.Where(MatchesFilter).ToList(); - if (!desired.SequenceEqual(FilteredRepos)) - { - FilteredRepos.Clear(); - _filteredSet.Clear(); - foreach (RepoItemViewModel repo in desired) - { - FilteredRepos.Add(repo); - _filteredSet.Add(repo); - } - } - UpdateAllSelectedFromItems(); - } + // Active-strip membership is maintained incrementally: a full rescan per report + // would cost O(rows) on every progress tick. Contains/Remove stay cheap because + // the collection is bounded by MaxConcurrency. + bool isActive = item.Status is SyncStatus.Cloning or SyncStatus.Pulling; + if(isActive) + { + if(!ActiveRepos.Contains(item)) + { + ActiveRepos.Add(item); + } + } + else + { + ActiveRepos.Remove(item); + } - /// - /// Applies one row's current filter match to : inserts it - /// at its table position (binary search on ) when it became - /// visible, removes it when it became hidden, and does nothing when its membership - /// did not change. O(log n) instead of the former O(n) rebuild per progress report. - /// - private void UpdateFilteredMembership(RepoItemViewModel item) - { - bool visible = MatchesFilter(item); - bool wasVisible = _filteredSet.Contains(item); - if (visible == wasVisible) - { - return; - } + if(report.Status == SyncStatus.Failed) + { + // Logged already by FailureLoggingProgress on the worker thread. Failures + // never reach StatusText (which has a live region), so assistive + // technology hears them only through this explicit channel. + AnnouncementRequested?.Invoke(this, new AnnouncementEventArgs($"{report.RepoName} failed. {report.Error}")); + } - int slot = FindFilteredSlot(item); - if (visible) - { - FilteredRepos.Insert(slot, item); - _filteredSet.Add(item); - } - else - { - FilteredRepos.RemoveAt(slot); - _filteredSet.Remove(item); - } - UpdateAllSelectedFromItems(); - } + if(report.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) + { + RecomputeCompletedCount(); + } - /// - /// Index in where sits (when - /// present) or belongs (when absent), by its position in . - /// - private int FindFilteredSlot(RepoItemViewModel item) - { - int position = _reposIndex[item]; - int low = 0; - int high = FilteredRepos.Count; - while (low < high) - { - int mid = (low + high) / 2; - if (_reposIndex[FilteredRepos[mid]] < position) - { - low = mid + 1; - } - else - { - high = mid; - } - } - return low; - } + if(statusBefore != item.Status) + { + // Status-dependent filters follow every transition (a row entering Cloning + // leaves Pending and joins Active immediately). Only this one row can have + // changed membership, so it is inserted or removed in place — no O(n) + // rebuild, no Reset notification, no scroll jump (#30). + UpdateFilteredMembership(item); + } + } - private bool MatchesFilter(RepoItemViewModel repo) - => MatchesStatusFilter(repo) - && (NameFilter.Length == 0 - || repo.Name.Contains(NameFilter.Trim(), StringComparison.OrdinalIgnoreCase)) - && (BranchFilter.Length == 0 - || repo.BranchText.Contains(BranchFilter.Trim(), StringComparison.OrdinalIgnoreCase)) - && (ArchivedFilter is not { } archived || repo.IsArchived == archived); - private bool MatchesStatusFilter(RepoItemViewModel repo) => Filter switch - { - RepoFilter.Active => repo.Status is SyncStatus.Cloning or SyncStatus.Pulling, - RepoFilter.Failed => repo.Status == SyncStatus.Failed, - RepoFilter.Pending => repo.Status == SyncStatus.Queued, - _ => true, - }; + + /// + /// Logs failed reports on the thread that produced them (the engine's worker), then + /// forwards every report to the UI-marshaled progress. FileActivityLog opens, + /// appends, and closes the file per entry; a mass failure (revoked PAT, dropped + /// network) used to put a thousand of those on the UI thread (#30). + /// + private sealed class FailureLoggingProgress(IProgress inner, IActivityLog log) + : IProgress + { + public void Report(RepoProgress value) + { + if(value.Status == SyncStatus.Failed) + { + log.Error($"{value.RepoName} failed: {value.Error}"); + } + inner.Report(value); + } + } + + + + /// + /// Recounts terminal rows within the captured run set (#22) — before the first run + /// the set is empty and the count stays 0. Also refreshes the failed-rows flag. + /// + private void RecomputeCompletedCount() + { + int completed = 0; + foreach(RepoItemViewModel repo in _runSet) + { + if(repo.Status is SyncStatus.Done or SyncStatus.Failed or SyncStatus.Canceled) + { + completed++; + } + } + CompletedCount = completed; + RecomputeHasFailedRepos(); + } + + + + private void RecomputeHasFailedRepos() + => HasFailedRepos = Repos.Any(r => r.Status == SyncStatus.Failed); + + + + // ---------------------------------------------------------------- filtering + + /// Refreshes the row -> position map after was reloaded or reordered. + private void RebuildReposIndex() + { + _reposIndex.Clear(); + for(int i = 0; i < Repos.Count; i++) + { + _reposIndex[Repos[i]] = i; + } + } + + + + /// + /// Rebuilds from , preserving the + /// table's current sort order, then re-derives the header checkbox from the + /// visible rows. Used when the filter, the table contents, or the sort order + /// change; single-row status transitions go through + /// instead. A no-op when the visible set + /// is already correct, so list controls keep their scroll position. + /// + private void RebuildFilteredRepos() + { + var desired = Repos.Where(MatchesFilter).ToList(); + if(!desired.SequenceEqual(FilteredRepos)) + { + FilteredRepos.Clear(); + _filteredSet.Clear(); + foreach(RepoItemViewModel repo in desired) + { + FilteredRepos.Add(repo); + _filteredSet.Add(repo); + } + } + UpdateAllSelectedFromItems(); + } + + + + /// + /// Applies one row's current filter match to : inserts it + /// at its table position (binary search on ) when it became + /// visible, removes it when it became hidden, and does nothing when its membership + /// did not change. O(log n) instead of the former O(n) rebuild per progress report. + /// + private void UpdateFilteredMembership(RepoItemViewModel item) + { + bool visible = MatchesFilter(item); + bool wasVisible = _filteredSet.Contains(item); + if(visible == wasVisible) + { + return; + } + + int slot = FindFilteredSlot(item); + if(visible) + { + FilteredRepos.Insert(slot, item); + _filteredSet.Add(item); + } + else + { + FilteredRepos.RemoveAt(slot); + _filteredSet.Remove(item); + } + UpdateAllSelectedFromItems(); + } + + + + /// + /// Index in where sits (when + /// present) or belongs (when absent), by its position in . + /// + private int FindFilteredSlot(RepoItemViewModel item) + { + int position = _reposIndex[item]; + int low = 0; + int high = FilteredRepos.Count; + while(low < high) + { + int mid = (low + high) / 2; + if(_reposIndex[FilteredRepos[mid]] < position) + { + low = mid + 1; + } + else + { + high = mid; + } + } + return low; + } + + + + private bool MatchesFilter(RepoItemViewModel repo) + => MatchesStatusFilter(repo) + && (NameFilter.Length == 0 + || repo.Name.Contains(NameFilter.Trim(), StringComparison.OrdinalIgnoreCase)) + && (BranchFilter.Length == 0 + || repo.BranchText.Contains(BranchFilter.Trim(), StringComparison.OrdinalIgnoreCase)) + && (ArchivedFilter is not { } archived || repo.IsArchived == archived); + + + + private bool MatchesStatusFilter(RepoItemViewModel repo) => Filter switch + { + RepoFilter.Active => repo.Status is SyncStatus.Cloning or SyncStatus.Pulling, + RepoFilter.Failed => repo.Status == SyncStatus.Failed, + RepoFilter.Pending => repo.Status == SyncStatus.Queued, + _ => true, + }; } diff --git a/gclo.ViewModels/gclo.ViewModels.csproj b/gclo.ViewModels/gclo.ViewModels.csproj index 10edce3..1392ecb 100644 --- a/gclo.ViewModels/gclo.ViewModels.csproj +++ b/gclo.ViewModels/gclo.ViewModels.csproj @@ -1,11 +1,11 @@ - + - + diff --git a/gclo.ViewModels/packages.lock.json b/gclo.ViewModels/packages.lock.json index 9b9b55d..05d9365 100644 --- a/gclo.ViewModels/packages.lock.json +++ b/gclo.ViewModels/packages.lock.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "dependencies": { "net10.0": { "CommunityToolkit.Mvvm": { @@ -8,30 +8,38 @@ "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": { "LibGit2Sharp": "[0.32.0, )", "Octokit": "[14.0.0, )" } + }, + "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==" } } } diff --git a/gclo/AboutDialog.xaml.cs b/gclo/AboutDialog.xaml.cs index 0338efc..016dbaa 100644 --- a/gclo/AboutDialog.xaml.cs +++ b/gclo/AboutDialog.xaml.cs @@ -1,53 +1,55 @@ -using System; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Collections.Generic; using System.Reflection; using Microsoft.UI.Xaml.Controls; -namespace gclo + +namespace gclo; + + +/// +/// About dialog: app identity and version, project link, MIT license notice, +/// and third-party attributions. The caller must set XamlRoot +/// before calling ShowAsync. +/// +public sealed partial class AboutDialog : ContentDialog { - /// One third-party component credited in the About dialog. - public sealed record Attribution(string Name, string License) - { - /// The license as rendered after the name. - public string LicenseText => " — " + License; - } + /// + /// The components gclo ships with. A dependency change is a one-line edit here; + /// the dialog renders them uniformly from a single template. + /// + public IReadOnlyList Attributions { get; } = + [ + new("Octokit", "MIT License"), + new("LibGit2Sharp", "MIT License"), + new("libgit2 (native library)", "GPLv2 with Linking Exception"), + new("CommunityToolkit.Mvvm", "MIT License"), + new("Velopack", "MIT License"), + new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"), + new(".NET Runtime", "MIT License"), + ]; - /// - /// About dialog: app identity and version, project link, MIT license notice, - /// and third-party attributions. The caller must set - /// before calling ShowAsync. - /// - public sealed partial class AboutDialog : ContentDialog - { - /// - /// The components gclo ships with. A dependency change is a one-line edit here; - /// the dialog renders them uniformly from a single template. - /// - public IReadOnlyList Attributions { get; } = - [ - new("Octokit", "MIT License"), - new("LibGit2Sharp", "MIT License"), - new("libgit2 (native library)", "GPLv2 with Linking Exception"), - new("CommunityToolkit.Mvvm", "MIT License"), - new("Velopack", "MIT License"), - new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"), - new(".NET Runtime", "MIT License"), - ]; - public AboutDialog() - { - InitializeComponent(); - VersionText.Text = "Version " + GetAppVersion(); - } - /// - /// Returns the build identity synced to the release pipeline: the semantic - /// version CI injects for the tag (0.1.0-dev on local builds) plus the short - /// git commit, e.g. "0.1.0-beta.6 (9ac6c2b)". The MSIX package version is - /// deliberately not used — its numeric quad cannot carry prerelease tags or - /// commit hashes, so it cannot distinguish a local build from a release. - /// - private static string GetAppVersion() - => gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly()); - } + public AboutDialog() + { + InitializeComponent(); + VersionText.Text = "Version " + GetAppVersion(); + } + + + + /// + /// Returns the build identity synced to the release pipeline: the semantic + /// version CI injects for the tag (0.1.0-dev on local builds) plus the short + /// git commit, e.g. "0.1.0-beta.6 (9ac6c2b)". The MSIX package version is + /// deliberately not used — its numeric quad cannot carry prerelease tags or + /// commit hashes, so it cannot distinguish a local build from a release. + /// + private static string GetAppVersion() + => gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly()); } diff --git a/gclo/AccessibleTableView.cs b/gclo/AccessibleTableView.cs index c24793c..b59c6e6 100644 --- a/gclo/AccessibleTableView.cs +++ b/gclo/AccessibleTableView.cs @@ -1,118 +1,54 @@ -using System.Collections.Generic; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using WinUI.TableView; -namespace gclo + +namespace gclo; + + +/// +/// whose UI Automation tree includes the column header row. +/// WinUI.TableView 1.5.0 gives headers their own peers (Invoke sorts, the name +/// composes the sort state) but inherits ListViewAutomationPeer's children, +/// which are the rows only: the header row, its headers, and anything hosted in +/// them (the filter funnels here) are unreachable by tree walking, and the Table +/// pattern's ColumnHeaders property fails for the same reason. This peer +/// prepends the header row, so screen readers and the FlaUI suite see the table +/// the way the hand-rolled header was seen before (#33). +/// +public sealed partial class AccessibleTableView : TableView { - /// - /// whose UI Automation tree includes the column header row. - /// WinUI.TableView 1.5.0 gives headers their own peers (Invoke sorts, the name - /// composes the sort state) but inherits ListViewAutomationPeer's children, - /// which are the rows only: the header row, its headers, and anything hosted in - /// them (the filter funnels here) are unreachable by tree walking, and the Table - /// pattern's ColumnHeaders property fails for the same reason. This peer - /// prepends the header row, so screen readers and the FlaUI suite see the table - /// the way the hand-rolled header was seen before (#33). - /// - public sealed partial class AccessibleTableView : TableView - { - protected override AutomationPeer OnCreateAutomationPeer() => new AccessibleTableViewPeer(this); + protected override AutomationPeer OnCreateAutomationPeer() => new AccessibleTableViewPeer(this); - /// The template's header row, once the template has been applied; null before. - internal TableViewHeaderRow? FindHeaderRow() => FindDescendant(this); - private static T? FindDescendant(DependencyObject root) where T : DependencyObject - { - int count = VisualTreeHelper.GetChildrenCount(root); - for (int i = 0; i < count; i++) - { - DependencyObject child = VisualTreeHelper.GetChild(root, i); - if (child is T match) - { - return match; - } - if (child is not ItemsPresenter && FindDescendant(child) is { } nested) - { - return nested; // rows live under the ItemsPresenter; the header row does not - } - } - return null; - } - } - /// See . - public sealed partial class AccessibleTableViewPeer : WinUI.TableView.AutomationPeers.TableViewAutomationPeer - { - private readonly AccessibleTableView _owner; + /// The template's header row, once the template has been applied; null before. + internal TableViewHeaderRow? FindHeaderRow() => FindDescendant(this); - public AccessibleTableViewPeer(AccessibleTableView owner) - : base(owner) - { - _owner = owner; - } - protected override IList GetChildrenCore() - { - IList children = base.GetChildrenCore() ?? new List(); - if (_owner.FindHeaderRow() is not { } headerRow) - { - return children; - } - // TableViewHeaderRow is a plain Control with no peer of its own, so its - // column headers (which do have peers) become direct children of the table. - var withHeaders = new List(children.Count + 8); - foreach (TableViewColumnHeader header in FindAll(headerRow)) - { - if (FrameworkElementAutomationPeer.CreatePeerForElement(header) is { } headerPeer) - { - withHeaders.Add(headerPeer); - } - } - withHeaders.AddRange(children); - return withHeaders; - } - - private static IEnumerable FindAll(DependencyObject root) where T : DependencyObject - { - int count = VisualTreeHelper.GetChildrenCount(root); - for (int i = 0; i < count; i++) - { - DependencyObject child = VisualTreeHelper.GetChild(root, i); - if (child is T match) - { - yield return match; - continue; - } - foreach (T nested in FindAll(child)) - { - yield return nested; - } - } - } - } - - /// - /// Header content for a table column: the title text plus whatever the column adds - /// beside it (the filter funnel). TableView names each cell from its column's - /// header object, so the override makes that "Name, Row 3, alpha" rather than the - /// Grid's type name. - /// - public sealed partial class ColumnHeaderContent : Grid - { - public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( - nameof(Title), typeof(string), typeof(ColumnHeaderContent), new PropertyMetadata("")); - - /// The column title, as read by assistive technology. - public string Title - { - get => (string)GetValue(TitleProperty); - set => SetValue(TitleProperty, value); - } - - public override string ToString() => Title; - } + private static T? FindDescendant(DependencyObject root) where T : DependencyObject + { + int count = VisualTreeHelper.GetChildrenCount(root); + for(int i = 0; i < count; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(root, i); + if(child is T match) + { + return match; + } + if(child is not ItemsPresenter && FindDescendant(child) is { } nested) + { + return nested; // rows live under the ItemsPresenter; the header row does not + } + } + return null; + } } diff --git a/gclo/AccessibleTableViewPeer.cs b/gclo/AccessibleTableViewPeer.cs new file mode 100644 index 0000000..5c9d726 --- /dev/null +++ b/gclo/AccessibleTableViewPeer.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Collections.Generic; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Media; +using WinUI.TableView; + + +namespace gclo; + + +/// See . +public sealed partial class AccessibleTableViewPeer : WinUI.TableView.AutomationPeers.TableViewAutomationPeer +{ + private readonly AccessibleTableView _owner; + + + + public AccessibleTableViewPeer(AccessibleTableView owner) + : base(owner) + { + _owner = owner; + } + + + + protected override IList GetChildrenCore() + { + IList children = base.GetChildrenCore() ?? new List(); + if(_owner.FindHeaderRow() is not { } headerRow) + { + return children; + } + + // TableViewHeaderRow is a plain Control with no peer of its own, so its + // column headers (which do have peers) become direct children of the table. + var withHeaders = new List(children.Count + 8); + foreach(TableViewColumnHeader header in FindAll(headerRow)) + { + if(FrameworkElementAutomationPeer.CreatePeerForElement(header) is { } headerPeer) + { + withHeaders.Add(headerPeer); + } + } + withHeaders.AddRange(children); + return withHeaders; + } + + + + private static IEnumerable FindAll(DependencyObject root) where T : DependencyObject + { + int count = VisualTreeHelper.GetChildrenCount(root); + for(int i = 0; i < count; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(root, i); + if(child is T match) + { + yield return match; + continue; + } + foreach(T nested in FindAll(child)) + { + yield return nested; + } + } + } +} diff --git a/gclo/AccountWizardDialog.xaml.cs b/gclo/AccountWizardDialog.xaml.cs index a53dbb8..9119e79 100644 --- a/gclo/AccountWizardDialog.xaml.cs +++ b/gclo/AccountWizardDialog.xaml.cs @@ -1,211 +1,245 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using gclo.ViewModels; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; +using Windows.Storage; -namespace gclo + +namespace gclo; + + +/// +/// Four-step account wizard (identity, token, organization, destination) over an +/// . Next/Save and Back are the dialog's primary +/// and secondary buttons; both clicks are intercepted (deferral + args.Cancel) +/// so the dialog only closes on Cancel or after the final step saved successfully — +/// tells the caller which of the two happened. +/// +/// As with every ContentDialog, the caller must set XamlRoot before +/// ShowAsync. +/// +public sealed partial class AccountWizardDialog : ContentDialog { - /// - /// Four-step account wizard (identity, token, organization, destination) over an - /// . Next/Save and Back are the dialog's primary - /// and secondary buttons; both clicks are intercepted (deferral + args.Cancel) - /// so the dialog only closes on Cancel or after the final step saved successfully — - /// tells the caller which of the two happened. - /// - /// As with every ContentDialog, the caller must set XamlRoot before - /// ShowAsync. - /// - public sealed partial class AccountWizardDialog : ContentDialog - { - // A ContentDialog has no HWND of its own; the host window supplies one for pickers. - private readonly Func _windowHandleProvider; + // A ContentDialog has no HWND of its own; the host window supplies one for pickers. + private readonly Func _windowHandleProvider; - /// Guards Next/Back against double activation while a step handler runs. - private bool _interactionInFlight; + /// Guards Next/Back against double activation while a step handler runs. + private bool _interactionInFlight; - /// The wizard state this dialog renders; target of every x:Bind. - public AccountWizardViewModel ViewModel { get; } + /// The wizard state this dialog renders; target of every x:Bind. + public AccountWizardViewModel ViewModel { get; } - /// True once the account was saved; false when the dialog was dismissed. - public bool Saved { get; private set; } + /// True once the account was saved; false when the dialog was dismissed. + public bool Saved { get; private set; } - /// - /// Wires the dialog to its (caller-owned) view model. The - /// returns the host window's HWND, - /// needed to initialize the folder picker on the destination step. - /// - public AccountWizardDialog(AccountWizardViewModel viewModel, Func windowHandleProvider) - { - ArgumentNullException.ThrowIfNull(viewModel); - ArgumentNullException.ThrowIfNull(windowHandleProvider); - ViewModel = viewModel; - _windowHandleProvider = windowHandleProvider; - InitializeComponent(); - // PasswordBox has no reliable two-way binding, so edits are mirrored back - // by hand. The stored token of an existing account is deliberately NOT - // loaded into the box: an empty box means "keep it", and the secret never - // sits in an editable control with a reveal button (#32). - TokenBox.PlaceholderText = ViewModel.IsEditing - ? "Leave empty to keep the stored token" - : "ghp_…"; - // NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged). - ConcurrencyBox.Value = ViewModel.MaxConcurrency; - } + /// + /// Wires the dialog to its (caller-owned) view model. The + /// returns the host window's HWND, + /// needed to initialize the folder picker on the destination step. + /// + public AccountWizardDialog(AccountWizardViewModel viewModel, Func windowHandleProvider) + { + ArgumentNullException.ThrowIfNull(viewModel); + ArgumentNullException.ThrowIfNull(windowHandleProvider); + ViewModel = viewModel; + _windowHandleProvider = windowHandleProvider; + InitializeComponent(); - // ---------------------------------------------------------------- x:Bind helpers + // PasswordBox has no reliable two-way binding, so edits are mirrored back + // by hand. The stored token of an existing account is deliberately NOT + // loaded into the box: an empty box means "keep it", and the secret never + // sits in an editable control with a reveal button (#32). + TokenBox.PlaceholderText = ViewModel.IsEditing + ? "Leave empty to keep the stored token" + : "ghp_…"; - /// Shows a step panel only while it is the wizard's current step. - public Visibility StepVisible(int step, int current) - => step == current ? Visibility.Visible : Visibility.Collapsed; + // NumberBox.Value is a double; the int is mirrored by hand (see ValueChanged). + ConcurrencyBox.Value = ViewModel.MaxConcurrency; + } - /// Primary button caption: "Save" on the last step, "Next" before it. - public string PrimaryButtonLabel(int step) => step == 4 ? "Save" : "Next"; - /// Back is available everywhere but the first step. - public bool BackEnabled(int step) => step > 1; - /// Next is locked while the token step is talking to GitHub. - public bool PrimaryEnabled(bool isValidatingToken) => !isValidatingToken; + // ---------------------------------------------------------------- x:Bind helpers - /// - /// Label for the org-subfolder checkbox; names the actual organization once one is chosen. - /// - public string OrgSubfolderLabel(string organization) - => string.IsNullOrWhiteSpace(organization) - ? "Create org subfolder" - : $"Create {organization.Trim()} subfolder"; + /// Shows a step panel only while it is the wizard's current step. + public Visibility StepVisible(int step, int current) + => step == current ? Visibility.Visible : Visibility.Collapsed; - // ---------------------------------------------------------------- buttons - /// - /// Next/Save: advances one validated step, or saves and closes on the last one. - /// The dialog stays open (args.Cancel) in every case except a successful save. - /// - private async void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) - { - if (_interactionInFlight) - { - args.Cancel = true; - return; - } - ContentDialogButtonClickDeferral deferral = args.GetDeferral(); - _interactionInFlight = true; - try - { - // TryAdvanceAsync returns true on the last step without advancing; - // capture where we were to tell "advanced" from "ready to save". - bool wasLastStep = ViewModel.IsLastStep; - if (!await ViewModel.TryAdvanceAsync()) - { - args.Cancel = true; - AnnounceStepError(); - return; - } + /// Primary button caption: "Save" on the last step, "Next" before it. + public string PrimaryButtonLabel(int step) => step == 4 ? "Save" : "Next"; - if (!wasLastStep) - { - args.Cancel = true; // moved to the next step; keep the dialog open - return; - } - try - { - await ViewModel.SaveAsync(); - Saved = true; // not canceled: the dialog closes now - } - catch (Exception ex) - { - // Duplicate name raced in, disk write failed, vault rejected the - // token, ... — surface it and let the user adjust or cancel. - args.Cancel = true; - SaveErrorBar.Message = ex.Message; - SaveErrorBar.IsOpen = true; - } - } - finally - { - _interactionInFlight = false; - deferral.Complete(); - } - } - /// Back: never closes the dialog; steps the wizard back one page. - private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) - { + /// Back is available everywhere but the first step. + public bool BackEnabled(int step) => step > 1; + + + + /// Next is locked while the token step is talking to GitHub. + public bool PrimaryEnabled(bool isValidatingToken) => !isValidatingToken; + + + + /// + /// Label for the org-subfolder checkbox; names the actual organization once one is chosen. + /// + public string OrgSubfolderLabel(string organization) + => string.IsNullOrWhiteSpace(organization) + ? "Create org subfolder" + : $"Create {organization.Trim()} subfolder"; + + + + // ---------------------------------------------------------------- buttons + + /// + /// Next/Save: advances one validated step, or saves and closes on the last one. + /// The dialog stays open (args.Cancel) in every case except a successful save. + /// + private async void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + if(_interactionInFlight) + { + args.Cancel = true; + return; + } + + ContentDialogButtonClickDeferral deferral = args.GetDeferral(); + _interactionInFlight = true; + try + { + // TryAdvanceAsync returns true on the last step without advancing; + // capture where we were to tell "advanced" from "ready to save". + bool wasLastStep = ViewModel.IsLastStep; + if(!await ViewModel.TryAdvanceAsync()) + { args.Cancel = true; - if (!_interactionInFlight) - { - ViewModel.GoBack(); - } - } + AnnounceStepError(); + return; + } - // ---------------------------------------------------------------- inputs + if(!wasLastStep) + { + args.Cancel = true; // moved to the next step; keep the dialog open + return; + } - // PasswordBox does not support reliable two-way x:Bind on Password; - // mirror it into the view model by hand. - private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e) - => ViewModel.Token = ((PasswordBox)sender).Password; + try + { + await ViewModel.SaveAsync(); + Saved = true; // not canceled: the dialog closes now + } + catch(Exception ex) + { + // Duplicate name raced in, disk write failed, vault rejected the + // token, ... — surface it and let the user adjust or cancel. + args.Cancel = true; + SaveErrorBar.Message = ex.Message; + SaveErrorBar.IsOpen = true; + } + } + finally + { + _interactionInFlight = false; + deferral.Complete(); + } + } - // An editable ComboBox does not render Text set before its template loaded; - // when editing an account, step 3 would show blank despite the seeded - // organization — see EditableComboBox. - private void OrgBox_Loaded(object sender, RoutedEventArgs e) - => EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); - /// Mirrors the NumberBox's double into the view model's int. - private void ConcurrencyBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) - { - if (double.IsNaN(sender.Value)) - { - // The box was cleared: push the canonical value back so it redisplays. - sender.Value = ViewModel.MaxConcurrency; - return; - } - ViewModel.MaxConcurrency = (int)Math.Clamp( - Math.Round(sender.Value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency); - } + /// Back: never closes the dialog; steps the wizard back one page. + private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + args.Cancel = true; + if(!_interactionInFlight) + { + ViewModel.GoBack(); + } + } - private async void BrowseButton_Click(object sender, RoutedEventArgs e) - { - var picker = new Windows.Storage.Pickers.FolderPicker(); - picker.FileTypeFilter.Add("*"); // required in packaged apps - WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider()); - var folder = await picker.PickSingleFolderAsync(); - if (folder is not null) - { - ViewModel.TargetRoot = folder.Path; - } - } + // ---------------------------------------------------------------- inputs - // ---------------------------------------------------------------- announcements + // PasswordBox does not support reliable two-way x:Bind on Password; + // mirror it into the view model by hand. + private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e) + => ViewModel.Token = ((PasswordBox)sender).Password; - /// - /// Raises LiveRegionChanged on the current step's error text after a failed - /// advance — LiveSetting alone does not announce: XAML never raises the event - /// automatically. Enqueued so the binding has pushed the new text first. - /// - private void AnnounceStepError() - { - TextBlock errorText = ViewModel.Step switch - { - 1 => NameErrorText, - 2 => TokenErrorText, - 3 => OrganizationErrorText, - _ => TargetErrorText, - }; - DispatcherQueue.TryEnqueue(() => - { - var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(errorText) - ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(errorText); - peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged); - }); - } - } + + // An editable ComboBox does not render Text set before its template loaded; + // when editing an account, step 3 would show blank despite the seeded + // organization — see EditableComboBox. + private void OrgBox_Loaded(object sender, RoutedEventArgs e) + => EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); + + + + /// Mirrors the NumberBox's double into the view model's int. + private void ConcurrencyBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + if(double.IsNaN(sender.Value)) + { + // The box was cleared: push the canonical value back so it redisplays. + sender.Value = ViewModel.MaxConcurrency; + return; + } + + ViewModel.MaxConcurrency = (int)Math.Clamp( + Math.Round(sender.Value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency); + } + + + + private async void BrowseButton_Click(object sender, RoutedEventArgs e) + { + var picker = new Windows.Storage.Pickers.FolderPicker(); + picker.FileTypeFilter.Add("*"); // required in packaged apps + + WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider()); + + StorageFolder? folder = await picker.PickSingleFolderAsync(); + if(folder is not null) + { + ViewModel.TargetRoot = folder.Path; + } + } + + + + // ---------------------------------------------------------------- announcements + + /// + /// Raises LiveRegionChanged on the current step's error text after a failed + /// advance — LiveSetting alone does not announce: XAML never raises the event + /// automatically. Enqueued so the binding has pushed the new text first. + /// + private void AnnounceStepError() + { + TextBlock errorText = ViewModel.Step switch + { + 1 => NameErrorText, + 2 => TokenErrorText, + 3 => OrganizationErrorText, + _ => TargetErrorText, + }; + + DispatcherQueue.TryEnqueue(() => + { + AutomationPeer peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(errorText) + ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(errorText); + peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged); + }); + } } diff --git a/gclo/App.xaml.cs b/gclo/App.xaml.cs index 3782190..e0d81a8 100644 --- a/gclo/App.xaml.cs +++ b/gclo/App.xaml.cs @@ -1,75 +1,85 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; using gclo.ViewModels; using Microsoft.UI.Xaml; -namespace gclo + +namespace gclo; + + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +public partial class App : Application { - /// - /// Provides application-specific behavior to supplement the default Application class. - /// - public partial class App : Application - { - private Window? _window; + private Window? _window; - /// - /// Where the unhandled-exception net writes; set by MainWindow once the shared - /// activity log exists. Null only during the first instants of startup. - /// - internal static IActivityLog? CrashLog { get; set; } + /// + /// Where the unhandled-exception net writes; set by MainWindow once the shared + /// activity log exists. Null only during the first instants of startup. + /// + internal static IActivityLog? CrashLog { get; set; } - /// - /// Constructed by Program.Main inside Application.Start after VelopackApp handled - /// install/update hooks — see Program.cs. - /// - public App() - { - InitializeComponent(); - // Last-resort net: an exception escaping an async void event handler is - // rethrown on the dispatcher with no frame above it, and the process dies - // as a stowed exception (0xc000027b) with nothing in the log. The known - // collision (two ContentDialogs) is prevented at the source by - // DialogGuard; this catches whatever else slips through, records it, and - // keeps the app running. - UnhandledException += (_, e) => - { - e.Handled = true; - try - { - CrashLog?.Error($"Unhandled UI exception: {e.Message}", e.Exception); - } - catch - { - // Logging must never turn a survivable error fatal; the stowed - // Exception property itself can throw when type info was lost. - } - }; - // App-level so Application.Current.Resources theme lookups (the status - // brushes) agree with the visible theme. Only settable here, before any - // window exists; mid-session theme changes still use the root element's - // RequestedTheme, and status brushes adopt them as row statuses update. - string theme = AppSettings.Load().Theme; - if (theme == "Light") - { - RequestedTheme = ApplicationTheme.Light; - } - else if (theme == "Dark") - { - RequestedTheme = ApplicationTheme.Dark; - } - } + /// + /// Constructed by Program.Main inside Application.Start after VelopackApp handled + /// install/update hooks — see Program.cs. + /// + public App() + { + InitializeComponent(); - /// - /// Invoked when the application is launched. The branded splash is an overlay - /// inside (see for why a - /// separate splash window is a UIA crash hazard). - /// - /// Details about the launch request and process. - protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) - { - _window = new MainWindow(); - _window.Activate(); - } - } + // Last-resort net: an exception escaping an async void event handler is + // rethrown on the dispatcher with no frame above it, and the process dies + // as a stowed exception (0xc000027b) with nothing in the log. The known + // collision (two ContentDialogs) is prevented at the source by + // DialogGuard; this catches whatever else slips through, records it, and + // keeps the app running. + UnhandledException += (_, e) => + { + e.Handled = true; + try + { + CrashLog?.Error($"Unhandled UI exception: {e.Message}", e.Exception); + } + catch + { + // Logging must never turn a survivable error fatal; the stowed + // Exception property itself can throw when type info was lost. + } + }; + + // App-level so Application.Current.Resources theme lookups (the status + // brushes) agree with the visible theme. Only settable here, before any + // window exists; mid-session theme changes still use the root element's + // RequestedTheme, and status brushes adopt them as row statuses update. + string theme = AppSettings.Load().Theme; + if(string.Equals(theme, "Light", System.StringComparison.Ordinal)) + { + RequestedTheme = ApplicationTheme.Light; + } + else if(string.Equals(theme, "Dark", System.StringComparison.Ordinal)) + { + RequestedTheme = ApplicationTheme.Dark; + } + } + + + + /// + /// Invoked when the application is launched. The branded splash is an overlay + /// inside (see for why a + /// separate splash window is a UIA crash hazard). + /// + /// Details about the launch request and process. + protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + _window = new MainWindow(); + _window.Activate(); + } } diff --git a/gclo/Attribution.cs b/gclo/Attribution.cs new file mode 100644 index 0000000..ab5cd58 --- /dev/null +++ b/gclo/Attribution.cs @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo; + + +/// One third-party component credited in the About dialog. +public sealed record Attribution(string Name, string License) +{ + /// The license as rendered after the name. + public string LicenseText => " — " + License; +} diff --git a/gclo/ColumnHeaderContent.cs b/gclo/ColumnHeaderContent.cs new file mode 100644 index 0000000..95d5b32 --- /dev/null +++ b/gclo/ColumnHeaderContent.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + + +namespace gclo; + + +/// +/// Header content for a table column: the title text plus whatever the column adds +/// beside it (the filter funnel). TableView names each cell from its column's +/// header object, so the override makes that "Name, Row 3, alpha" rather than the +/// Grid's type name. +/// +public sealed partial class ColumnHeaderContent : Grid +{ + public static readonly DependencyProperty TitleProperty = DependencyProperty.Register( + nameof(Title), typeof(string), typeof(ColumnHeaderContent), new PropertyMetadata("")); + + + + /// The column title, as read by assistive technology. + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + + + public override string ToString() => Title; +} diff --git a/gclo/ConnectionFormControl.xaml.cs b/gclo/ConnectionFormControl.xaml.cs index f6bca28..02ca338 100644 --- a/gclo/ConnectionFormControl.xaml.cs +++ b/gclo/ConnectionFormControl.xaml.cs @@ -1,113 +1,140 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using gclo.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Windows.Storage; -namespace gclo + +namespace gclo; + + +/// +/// The connection form (token, organization, target folder, org subfolder, path +/// preview) over a . One definition for the connect +/// card and the chip's Edit flyout, which used to carry diverging copies (#31). +/// gives each host its own UIA ids +/// ("ConnectTokenBox", "EditTokenBox", ...). +/// +public sealed partial class ConnectionFormControl : UserControl { - /// - /// The connection form (token, organization, target folder, org subfolder, path - /// preview) over a . One definition for the connect - /// card and the chip's Edit flyout, which used to carry diverging copies (#31). - /// gives each host its own UIA ids - /// ("ConnectTokenBox", "EditTokenBox", ...). - /// - public sealed partial class ConnectionFormControl : UserControl - { - /// The workspace the form edits; set by the host before the control loads. - public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( - nameof(ViewModel), typeof(WorkspaceViewModel), typeof(ConnectionFormControl), new PropertyMetadata(null)); + /// The workspace the form edits; set by the host before the control loads. + public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( + nameof(ViewModel), typeof(WorkspaceViewModel), typeof(ConnectionFormControl), new PropertyMetadata(null)); - /// Prefix for the form's AutomationIds, e.g. "Connect" or "Edit". - public static readonly DependencyProperty AutomationIdPrefixProperty = DependencyProperty.Register( - nameof(AutomationIdPrefix), typeof(string), typeof(ConnectionFormControl), new PropertyMetadata("")); + /// Prefix for the form's AutomationIds, e.g. "Connect" or "Edit". + public static readonly DependencyProperty AutomationIdPrefixProperty = DependencyProperty.Register( + nameof(AutomationIdPrefix), typeof(string), typeof(ConnectionFormControl), new PropertyMetadata("")); - public ConnectionFormControl() - { - InitializeComponent(); - } - /// The workspace state this form edits; target of every x:Bind. - public WorkspaceViewModel ViewModel - { - get => (WorkspaceViewModel)GetValue(ViewModelProperty); - set => SetValue(ViewModelProperty, value); - } - /// Prefix for the form's AutomationIds, e.g. "Connect" or "Edit". - public string AutomationIdPrefix - { - get => (string)GetValue(AutomationIdPrefixProperty); - set => SetValue(AutomationIdPrefixProperty, value); - } + public ConnectionFormControl() + { + InitializeComponent(); + } - /// - /// Supplies the host window's HWND for the folder picker (a UserControl has no - /// HWND of its own). Set by the host; Browse is a no-op without it. - /// - public Func? WindowHandleProvider { get; set; } - /// Composes an AutomationId from the host's prefix. - public string Id(string suffix) => AutomationIdPrefix + suffix; - /// Label for the org-subfolder checkbox; names the actual organization once one is chosen. - public string OrgSubfolderLabel(string organization) - => string.IsNullOrWhiteSpace(organization) - ? "Create org subfolder" - : $"Create {organization.Trim()} subfolder"; + /// The workspace state this form edits; target of every x:Bind. + public WorkspaceViewModel ViewModel + { + get => (WorkspaceViewModel)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } - // Runs on every host load (the card once, the flyout on each open). A token the - // view model already holds (an account's vault token, or the saved default - // token) is deliberately NOT mirrored into the box: the placeholder says what - // is in effect, and an untouched box keeps it. Only typing replaces (#32). - private void OnLoaded(object sender, RoutedEventArgs e) - { - TokenBox.Password = ""; - TokenBox.PlaceholderText = ViewModel is null || ViewModel.Token.Length == 0 - ? "ghp_…" - : ViewModel.AccountId is null - ? "Using the saved default token — type here to replace it" - : "Using the account's stored token — type here to replace it"; - } - // PasswordBox does not support reliable two-way x:Bind on Password; mirror it - // into the view model by hand. - private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e) - { - if (ViewModel is not null) - { - ViewModel.Token = ((PasswordBox)sender).Password; - } - } - // An editable ComboBox does not render Text that was set before its template - // loaded, so an account workspace's seeded org would show as blank even though - // the view model holds it — see EditableComboBox. - private void OrgBox_Loaded(object sender, RoutedEventArgs e) - { - if (ViewModel is not null) - { - EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); - } - } + /// Prefix for the form's AutomationIds, e.g. "Connect" or "Edit". + public string AutomationIdPrefix + { + get => (string)GetValue(AutomationIdPrefixProperty); + set => SetValue(AutomationIdPrefixProperty, value); + } - private async void BrowseButton_Click(object sender, RoutedEventArgs e) - { - if (ViewModel is null || WindowHandleProvider is not { } handle) - { - return; - } - var picker = new Windows.Storage.Pickers.FolderPicker(); - picker.FileTypeFilter.Add("*"); // required in packaged apps - WinRT.Interop.InitializeWithWindow.Initialize(picker, handle()); + /// + /// Supplies the host window's HWND for the folder picker (a UserControl has no + /// HWND of its own). Set by the host; Browse is a no-op without it. + /// + public Func? WindowHandleProvider { get; set; } - var folder = await picker.PickSingleFolderAsync(); - if (folder is not null) - { - ViewModel.TargetFolder = folder.Path; - } - } - } + + + /// Composes an AutomationId from the host's prefix. + public string Id(string suffix) => AutomationIdPrefix + suffix; + + + + /// Label for the org-subfolder checkbox; names the actual organization once one is chosen. + public string OrgSubfolderLabel(string organization) + => string.IsNullOrWhiteSpace(organization) + ? "Create org subfolder" + : $"Create {organization.Trim()} subfolder"; + + + + // Runs on every host load (the card once, the flyout on each open). A token the + // view model already holds (an account's vault token, or the saved default + // token) is deliberately NOT mirrored into the box: the placeholder says what + // is in effect, and an untouched box keeps it. Only typing replaces (#32). + private void OnLoaded(object sender, RoutedEventArgs e) + { + TokenBox.Password = ""; + TokenBox.PlaceholderText = ViewModel is null || ViewModel.Token.Length == 0 + ? "ghp_…" + : ViewModel.AccountId is null + ? "Using the saved default token — type here to replace it" + : "Using the account's stored token — type here to replace it"; + } + + + + // PasswordBox does not support reliable two-way x:Bind on Password; mirror it + // into the view model by hand. + private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e) + { + if(ViewModel is not null) + { + ViewModel.Token = ((PasswordBox)sender).Password; + } + } + + + + // An editable ComboBox does not render Text that was set before its template + // loaded, so an account workspace's seeded org would show as blank even though + // the view model holds it — see EditableComboBox. + private void OrgBox_Loaded(object sender, RoutedEventArgs e) + { + if(ViewModel is not null) + { + EditableComboBox.ReapplyText((ComboBox)sender, ViewModel.Organization); + } + } + + + + private async void BrowseButton_Click(object sender, RoutedEventArgs e) + { + if(ViewModel is null || WindowHandleProvider is not { } handle) + { + return; + } + + var picker = new Windows.Storage.Pickers.FolderPicker(); + picker.FileTypeFilter.Add("*"); // required in packaged apps + + WinRT.Interop.InitializeWithWindow.Initialize(picker, handle()); + + StorageFolder? folder = await picker.PickSingleFolderAsync(); + if(folder is not null) + { + ViewModel.TargetFolder = folder.Path; + } + } } diff --git a/gclo/DialogGuard.cs b/gclo/DialogGuard.cs index e279363..d18a88e 100644 --- a/gclo/DialogGuard.cs +++ b/gclo/DialogGuard.cs @@ -1,46 +1,56 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; // the GetAwaiter extension for IAsyncOperation lives here (CsWinRT) using System.Threading.Tasks; using Microsoft.UI.Xaml.Controls; -namespace gclo + +namespace gclo; + + +/// +/// Serializes ContentDialog display. WinUI allows a single open ContentDialog +/// per XamlRoot, and a second ShowAsync does not queue — it throws a +/// COMException which, escaping an async void event handler, kills the process +/// with a stowed exception (0xc000027b). Every dialog in the app opens through +/// , which ignores the request while another dialog is +/// on screen — the same no-op a user expects from commands behind a modal. +/// All calls run on the single UI thread, so the flag needs no locking. +/// +internal static class DialogGuard { - /// - /// Serializes ContentDialog display. WinUI allows a single open ContentDialog - /// per XamlRoot, and a second ShowAsync does not queue — it throws a - /// COMException which, escaping an async void event handler, kills the process - /// with a stowed exception (0xc000027b). Every dialog in the app opens through - /// , which ignores the request while another dialog is - /// on screen — the same no-op a user expects from commands behind a modal. - /// All calls run on the single UI thread, so the flag needs no locking. - /// - internal static class DialogGuard - { - private static bool _dialogOpen; + private static bool s_dialogOpen; - /// True while a dialog shown through is on screen. - public static bool IsDialogOpen => _dialogOpen; - /// - /// Shows unless another dialog is on screen. - /// Returns the dialog result, or null when the request was ignored — - /// callers treat null like a dismissal. - /// - public static async Task ShowAsync(ContentDialog dialog) - { - if (_dialogOpen) - { - return null; - } - _dialogOpen = true; - try - { - return await dialog.ShowAsync(); - } - finally - { - _dialogOpen = false; - } - } - } + /// True while a dialog shown through is on screen. + public static bool IsDialogOpen => s_dialogOpen; + + + + /// + /// Shows unless another dialog is on screen. + /// Returns the dialog result, or null when the request was ignored — + /// callers treat null like a dismissal. + /// + public static async Task ShowAsync(ContentDialog dialog) + { + if(s_dialogOpen) + { + return null; + } + + s_dialogOpen = true; + try + { + return await dialog.ShowAsync(); + } + finally + { + s_dialogOpen = false; + } + } } diff --git a/gclo/EditableComboBox.cs b/gclo/EditableComboBox.cs index 0e77827..1bdf4ce 100644 --- a/gclo/EditableComboBox.cs +++ b/gclo/EditableComboBox.cs @@ -1,60 +1,68 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; -namespace gclo -{ - /// - /// Workaround for a WinUI platform quirk: an editable ComboBox never renders - /// Text that was bound before its template loaded — the value sits in the Text - /// property but the template's inner TextBox stays empty, so a seeded value - /// (an account workspace's organization) looks blank. Re-assigning Text is an - /// identical-value no-op, so the inner TextBox must be written directly. - /// - internal static class EditableComboBox - { - /// - /// Pushes into the combo's template TextBox (and the - /// Text property, for good measure). Call from the control's Loaded handler; - /// enqueued once when the template has not been applied yet at that point. - /// - public static void ReapplyText(ComboBox combo, string text) - { - if (FindInnerTextBox(combo) is TextBox inner) - { - inner.Text = text; - inner.SelectionStart = text.Length; - } - else - { - // Template not applied yet: try once more after this layout pass. - combo.DispatcherQueue.TryEnqueue(() => - { - if (FindInnerTextBox(combo) is TextBox late) - { - late.Text = text; - late.SelectionStart = text.Length; - } - }); - } - } - private static TextBox? FindInnerTextBox(DependencyObject root) - { - int count = VisualTreeHelper.GetChildrenCount(root); - for (int i = 0; i < count; i++) +namespace gclo; + + +/// +/// Workaround for a WinUI platform quirk: an editable ComboBox never renders +/// Text that was bound before its template loaded — the value sits in the Text +/// property but the template's inner TextBox stays empty, so a seeded value +/// (an account workspace's organization) looks blank. Re-assigning Text is an +/// identical-value no-op, so the inner TextBox must be written directly. +/// +internal static class EditableComboBox +{ + /// + /// Pushes into the combo's template TextBox (and the + /// Text property, for good measure). Call from the control's Loaded handler; + /// enqueued once when the template has not been applied yet at that point. + /// + public static void ReapplyText(ComboBox combo, string text) + { + if(FindInnerTextBox(combo) is TextBox inner) + { + inner.Text = text; + inner.SelectionStart = text.Length; + } + else + { + // Template not applied yet: try once more after this layout pass. + combo.DispatcherQueue.TryEnqueue(() => + { + if(FindInnerTextBox(combo) is TextBox late) { - DependencyObject child = VisualTreeHelper.GetChild(root, i); - if (child is TextBox box) - { - return box; - } - if (FindInnerTextBox(child) is TextBox nested) - { - return nested; - } + late.Text = text; + late.SelectionStart = text.Length; } - return null; - } - } + }); + } + } + + + + private static TextBox? FindInnerTextBox(DependencyObject root) + { + int count = VisualTreeHelper.GetChildrenCount(root); + for(int i = 0; i < count; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(root, i); + if(child is TextBox box) + { + return box; + } + if(FindInnerTextBox(child) is TextBox nested) + { + return nested; + } + } + return null; + } } diff --git a/gclo/LogWindow.xaml.cs b/gclo/LogWindow.xaml.cs index 69e632a..4f1087d 100644 --- a/gclo/LogWindow.xaml.cs +++ b/gclo/LogWindow.xaml.cs @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.Collections.Generic; using System.IO; @@ -5,171 +10,195 @@ using System.Text.RegularExpressions; using gclo.Engine; using Microsoft.UI.Xaml; -namespace gclo + +namespace gclo; + + +/// +/// Non-modal activity-log window: stays open beside the main window while syncs +/// run, tails the current log file live (timer + follow mode), and shows the raw +/// technical text — timestamps, levels, full exception stacks — in monospace. +/// keeps at most one instance and re-activates it. +/// +public sealed partial class LogWindow : Window { - /// - /// Non-modal activity-log window: stays open beside the main window while syncs - /// run, tails the current log file live (timer + follow mode), and shows the raw - /// technical text — timestamps, levels, full exception stacks — in monospace. - /// keeps at most one instance and re-activates it. - /// - public sealed partial class LogWindow : Window - { - private const int TailLineCount = 2000; - private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(1); + private const int TailLineCount = 2000; - private readonly IActivityLog _log; - private readonly DispatcherTimer _refresh = new() { Interval = RefreshInterval }; + private static readonly TimeSpan s_refreshInterval = TimeSpan.FromSeconds(1); - private long _lastLength = -1; - private bool _errorsOnly; + private readonly IActivityLog _log; - public LogWindow(IActivityLog log) - { - ArgumentNullException.ThrowIfNull(log); - _log = log; - InitializeComponent(); + private readonly DispatcherTimer _refresh = new() { Interval = s_refreshInterval }; - LogPathText.Text = _log.CurrentLogFilePath; + private long _lastLength = -1; - var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - double scale = GetDpiForWindow(hwnd) / 96.0; - AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(680 * scale))); + private bool _errorsOnly; - _refresh.Tick += (_, _) => RefreshIfChanged(); - _refresh.Start(); - Closed += (_, _) => _refresh.Stop(); + + public LogWindow(IActivityLog log) + { + ArgumentNullException.ThrowIfNull(log); + _log = log; + InitializeComponent(); + + LogPathText.Text = _log.CurrentLogFilePath; + + nint hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + double scale = GetDpiForWindow(hwnd) / 96.0; + AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(680 * scale))); + + _refresh.Tick += (_, _) => RefreshIfChanged(); + _refresh.Start(); + Closed += (_, _) => _refresh.Stop(); + + Reload(scrollToEnd: true); + } + + + + private void ErrorsOnlyToggle_Click(object sender, RoutedEventArgs e) + { + _errorsOnly = ErrorsOnlyToggle.IsChecked == true; + Reload(scrollToEnd: true); + } + + + + private void CopyButton_Click(object sender, RoutedEventArgs e) + { + var package = new Windows.ApplicationModel.DataTransfer.DataPackage(); + package.SetText(LogText.Text); + Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package); + } + + + + private async void OpenLogsFolder_Click(object sender, RoutedEventArgs e) + { + string directory = _log.LogDirectory; + if(Directory.Exists(directory)) + { + await Windows.System.Launcher.LaunchFolderPathAsync(directory); + } + } + + + + /// Timer tick: reload only when the file grew (or rolled) and Follow is on. + private void RefreshIfChanged() + { + if(FollowToggle.IsChecked != true) + { + return; + } + + try + { + string path = _log.CurrentLogFilePath; + long length = File.Exists(path) ? new FileInfo(path).Length : 0; + if(length != _lastLength) + { + LogPathText.Text = path; // the day can roll over while the window is open Reload(scrollToEnd: true); - } + } + } + catch + { + // Diagnostics UI must never take the app down over an unreadable file. + } + } - private void ErrorsOnlyToggle_Click(object sender, RoutedEventArgs e) - { - _errorsOnly = ErrorsOnlyToggle.IsChecked == true; - Reload(scrollToEnd: true); - } - private void CopyButton_Click(object sender, RoutedEventArgs e) - { - var package = new Windows.ApplicationModel.DataTransfer.DataPackage(); - package.SetText(LogText.Text); - Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package); - } - private async void OpenLogsFolder_Click(object sender, RoutedEventArgs e) - { - string directory = _log.LogDirectory; - if (Directory.Exists(directory)) + private void Reload(bool scrollToEnd) + { + IReadOnlyList lines = ReadTail(_log.CurrentLogFilePath, out _lastLength); + if(_errorsOnly) + { + lines = FilterToErrorEntries(lines); + } + + LogText.Text = lines.Count == 0 + ? (_errorsOnly ? "No errors logged. Good." : "No log entries yet.") + : string.Join(Environment.NewLine, lines); + + if(scrollToEnd) + { + LogScroll.UpdateLayout(); + LogScroll.ChangeView(null, LogScroll.ScrollableHeight, null, disableAnimation: true); + } + } + + + + /// + /// Last lines of the file (shared for concurrent + /// appends); never throws — this is diagnostics UI. + /// + private static IReadOnlyList ReadTail(string path, out long length) + { + length = 0; + try + { + if(string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return []; + } + + using var stream = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + length = stream.Length; + using var reader = new StreamReader(stream); + + var tail = new Queue(TailLineCount); + while(reader.ReadLine() is string line) + { + if(tail.Count == TailLineCount) { - await Windows.System.Launcher.LaunchFolderPathAsync(directory); + tail.Dequeue(); } - } + tail.Enqueue(line); + } + return [.. tail]; + } + catch(Exception ex) + { + return ["Could not read the log file.", ex.Message]; + } + } - /// Timer tick: reload only when the file grew (or rolled) and Follow is on. - private void RefreshIfChanged() - { - if (FollowToggle.IsChecked != true) - { - return; - } - try - { - string path = _log.CurrentLogFilePath; - long length = File.Exists(path) ? new FileInfo(path).Length : 0; - if (length != _lastLength) - { - LogPathText.Text = path; // the day can roll over while the window is open - Reload(scrollToEnd: true); - } - } - catch - { - // Diagnostics UI must never take the app down over an unreadable file. - } - } - private void Reload(bool scrollToEnd) - { - IReadOnlyList lines = ReadTail(_log.CurrentLogFilePath, out _lastLength); - if (_errorsOnly) - { - lines = FilterToErrorEntries(lines); - } + /// + /// Keeps [ERROR] entries including their continuation lines (exception text + /// spans lines until the next timestamped entry starts). + /// + private static List FilterToErrorEntries(IReadOnlyList lines) + { + var kept = new List(); + bool inError = false; + foreach(string line in lines) + { + if(EntryStart().IsMatch(line)) + { + inError = line.Contains("[ERROR]", StringComparison.Ordinal); + } + if(inError) + { + kept.Add(line); + } + } + return kept; + } - LogText.Text = lines.Count == 0 - ? (_errorsOnly ? "No errors logged. Good." : "No log entries yet.") - : string.Join(Environment.NewLine, lines); - if (scrollToEnd) - { - LogScroll.UpdateLayout(); - LogScroll.ChangeView(null, LogScroll.ScrollableHeight, null, disableAnimation: true); - } - } - /// - /// Last lines of the file (shared for concurrent - /// appends); never throws — this is diagnostics UI. - /// - private static IReadOnlyList ReadTail(string path, out long length) - { - length = 0; - try - { - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) - { - return []; - } + [GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", RegexOptions.None, 1000)] + private static partial Regex EntryStart(); - using var stream = new FileStream( - path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - length = stream.Length; - using var reader = new StreamReader(stream); - var tail = new Queue(TailLineCount); - while (reader.ReadLine() is string line) - { - if (tail.Count == TailLineCount) - { - tail.Dequeue(); - } - tail.Enqueue(line); - } - return [.. tail]; - } - catch (Exception ex) - { - return ["Could not read the log file.", ex.Message]; - } - } - /// - /// Keeps [ERROR] entries including their continuation lines (exception text - /// spans lines until the next timestamped entry starts). - /// - private static List FilterToErrorEntries(IReadOnlyList lines) - { - var kept = new List(); - bool inError = false; - foreach (string line in lines) - { - if (EntryStart().IsMatch(line)) - { - inError = line.Contains("[ERROR]", StringComparison.Ordinal); - } - if (inError) - { - kept.Add(line); - } - } - return kept; - } - - [GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")] - private static partial Regex EntryStart(); - - [System.Runtime.InteropServices.DllImport("user32.dll")] - private static extern uint GetDpiForWindow(IntPtr hwnd); - } + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr hwnd); } diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 1660efb..5cfaff0 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.Collections.Generic; using System.ComponentModel; @@ -12,1087 +17,1197 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; -namespace gclo + +namespace gclo; + + +/// +/// Shell window: the menu bar plus a navigation pane with the pinned Quick Sync +/// workspace and one workspace per saved account. Each workspace (a +/// and its ) is created +/// on first visit and cached, so switching away never interrupts a running sync; +/// pane badges surface running/failed state for the workspaces not on screen. +/// The pane's footer carries two commands: 'Sync all' (runs every account +/// sequentially via ) and 'Add account' (the +/// ); account items get an Edit/Delete context menu. +/// +public sealed partial class MainWindow : Window { - /// - /// Shell window: the menu bar plus a navigation pane with the pinned Quick Sync - /// workspace and one workspace per saved account. Each workspace (a - /// and its ) is created - /// on first visit and cached, so switching away never interrupts a running sync; - /// pane badges surface running/failed state for the workspaces not on screen. - /// The pane's footer carries two commands: 'Sync all' (runs every account - /// sequentially via ) and 'Add account' (the - /// ); account items get an Edit/Delete context menu. - /// - public sealed partial class MainWindow : Window - { - private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo"; + private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo"; - // Smallest logical (DPI-independent) size at which the workspace toolbar, - // connect card, and repo table remain usable WITH the navigation pane open: - // the pane takes 240, page and card padding 64, the fixed table columns 400, - // and the Name column needs room to show a name (#31). - private const int MinWindowWidth = 900; - private const int MinWindowHeight = 520; + // Smallest logical (DPI-independent) size at which the workspace toolbar, + // connect card, and repo table remain usable WITH the navigation pane open: + // the pane takes 240, page and card padding 64, the fixed table columns 400, + // and the Name column needs room to show a name (#31). + private const int MinWindowWidth = 900; - /// Set once the root's theme-change subscription exists. - private bool _themeHooked; + private const int MinWindowHeight = 520; - private readonly AppSettings _settings; - private readonly UpdateService _updateService; + /// Set once the root's theme-change subscription exists. + private bool _themeHooked; - /// Guards Help > Check for updates against re-entry while a check or download runs. - private bool _updateInFlight; + private readonly AppSettings _settings; - // One log, vault, and store shared by every workspace (and the log viewer), - // so all workspaces write to the same file and read the same accounts. - private readonly IActivityLog _log; - private readonly ITokenVault _tokenVault; - private readonly AccountsStore _accountsStore; + private readonly UpdateService _updateService; - /// - /// A cached workspace: the view model always (it keeps a running sync alive - /// across navigation and costs little), the page only while it is on screen - /// or its view model is busy. A page pins a whole visual tree — connect card, - /// toolbar, flyouts, a ListView's realized rows — so idle pages are released - /// when the user navigates away and rebuilt from the view model on return - /// (only transient scroll position is lost) (#30). - /// - private sealed class WorkspaceEntry(WorkspaceViewModel viewModel) - { - public WorkspaceViewModel ViewModel { get; } = viewModel; + /// Guards Help > Check for updates against re-entry while a check or download runs. + private bool _updateInFlight; - public WorkspacePage? Page { get; set; } - } + // One log, vault, and store shared by every workspace (and the log viewer), + // so all workspaces write to the same file and read the same accounts. + private readonly IActivityLog _log; - /// Workspaces created so far, keyed by account id (Guid.Empty = Quick Sync). - private readonly Dictionary _workspaces = new(); + private readonly ITokenVault _tokenVault; - /// Badge subscriptions per workspace, unhooked before the view models are disposed. - private readonly Dictionary _badgeHandlers = new(); + private readonly AccountsStore _accountsStore; - /// Navigation items by workspace id, for badge and tooltip refreshes. - private readonly Dictionary _navItems = new(); - /// The workspace the pane currently rests on; where the footer commands revert to. - private Guid _currentWorkspaceId = Guid.Empty; - /// Suppresses re-entrant SelectionChanged while the code puts a selection back. - private bool _revertingSelection; + /// + /// A cached workspace: the view model always (it keeps a running sync alive + /// across navigation and costs little), the page only while it is on screen + /// or its view model is busy. A page pins a whole visual tree — connect card, + /// toolbar, flyouts, a ListView's realized rows — so idle pages are released + /// when the user navigates away and rebuilt from the view model on return + /// (only transient scroll position is lost) (#30). + /// + private sealed class WorkspaceEntry(WorkspaceViewModel viewModel) + { + public WorkspaceViewModel ViewModel { get; } = viewModel; - /// - /// Cancels the in-flight 'Sync all' queue; null while none runs. The token source - /// itself stays local to so its lifetime is - /// contained there — only the ability to cancel escapes. - /// - private Action? _cancelSyncAll; + public WorkspacePage? Page { get; set; } + } - public MainWindow() - { - _log = new FileActivityLog(System.IO.Path.Combine(GcloPaths.DataRoot, "logs")); - App.CrashLog = _log; // the unhandled-exception net now reaches the activity log - _log.Info($"gclo {BuildVersion.Describe(typeof(MainWindow).Assembly)} starting."); - _updateService = new UpdateService(_log); - _tokenVault = new CredentialManagerVault(); - _accountsStore = new AccountsStore(_tokenVault, log: _log); - InitializeComponent(); - ApplyBackdrop(); - Title = "gclo — Git Clone Large Organizations"; + /// Workspaces created so far, keyed by account id (Guid.Empty = Quick Sync). + private readonly Dictionary _workspaces = new(); - // Quick Sync is declared in XAML; its Guid tag and the account items are - // runtime data, so they are filled in here. - QuickSyncNavItem.Tag = Guid.Empty; - _navItems[Guid.Empty] = QuickSyncNavItem; - foreach (Account account in _accountsStore.GetAll()) + /// Badge subscriptions per workspace, unhooked before the view models are disposed. + private readonly Dictionary _badgeHandlers = new(); + + /// Navigation items by workspace id, for badge and tooltip refreshes. + private readonly Dictionary _navItems = new(); + + /// The workspace the pane currently rests on; where the footer commands revert to. + private Guid _currentWorkspaceId = Guid.Empty; + + /// Suppresses re-entrant SelectionChanged while the code puts a selection back. + private bool _revertingSelection; + + /// + /// Cancels the in-flight 'Sync all' queue; null while none runs. The token source + /// itself stays local to so its lifetime is + /// contained there — only the ability to cancel escapes. + /// + private Action? _cancelSyncAll; + + + + public MainWindow() + { + _log = new FileActivityLog(System.IO.Path.Combine(GcloPaths.DataRoot, "logs")); + App.CrashLog = _log; // the unhandled-exception net now reaches the activity log + _log.Info($"gclo {BuildVersion.Describe(typeof(MainWindow).Assembly)} starting."); + _updateService = new UpdateService(_log); + _tokenVault = new CredentialManagerVault(); + _accountsStore = new AccountsStore(_tokenVault, log: _log); + + InitializeComponent(); + ApplyBackdrop(); + + Title = "gclo — Git Clone Large Organizations"; + + // Quick Sync is declared in XAML; its Guid tag and the account items are + // runtime data, so they are filled in here. + QuickSyncNavItem.Tag = Guid.Empty; + _navItems[Guid.Empty] = QuickSyncNavItem; + foreach(Account account in _accountsStore.GetAll()) + { + WorkspaceNav.MenuItems.Add(CreateAccountNavItem(account)); + } + UpdateSyncAllEnabled(); + + _settings = AppSettings.Load(); + + // Create and show Quick Sync before selecting it, so NavigationView.Content + // is never empty regardless of when SelectionChanged fires. + ShowWorkspace(Guid.Empty); + ApplySettings(seedQuickSyncConcurrency: true); + WorkspaceNav.SelectedItem = QuickSyncNavItem; + + // AppWindow.Resize takes physical pixels; scale by the monitor DPI so the + // window is the same visual size at 150%/200% display scaling. + nint hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + double scale = GetDpiForWindow(hwnd) / 96.0; + AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale))); + + // The brand icon in the title bar (and alt-tab / taskbar for unpackaged runs). + AppWindow.SetIcon(System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "Brand", "gclo.ico")); + + // Keep the window from shrinking below a usable workspace layout. + // PreferredMinimum* takes physical pixels like Resize (WinAppSDK 1.7+, + // present in the installed SDK). If a future SDK removes these + // properties, clamp instead from an AppWindow.Changed handler. + if(AppWindow.Presenter is OverlappedPresenter presenter) + { + presenter.PreferredMinimumWidth = (int)(MinWindowWidth * scale); + presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale); + } + + Closed += (_, _) => + { + _logWindow?.Close(); // a log-only process would linger otherwise + DisposeWorkspaces(); + _log.Info("gclo closing."); + }; + + // The splash overlay honors Settings → Advanced: skipped entirely when + // disabled, otherwise dismissed after the configured display time. + if(_settings.ShowSplashScreen) + { + DispatcherQueue.TryEnqueue(() => LogIfFaulted(RunStartupAfterSplashAsync())); + } + else + { + StartupSplash.Visibility = Visibility.Collapsed; + DispatcherQueue.TryEnqueue(() => LogIfFaulted(CheckForUpdatesAtStartupAsync())); + } + } + + + + /// + /// Startup continuation when the splash is shown: wait out the configured display + /// time, dismiss the overlay, then run the quiet update check. + /// + private async Task RunStartupAfterSplashAsync() + { + await Task.Delay(_settings.SplashMilliseconds); + await StartupSplash.DismissAsync(); + await CheckForUpdatesAtStartupAsync(); + } + + + + /// + /// Observes a fire-and-forget UI task exactly as the crash net observes an async + /// void handler: a fault is logged and never terminates the app. + /// + private void LogIfFaulted(Task task) + { + _ = task.ContinueWith( + faulted => _log.Error($"Unhandled UI exception: {faulted.Exception!.GetBaseException().Message}", faulted.Exception), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + + + + /// + /// A background check on every launch (installed builds only): a newer release + /// is offered in the update bar with an "Update and restart…" action that runs + /// the same flow as Help > Check for updates; nothing is shown when up to date + /// or when the check fails (the activity log records both) (#32). + /// + private async Task CheckForUpdatesAtStartupAsync() + { + if(!_updateService.IsSupported || _updateInFlight) + { + return; + } + + UpdateCheckResult result = await _updateService.CheckAsync(); + if(result.AvailableVersion is not { } version) + { + return; + } + + ShowUpdateBar(InfoBarSeverity.Informational, $"gclo v{version} is available.", busy: false); + var install = new Button { Content = "Update and restart…" }; + install.Click += async (_, _) => await RunUpdateFlowAsync(); + UpdateBar.ActionButton = install; + } + + + + /// + /// Mica where the OS supports it, acrylic where only that does, otherwise the + /// themed page background so the layered card brushes have a surface to sit on. + /// + private void ApplyBackdrop() + { + if(Microsoft.UI.Composition.SystemBackdrops.MicaController.IsSupported()) + { + SystemBackdrop = new MicaBackdrop(); + } + else if(Microsoft.UI.Composition.SystemBackdrops.DesktopAcrylicController.IsSupported()) + { + SystemBackdrop = new DesktopAcrylicBackdrop(); + } + else if(Application.Current.Resources.TryGetValue("ApplicationPageBackgroundThemeBrush", out object? brush) + && brush is Brush background) + { + RootGrid.Background = background; + } + } + + + + /// + /// Applies the theme and seeds the ad-hoc Quick Sync workspace from the settings + /// defaults (account workspaces carry their own configuration). The target + /// folder is only filled when blank; the parallelism default is pushed only + /// when — at startup, and after a + /// Settings save that actually changed it — so saving an unrelated setting + /// (theme, splash) cannot silently reset a value the user tuned in the + /// workspace's Options flyout (#30). + /// + private void ApplySettings(bool seedQuickSyncConcurrency) + { + if(Content is FrameworkElement root) + { + root.RequestedTheme = StatusFormat.ToElementTheme(_settings.Theme); + if(!_themeHooked) + { + // Covers an OS light/dark flip while running in System mode too. + root.ActualThemeChanged += (sender, _) => OnThemeChanged(sender.ActualTheme); + _themeHooked = true; + } + OnThemeChanged(root.ActualTheme); + } + + if(_workspaces.TryGetValue(Guid.Empty, out WorkspaceEntry? quickSync)) + { + WorkspaceViewModel viewModel = quickSync.ViewModel; + if(string.IsNullOrWhiteSpace(viewModel.TargetFolder) + && !string.IsNullOrWhiteSpace(_settings.DefaultTargetFolder)) + { + viewModel.TargetFolder = _settings.DefaultTargetFolder; + } + if(seedQuickSyncConcurrency) + { + viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency; + } + } + } + + + + /// + /// Keeps everything that is not a ThemeResource reference in step with the + /// effective theme: the function-bound status brushes (resolved from the + /// matching theme dictionary from now on), the rows that cache them (asked to + /// re-evaluate), the pane badges, and the title bar (#31). + /// + private void OnThemeChanged(ElementTheme actual) + { + StatusFormat.CurrentTheme = actual; + foreach((Guid id, WorkspaceEntry workspace) in _workspaces) + { + workspace.ViewModel.RefreshPresentation(); + OnWorkspaceStateChanged(id); + } + AppWindow.TitleBar.PreferredTheme = actual switch + { + ElementTheme.Light => TitleBarTheme.Light, + ElementTheme.Dark => TitleBarTheme.Dark, + _ => TitleBarTheme.UseDefaultAppMode, + }; + } + + + + // ---------------------------------------------------------------- workspaces + + private async void WorkspaceNav_SelectionChanged( + NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if(_revertingSelection || args.SelectedItem is not NavigationViewItem item) + { + return; + } + + if(item.Tag is "add") + { + // The footer entries are commands, not destinations: put the selection + // back on the current workspace, then run the command. + RevertSelectionToCurrent(); + await ShowAccountWizardAsync(existing: null); + return; + } + + if(item.Tag is "syncall") + { + RevertSelectionToCurrent(); + await ToggleSyncAllAsync(); + return; + } + + if(item.Tag is Guid id) + { + if(id != _currentWorkspaceId) + { + ReleasePageIfIdle(_currentWorkspaceId); + } + ShowWorkspace(id); + if(_workspaces.TryGetValue(id, out WorkspaceEntry? shown)) + { + _currentWorkspaceId = id; + _log.Info($"Showing workspace '{shown.ViewModel.DisplayName}'."); + } + } + } + + + + /// + /// Puts the selection back on the current workspace after a footer command + /// (a command, not a destination) was activated. + /// + private void RevertSelectionToCurrent() + { + _revertingSelection = true; + try + { + WorkspaceNav.SelectedItem = + _navItems.TryGetValue(_currentWorkspaceId, out NavigationViewItem? current) + ? current + : QuickSyncNavItem; + } + finally + { + _revertingSelection = false; + } + } + + + + /// + /// Puts the workspace for on screen, creating and caching + /// its view model on first visit and (re)building its page when none is cached. + /// Cached view models keep running syncs alive across switches. + /// + private void ShowWorkspace(Guid id) + { + if(EnsureWorkspace(id) is { } workspace) + { + workspace.Page ??= CreatePage(workspace.ViewModel); + WorkspaceNav.Content = workspace.Page; + } + } + + + + private WorkspacePage CreatePage(WorkspaceViewModel viewModel) + => new(viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) + { + EditAccountRequested = ShowEditAccountWizardAsync, + SaveAsAccountRequested = ShowSeededAccountWizardAsync, + }; + + + + /// + /// Drops the page of a workspace the user navigated away from when nothing is + /// running in it; a busy workspace keeps its page (its path-recovery dialog and + /// announcements route through it). The view model stays cached either way. + /// + private void ReleasePageIfIdle(Guid id) + { + if(!_workspaces.TryGetValue(id, out WorkspaceEntry? workspace) || workspace.Page is null) + { + return; + } + + WorkspaceViewModel viewModel = workspace.ViewModel; + if(viewModel.IsRunning || viewModel.IsLoadingRepos || viewModel.IsResolvingPaths) + { + return; + } + + workspace.Page.Detach(); + workspace.Page = null; + } + + + + /// + /// Returns the cached workspace for , creating and caching + /// its view model (badge subscription included) on first use WITHOUT building a + /// page or putting it on screen — 'Sync all' warms unvisited workspaces this + /// way and never needs the page. Null when the account no longer exists in the + /// store. + /// + private WorkspaceEntry? EnsureWorkspace(Guid id) + { + if(_workspaces.TryGetValue(id, out WorkspaceEntry? workspace)) + { + return workspace; + } + + WorkspaceViewModel? viewModel = CreateWorkspaceViewModel(id); + if(viewModel is null) + { + return null; // stale item: the account no longer exists in the store + } + + workspace = new WorkspaceEntry(viewModel); + _workspaces[id] = workspace; + + PropertyChangedEventHandler handler = (_, e) => + { + if(e.PropertyName is nameof(WorkspaceViewModel.IsRunning) + or nameof(WorkspaceViewModel.HasFailedRepos)) + { + OnWorkspaceStateChanged(id); + } + else if(e.PropertyName is nameof(WorkspaceViewModel.CompletedCount) + && viewModel.IsRunning) + { + // Live "N of M" on the running account's sidebar item, so a + // sync-all pass shows progress without switching workspaces. + if(_navItems.TryGetValue(id, out NavigationViewItem? item)) { - WorkspaceNav.MenuItems.Add(CreateAccountNavItem(account)); + ToolTipService.SetToolTip( + item, + $"Syncing: {viewModel.CompletedCount} of {viewModel.TotalCount} repositories"); } - UpdateSyncAllEnabled(); + } + }; + viewModel.PropertyChanged += handler; + _badgeHandlers[id] = handler; + return workspace; + } - _settings = AppSettings.Load(); - // Create and show Quick Sync before selecting it, so NavigationView.Content - // is never empty regardless of when SelectionChanged fires. - ShowWorkspace(Guid.Empty); - ApplySettings(seedQuickSyncConcurrency: true); - WorkspaceNav.SelectedItem = QuickSyncNavItem; - // AppWindow.Resize takes physical pixels; scale by the monitor DPI so the - // window is the same visual size at 150%/200% display scaling. - var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - double scale = GetDpiForWindow(hwnd) / 96.0; - AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(1000 * scale), (int)(750 * scale))); + private WorkspaceViewModel? CreateWorkspaceViewModel(Guid id) + { + if(id == Guid.Empty) + { + // Quick Sync: no account behind it. A saved default token (Settings) + // pre-fills the connect card; setting Token also starts the org lookup. + var quickSync = new WorkspaceViewModel( + UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()), + UiTestFixture.WrapGitClient(new LibGit2GitClient()), + UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), + log: _log); + if(_tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId) is { Length: > 0 } defaultToken) + { + quickSync.Token = defaultToken; + } + return quickSync; + } - // The brand icon in the title bar (and alt-tab / taskbar for unpackaged runs). - AppWindow.SetIcon(System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "Brand", "gclo.ico")); + Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); + if(account is null) + { + _log.Error($"No account with id {id:N}; cannot open its workspace."); + return null; + } + return new WorkspaceViewModel( + UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()), + UiTestFixture.WrapGitClient(new LibGit2GitClient()), + UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), + account: account, + tokenVault: _tokenVault, + accountsStore: _accountsStore, + log: _log); + } - // Keep the window from shrinking below a usable workspace layout. - // PreferredMinimum* takes physical pixels like Resize (WinAppSDK 1.7+, - // present in the installed SDK). If a future SDK removes these - // properties, clamp instead from an AppWindow.Changed handler. - if (AppWindow.Presenter is OverlappedPresenter presenter) - { - presenter.PreferredMinimumWidth = (int)(MinWindowWidth * scale); - presenter.PreferredMinimumHeight = (int)(MinWindowHeight * scale); - } - Closed += (_, _) => - { - _logWindow?.Close(); // a log-only process would linger otherwise - DisposeWorkspaces(); - _log.Info("gclo closing."); - }; - // The splash overlay honors Settings → Advanced: skipped entirely when - // disabled, otherwise dismissed after the configured display time. - if (_settings.ShowSplashScreen) - { - DispatcherQueue.TryEnqueue(async () => - { - await Task.Delay(_settings.SplashMilliseconds); - await StartupSplash.DismissAsync(); - await CheckForUpdatesAtStartupAsync(); - }); - } - else - { - StartupSplash.Visibility = Visibility.Collapsed; - DispatcherQueue.TryEnqueue(async () => await CheckForUpdatesAtStartupAsync()); - } - } + /// + /// Per-account queue states of the current (or most recent) sync-all pass; + /// cleared when the next pass starts. Rendered by + /// whenever the account is not running. + /// + private readonly Dictionary _syncAllStates = new(); - /// - /// A background check on every launch (installed builds only): a newer release - /// is offered in the update bar with an "Update and restart…" action that runs - /// the same flow as Help > Check for updates; nothing is shown when up to date - /// or when the check fails (the activity log records both) (#32). - /// - private async Task CheckForUpdatesAtStartupAsync() - { - if (!_updateService.IsSupported || _updateInFlight) - { - return; - } - UpdateCheckResult result = await _updateService.CheckAsync(); - if (result.AvailableVersion is not { } version) - { - return; - } - ShowUpdateBar(InfoBarSeverity.Informational, $"gclo v{version} is available.", busy: false); - var install = new Button { Content = "Update and restart…" }; - install.Click += async (_, _) => await RunUpdateFlowAsync(); - UpdateBar.ActionButton = install; - } + /// + /// Refreshes a workspace's pane badge and, after a run, its tooltip from the + /// stored summary. Badge priority: a running sync always shows the attention + /// dot; otherwise the sync-all pass state (queued, succeeded with a check, + /// failed with the failure count, skipped) when one exists; otherwise the + /// standing critical dot for lingering failures. + /// + private void OnWorkspaceStateChanged(Guid id) + { + if(!_navItems.TryGetValue(id, out NavigationViewItem? item) + || !_workspaces.TryGetValue(id, out WorkspaceEntry? workspace)) + { + return; + } - /// - /// Applies the theme and seeds the ad-hoc Quick Sync workspace from the settings - /// defaults (account workspaces carry their own configuration). The target - /// folder is only filled when blank; the parallelism default is pushed only - /// when — at startup, and after a - /// Settings save that actually changed it — so saving an unrelated setting - /// (theme, splash) cannot silently reset a value the user tuned in the - /// workspace's Options flyout (#30). - /// - /// - /// Mica where the OS supports it, acrylic where only that does, otherwise the - /// themed page background so the layered card brushes have a surface to sit on. - /// - private void ApplyBackdrop() - { - if (Microsoft.UI.Composition.SystemBackdrops.MicaController.IsSupported()) - { - SystemBackdrop = new MicaBackdrop(); - } - else if (Microsoft.UI.Composition.SystemBackdrops.DesktopAcrylicController.IsSupported()) - { - SystemBackdrop = new DesktopAcrylicBackdrop(); - } - else if (Application.Current.Resources.TryGetValue("ApplicationPageBackgroundThemeBrush", out object? brush) - && brush is Brush background) - { - RootGrid.Background = background; - } - } + WorkspaceViewModel viewModel = workspace.ViewModel; + item.InfoBadge = viewModel.IsRunning + ? CreateBadge("AttentionDotInfoBadgeStyle", "SystemFillColorAttentionBrush") + : _syncAllStates.TryGetValue(id, out SyncAllAccountState state) + ? state switch + { + SyncAllAccountState.Queued => + CreateBadge("InformationalDotInfoBadgeStyle", "SystemFillColorSolidNeutralBrush"), + SyncAllAccountState.Succeeded => + CreateBadge("SuccessIconInfoBadgeStyle", "SystemFillColorSuccessBrush"), + SyncAllAccountState.Failed => + CreateBadge( + "CriticalValueInfoBadgeStyle", + "SystemFillColorCriticalBrush", + viewModel.Repos.Count(r => r.Status == SyncStatus.Failed)), + SyncAllAccountState.Skipped => + CreateBadge("CautionDotInfoBadgeStyle", "SystemFillColorCautionBrush"), + _ => null, // Running is covered by IsRunning above + } + : viewModel.HasFailedRepos + ? CreateBadge("CriticalDotInfoBadgeStyle", "SystemFillColorCriticalBrush") + : null; - private void ApplySettings(bool seedQuickSyncConcurrency) - { - if (Content is FrameworkElement root) - { - root.RequestedTheme = StatusFormat.ToElementTheme(_settings.Theme); - if (!_themeHooked) - { - // Covers an OS light/dark flip while running in System mode too. - root.ActualThemeChanged += (sender, _) => OnThemeChanged(sender.ActualTheme); - _themeHooked = true; - } - OnThemeChanged(root.ActualTheme); - } - - if (_workspaces.TryGetValue(Guid.Empty, out var quickSync)) - { - WorkspaceViewModel viewModel = quickSync.ViewModel; - if (string.IsNullOrWhiteSpace(viewModel.TargetFolder) - && !string.IsNullOrWhiteSpace(_settings.DefaultTargetFolder)) - { - viewModel.TargetFolder = _settings.DefaultTargetFolder; - } - if (seedQuickSyncConcurrency) - { - viewModel.MaxConcurrency = _settings.DefaultMaxConcurrency; - } - } - } - - /// - /// Keeps everything that is not a ThemeResource reference in step with the - /// effective theme: the function-bound status brushes (resolved from the - /// matching theme dictionary from now on), the rows that cache them (asked to - /// re-evaluate), the pane badges, and the title bar (#31). - /// - private void OnThemeChanged(ElementTheme actual) - { - StatusFormat.CurrentTheme = actual; - foreach ((Guid id, WorkspaceEntry workspace) in _workspaces) - { - workspace.ViewModel.RefreshPresentation(); - OnWorkspaceStateChanged(id); - } - AppWindow.TitleBar.PreferredTheme = actual switch - { - ElementTheme.Light => TitleBarTheme.Light, - ElementTheme.Dark => TitleBarTheme.Dark, - _ => TitleBarTheme.UseDefaultAppMode, - }; - } - - // ---------------------------------------------------------------- workspaces - - private async void WorkspaceNav_SelectionChanged( - NavigationView sender, NavigationViewSelectionChangedEventArgs args) - { - if (_revertingSelection || args.SelectedItem is not NavigationViewItem item) - { - return; - } - - if (item.Tag is "add") - { - // The footer entries are commands, not destinations: put the selection - // back on the current workspace, then run the command. - RevertSelectionToCurrent(); - await ShowAccountWizardAsync(existing: null); - return; - } - - if (item.Tag is "syncall") - { - RevertSelectionToCurrent(); - await ToggleSyncAllAsync(); - return; - } - - if (item.Tag is Guid id) - { - if (id != _currentWorkspaceId) - { - ReleasePageIfIdle(_currentWorkspaceId); - } - ShowWorkspace(id); - if (_workspaces.TryGetValue(id, out WorkspaceEntry? shown)) - { - _currentWorkspaceId = id; - _log.Info($"Showing workspace '{shown.ViewModel.DisplayName}'."); - } - } - } - - /// - /// Puts the selection back on the current workspace after a footer command - /// (a command, not a destination) was activated. - /// - private void RevertSelectionToCurrent() - { - _revertingSelection = true; - try - { - WorkspaceNav.SelectedItem = - _navItems.TryGetValue(_currentWorkspaceId, out NavigationViewItem? current) - ? current - : QuickSyncNavItem; - } - finally - { - _revertingSelection = false; - } - } - - /// - /// Puts the workspace for on screen, creating and caching - /// its view model on first visit and (re)building its page when none is cached. - /// Cached view models keep running syncs alive across switches. - /// - private void ShowWorkspace(Guid id) - { - if (EnsureWorkspace(id) is { } workspace) - { - workspace.Page ??= CreatePage(workspace.ViewModel); - WorkspaceNav.Content = workspace.Page; - } - } - - private WorkspacePage CreatePage(WorkspaceViewModel viewModel) - => new(viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) - { - EditAccountRequested = ShowEditAccountWizardAsync, - SaveAsAccountRequested = ShowSeededAccountWizardAsync, - }; - - /// - /// Drops the page of a workspace the user navigated away from when nothing is - /// running in it; a busy workspace keeps its page (its path-recovery dialog and - /// announcements route through it). The view model stays cached either way. - /// - private void ReleasePageIfIdle(Guid id) - { - if (!_workspaces.TryGetValue(id, out WorkspaceEntry? workspace) || workspace.Page is null) - { - return; - } - - WorkspaceViewModel viewModel = workspace.ViewModel; - if (viewModel.IsRunning || viewModel.IsLoadingRepos || viewModel.IsResolvingPaths) - { - return; - } - - workspace.Page.Detach(); - workspace.Page = null; - } - - /// - /// Returns the cached workspace for , creating and caching - /// its view model (badge subscription included) on first use WITHOUT building a - /// page or putting it on screen — 'Sync all' warms unvisited workspaces this - /// way and never needs the page. Null when the account no longer exists in the - /// store. - /// - private WorkspaceEntry? EnsureWorkspace(Guid id) - { - if (_workspaces.TryGetValue(id, out WorkspaceEntry? workspace)) - { - return workspace; - } - - WorkspaceViewModel? viewModel = CreateWorkspaceViewModel(id); - if (viewModel is null) - { - return null; // stale item: the account no longer exists in the store - } - - workspace = new WorkspaceEntry(viewModel); - _workspaces[id] = workspace; - - PropertyChangedEventHandler handler = (_, e) => - { - if (e.PropertyName is nameof(WorkspaceViewModel.IsRunning) - or nameof(WorkspaceViewModel.HasFailedRepos)) - { - OnWorkspaceStateChanged(id); - } - else if (e.PropertyName is nameof(WorkspaceViewModel.CompletedCount) - && viewModel.IsRunning) - { - // Live "N of M" on the running account's sidebar item, so a - // sync-all pass shows progress without switching workspaces. - if (_navItems.TryGetValue(id, out NavigationViewItem? item)) - { - ToolTipService.SetToolTip( - item, - $"Syncing: {viewModel.CompletedCount} of {viewModel.TotalCount} repositories"); - } - } - }; - viewModel.PropertyChanged += handler; - _badgeHandlers[id] = handler; - return workspace; - } - - private WorkspaceViewModel? CreateWorkspaceViewModel(Guid id) - { - if (id == Guid.Empty) - { - // Quick Sync: no account behind it. A saved default token (Settings) - // pre-fills the connect card; setting Token also starts the org lookup. - var quickSync = new WorkspaceViewModel( - UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()), - UiTestFixture.WrapGitClient(new LibGit2GitClient()), - UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), - log: _log); - if (_tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId) is { Length: > 0 } defaultToken) - { - quickSync.Token = defaultToken; - } - return quickSync; - } - - Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); - if (account is null) - { - _log.Error($"No account with id {id:N}; cannot open its workspace."); - return null; - } - return new WorkspaceViewModel( - UiTestFixture.WrapRepositoryLister(new GitHubRepositoryLister()), - UiTestFixture.WrapGitClient(new LibGit2GitClient()), - UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), - account: account, - tokenVault: _tokenVault, - accountsStore: _accountsStore, - log: _log); - } - - /// - /// Per-account queue states of the current (or most recent) sync-all pass; - /// cleared when the next pass starts. Rendered by - /// whenever the account is not running. - /// - private readonly Dictionary _syncAllStates = new(); - - /// - /// Refreshes a workspace's pane badge and, after a run, its tooltip from the - /// stored summary. Badge priority: a running sync always shows the attention - /// dot; otherwise the sync-all pass state (queued, succeeded with a check, - /// failed with the failure count, skipped) when one exists; otherwise the - /// standing critical dot for lingering failures. - /// - private void OnWorkspaceStateChanged(Guid id) - { - if (!_navItems.TryGetValue(id, out NavigationViewItem? item) - || !_workspaces.TryGetValue(id, out WorkspaceEntry? workspace)) - { - return; - } - - WorkspaceViewModel viewModel = workspace.ViewModel; - item.InfoBadge = viewModel.IsRunning - ? CreateBadge("AttentionDotInfoBadgeStyle", "SystemFillColorAttentionBrush") - : _syncAllStates.TryGetValue(id, out SyncAllAccountState state) - ? state switch - { - SyncAllAccountState.Queued => - CreateBadge("InformationalDotInfoBadgeStyle", "SystemFillColorSolidNeutralBrush"), - SyncAllAccountState.Succeeded => - CreateBadge("SuccessIconInfoBadgeStyle", "SystemFillColorSuccessBrush"), - SyncAllAccountState.Failed => - CreateBadge( - "CriticalValueInfoBadgeStyle", - "SystemFillColorCriticalBrush", - viewModel.Repos.Count(r => r.Status == SyncStatus.Failed)), - SyncAllAccountState.Skipped => - CreateBadge("CautionDotInfoBadgeStyle", "SystemFillColorCautionBrush"), - _ => null, // Running is covered by IsRunning above - } - : viewModel.HasFailedRepos - ? CreateBadge("CriticalDotInfoBadgeStyle", "SystemFillColorCriticalBrush") - : null; - - // A finished run has stamped its outcome onto the account by now. - if (!viewModel.IsRunning && id != Guid.Empty) - { - Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); - if (account is not null) - { - ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced"); - } - } - } - - /// - /// An InfoBadge styled by the WinUI badge resource when present, otherwise - /// tinted directly from the matching theme fill brush. A non-null - /// makes it a numeric badge (e.g. failed count). - /// - private static InfoBadge CreateBadge(string styleKey, string fallbackBrushKey, int? value = null) - { - var badge = new InfoBadge(); - if (value is int number) - { - badge.Value = number; - } - if (Application.Current.Resources.TryGetValue(styleKey, out object? styleValue) - && styleValue is Style style) - { - badge.Style = style; - } - else - { - badge.Background = StatusFormat.ThemedBrush(fallbackBrushKey); - } - return badge; - } - - // ---------------------------------------------------------------- accounts - - /// - /// Builds the pane item for an account — name, icon, last-sync tooltip, and the - /// Edit/Delete context menu — and registers it for badge and tooltip refreshes. - /// - private NavigationViewItem CreateAccountNavItem(Account account) - { - var item = new NavigationViewItem - { - Content = account.Name, - Tag = account.Id, - Icon = new FontIcon { Glyph = "" }, - ContextFlyout = CreateAccountContextFlyout(account.Id), - }; + // A finished run has stamped its outcome onto the account by now. + if(!viewModel.IsRunning && id != Guid.Empty) + { + Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); + if(account is not null) + { ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced"); - _navItems[account.Id] = item; - return item; - } + } + } + } - /// The Edit/Delete context menu for an account's pane item. - private MenuFlyout CreateAccountContextFlyout(Guid id) - { - var edit = new MenuFlyoutItem + + + /// + /// An InfoBadge styled by the WinUI badge resource when present, otherwise + /// tinted directly from the matching theme fill brush. A non-null + /// makes it a numeric badge (e.g. failed count). + /// + private static InfoBadge CreateBadge(string styleKey, string fallbackBrushKey, int? value = null) + { + var badge = new InfoBadge(); + if(value is int number) + { + badge.Value = number; + } + if(Application.Current.Resources.TryGetValue(styleKey, out object? styleValue) + && styleValue is Style style) + { + badge.Style = style; + } + else + { + badge.Background = StatusFormat.ThemedBrush(fallbackBrushKey); + } + return badge; + } + + + + // ---------------------------------------------------------------- accounts + + /// + /// Builds the pane item for an account — name, icon, last-sync tooltip, and the + /// Edit/Delete context menu — and registers it for badge and tooltip refreshes. + /// + private NavigationViewItem CreateAccountNavItem(Account account) + { + var item = new NavigationViewItem + { + Content = account.Name, + Tag = account.Id, + Icon = new FontIcon { Glyph = "" }, + ContextFlyout = CreateAccountContextFlyout(account.Id), + }; + ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced"); + _navItems[account.Id] = item; + return item; + } + + + + /// The Edit/Delete context menu for an account's pane item. + private MenuFlyout CreateAccountContextFlyout(Guid id) + { + var edit = new MenuFlyoutItem + { + Text = "Edit…", + Icon = new FontIcon { Glyph = "" }, + }; + edit.Click += async (_, _) => await ShowEditAccountWizardAsync(id); + + var delete = new MenuFlyoutItem + { + Text = "Delete…", + Icon = new FontIcon { Glyph = "" }, + }; + delete.Click += async (_, _) => await ConfirmDeleteAccountAsync(id); + + var flyout = new MenuFlyout(); + flyout.Items.Add(edit); + flyout.Items.Add(delete); + return flyout; + } + + + + /// + /// Inserts an account item into the pane's account section, keeping the section + /// sorted by name (case-insensitive) like . + /// Only Guid-tagged items other than Quick Sync are accounts; everything before + /// them (Quick Sync, the separator) is skipped by the tag check. + /// + private void InsertAccountNavItemSorted(NavigationViewItem item) + { + string name = item.Content as string ?? ""; + int insertAt = WorkspaceNav.MenuItems.Count; + for(int i = 0; i < WorkspaceNav.MenuItems.Count; i++) + { + if(WorkspaceNav.MenuItems[i] is NavigationViewItem { Tag: Guid id, Content: string existing } + && id != Guid.Empty + && StringComparer.OrdinalIgnoreCase.Compare(name, existing) < 0) + { + insertAt = i; + break; + } + } + WorkspaceNav.MenuItems.Insert(insertAt, item); + } + + + + /// + /// Runs the account wizard: adding when is null, + /// editing (seeded from the account and its vault token) otherwise. On save, + /// the pane is updated accordingly; on cancel nothing changes. + /// + private async Task ShowAccountWizardAsync(Account? existing) + { + // The vault is consulted only if step 2 needs the stored token for a + // validation call; the wizard never displays or retains it. + Func? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id); + var viewModel = new AccountWizardViewModel( + _accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log); + var dialog = new AccountWizardDialog( + viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) + { + XamlRoot = Content.XamlRoot, + }; + if(DialogGuard.IsDialogOpen) + { + _log.Info("Account wizard not opened: another dialog is open."); + return; + } + _log.Info(existing is null ? "Account wizard opened (add)." : $"Account wizard opened (edit '{existing.Name}')."); + await DialogGuard.ShowAsync(dialog); + if(!dialog.Saved) + { + _log.Info("Account wizard canceled."); + return; + } + + if(existing is null) + { + OnAccountAdded(viewModel); + } + else + { + OnAccountEdited(existing.Id); + } + } + + + + private async Task ShowEditAccountWizardAsync(Guid id) + { + Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); + if(account is null) + { + return; // deleted meanwhile; the stale item is on its way out + } + await ShowAccountWizardAsync(account); + } + + + + /// + /// "Save as account…" from Quick Sync: the add wizard opens with the working + /// connection already filled in, so the user only names it (#30). + /// + private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed) + { + var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log); + var dialog = new AccountWizardDialog( + viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) + { + XamlRoot = Content.XamlRoot, + }; + if(DialogGuard.IsDialogOpen) + { + _log.Info("Account wizard not opened: another dialog is open."); + return; + } + _log.Info($"Account wizard opened (save Quick Sync connection to '{seed.Organization}' as an account)."); + await DialogGuard.ShowAsync(dialog); + if(dialog.Saved) + { + OnAccountAdded(viewModel); + } + else + { + _log.Info("Account wizard canceled."); + } + } + + + + /// + /// After the wizard saved a new account: create its pane item (sorted into the + /// account section) and select it, which shows its workspace. + /// + private void OnAccountAdded(AccountWizardViewModel viewModel) + { + Account? account = _accountsStore.FindByName(viewModel.Name.Trim()); + if(account is null) + { + _log.Error($"Account '{viewModel.Name}' was saved but cannot be found; restart to see it."); + return; + } + + NavigationViewItem item = CreateAccountNavItem(account); + InsertAccountNavItemSorted(item); + UpdateSyncAllEnabled(); + WorkspaceNav.SelectedItem = item; // SelectionChanged shows the new workspace + } + + + + /// + /// After the wizard saved an edit: refresh the pane item and drop the cached + /// workspace — it still runs on the old profile — so the next visit (immediate, + /// when it is the current one) rebuilds it from the updated account. + /// + private void OnAccountEdited(Guid id) + { + Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); + if(account is null) + { + return; + } + + if(_navItems.TryGetValue(id, out NavigationViewItem? item)) + { + item.Content = account.Name; + ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced"); + } + + if(_workspaces.ContainsKey(id)) + { + EvictWorkspace(id); + if(_currentWorkspaceId == id) + { + ShowWorkspace(id); + } + } + } + + + + /// + /// The context menu's Delete: confirms by name, then removes the account's + /// metadata and token, its pane item, and its cached workspace. Clones on disk + /// stay. Quick Sync takes over when the deleted account was on screen. + /// + private async Task ConfirmDeleteAccountAsync(Guid id) + { + Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); + if(account is null) + { + return; + } + + var confirm = new ContentDialog + { + Title = "Delete account", + Content = $"Delete '{account.Name}'? The saved profile and its stored token " + + "are removed. Repositories already cloned to disk are not touched.", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = Content.XamlRoot, + }; + if(await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary) + { + _log.Info($"Delete of account '{account.Name}' canceled."); + return; + } + + try + { + _accountsStore.Delete(id); // the store logs the deletion + } + catch(Exception ex) + { + _log.Error($"Deleting account '{account.Name}' failed: {ex.Message}", ex); + await ShowMessageAsync( + "Delete account", $"The account could not be deleted.\n{ex.Message}"); + return; + } + + EvictWorkspace(id); + if(_navItems.TryGetValue(id, out NavigationViewItem? item)) + { + _navItems.Remove(id); + WorkspaceNav.MenuItems.Remove(item); + } + UpdateSyncAllEnabled(); + + if(_currentWorkspaceId == id) + { + _currentWorkspaceId = Guid.Empty; + WorkspaceNav.SelectedItem = QuickSyncNavItem; // SelectionChanged shows it + } + } + + + + /// + /// Removes a cached workspace: unhooks its badge subscription, cancels any + /// in-flight sync, disposes the view model, and clears its pane badge. The nav + /// item itself stays — delete removes it, edit rebuilds the workspace behind it. + /// + private void EvictWorkspace(Guid id) + { + if(!_workspaces.TryGetValue(id, out WorkspaceEntry? workspace)) + { + return; + } + + if(_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler)) + { + workspace.ViewModel.PropertyChanged -= handler; + _badgeHandlers.Remove(id); + } + if(workspace.ViewModel.SyncCancelCommand.CanExecute(null)) + { + workspace.ViewModel.SyncCancelCommand.Execute(null); + } + workspace.Page?.Detach(); + workspace.ViewModel.Dispose(); + _workspaces.Remove(id); + + if(_navItems.TryGetValue(id, out NavigationViewItem? item)) + { + item.InfoBadge = null; + } + } + + + + // ---------------------------------------------------------------- sync all + + /// + /// The 'Sync all' footer command: runs every account workspace sequentially in + /// pane order via , then shows a short summary. + /// A second activation while running cancels the queue after the in-flight + /// account (that account's own sync is left to finish). + /// + private async Task ToggleSyncAllAsync() + { + if(_cancelSyncAll is not null) + { + _log.Info("Sync all: cancel requested; the account in flight will finish."); + _cancelSyncAll(); + return; + } + _log.Info("Sync all requested."); + + // Account workspaces in pane order, created (not shown) when never visited. + var accountWorkspaces = new List(); + foreach(object menuItem in WorkspaceNav.MenuItems) + { + if(menuItem is NavigationViewItem { Tag: Guid id } + && id != Guid.Empty + && EnsureWorkspace(id) is { } workspace) + { + accountWorkspaces.Add(workspace.ViewModel); + } + } + if(accountWorkspaces.Count == 0) + { + return; + } + + using var cts = new CancellationTokenSource(); + _cancelSyncAll = cts.Cancel; + SyncAllNavItem.Content = "Cancel sync all"; + try + { + // Badges from the previous pass make way for this one; the coordinator + // announces Queued for every scheduled account immediately. It runs on + // this (UI) thread, so the callback may touch the pane directly. + _syncAllStates.Clear(); + var coordinator = new SyncAllCoordinator(_log) + { + AccountStateChanged = (accountId, state) => { - Text = "Edit…", - Icon = new FontIcon { Glyph = "" }, - }; - edit.Click += async (_, _) => await ShowEditAccountWizardAsync(id); + _syncAllStates[accountId] = state; + OnWorkspaceStateChanged(accountId); + }, + }; + SyncAllResult result = await coordinator.RunAsync(accountWorkspaces, cts.Token); - var delete = new MenuFlyoutItem - { - Text = "Delete…", - Icon = new FontIcon { Glyph = "" }, - }; - delete.Click += async (_, _) => await ConfirmDeleteAccountAsync(id); + string accounts = result.Ran == 1 ? "1 account" : $"{result.Ran} accounts"; + string message = result.WasCanceled + ? $"Sync all was canceled: {accounts} synced, {result.Skipped} skipped." + : $"Sync all finished: {accounts} synced, {result.Skipped} skipped."; + await ShowMessageAsync("Sync all", message); + } + catch(Exception ex) + { + // Unexpected only: the coordinator reports per-account trouble by + // skipping, and DialogGuard turns a blocked summary dialog into a + // no-op instead of a throw; the activity log carries the + // per-account outcomes either way. + _log.Error($"Sync all did not finish cleanly: {ex.Message}", ex); + } + finally + { + _cancelSyncAll = null; + SyncAllNavItem.Content = "Sync all"; + UpdateSyncAllEnabled(); + } + } - var flyout = new MenuFlyout(); - flyout.Items.Add(edit); - flyout.Items.Add(delete); - return flyout; - } - /// - /// Inserts an account item into the pane's account section, keeping the section - /// sorted by name (case-insensitive) like . - /// Only Guid-tagged items other than Quick Sync are accounts; everything before - /// them (Quick Sync, the separator) is skipped by the tag check. - /// - private void InsertAccountNavItemSorted(NavigationViewItem item) - { - string name = item.Content as string ?? ""; - int insertAt = WorkspaceNav.MenuItems.Count; - for (int i = 0; i < WorkspaceNav.MenuItems.Count; i++) - { - if (WorkspaceNav.MenuItems[i] is NavigationViewItem { Tag: Guid id, Content: string existing } - && id != Guid.Empty - && StringComparer.OrdinalIgnoreCase.Compare(name, existing) < 0) - { - insertAt = i; - break; - } - } - WorkspaceNav.MenuItems.Insert(insertAt, item); - } - /// - /// Runs the account wizard: adding when is null, - /// editing (seeded from the account and its vault token) otherwise. On save, - /// the pane is updated accordingly; on cancel nothing changes. - /// - private async Task ShowAccountWizardAsync(Account? existing) - { - // The vault is consulted only if step 2 needs the stored token for a - // validation call; the wizard never displays or retains it. - Func? storedToken = existing is null ? null : () => _tokenVault.TryRetrieve(existing.Id); - var viewModel = new AccountWizardViewModel( - _accountsStore, new GitHubOrganizationLister(), _settings, existing, storedToken, _log); - var dialog = new AccountWizardDialog( - viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) - { - XamlRoot = Content.XamlRoot, - }; - if (DialogGuard.IsDialogOpen) - { - _log.Info("Account wizard not opened: another dialog is open."); - return; - } - _log.Info(existing is null ? "Account wizard opened (add)." : $"Account wizard opened (edit '{existing.Name}')."); - await DialogGuard.ShowAsync(dialog); - if (!dialog.Saved) - { - _log.Info("Account wizard canceled."); - return; - } + /// + /// 'Sync all' is only actionable with at least one account — or while a run is + /// in flight, when the item must stay enabled so it can be canceled. + /// + private void UpdateSyncAllEnabled() + => SyncAllNavItem.IsEnabled = + _cancelSyncAll is not null || _navItems.Keys.Any(id => id != Guid.Empty); - if (existing is null) - { - OnAccountAdded(viewModel); - } - else - { - OnAccountEdited(existing.Id); - } - } - private async Task ShowEditAccountWizardAsync(Guid id) - { - Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); - if (account is null) - { - return; // deleted meanwhile; the stale item is on its way out - } - await ShowAccountWizardAsync(account); - } - /// - /// "Save as account…" from Quick Sync: the add wizard opens with the working - /// connection already filled in, so the user only names it (#30). - /// - private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed) - { - var viewModel = new AccountWizardViewModel(_accountsStore, new GitHubOrganizationLister(), seed, _log); - var dialog = new AccountWizardDialog( - viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) - { - XamlRoot = Content.XamlRoot, - }; - if (DialogGuard.IsDialogOpen) - { - _log.Info("Account wizard not opened: another dialog is open."); - return; - } - _log.Info($"Account wizard opened (save Quick Sync connection to '{seed.Organization}' as an account)."); - await DialogGuard.ShowAsync(dialog); - if (dialog.Saved) - { - OnAccountAdded(viewModel); - } - else - { - _log.Info("Account wizard canceled."); - } - } + /// + /// Best effort on close: stop the 'Sync all' queue, cancel in-flight git work in + /// every still-cached workspace (edits and deletes evict entries earlier, so this + /// covers whatever remains) so clones are not killed mid-checkout (a killed clone + /// is repaired on the next run), then dispose the view models and drop the badge + /// subscriptions. + /// + private void DisposeWorkspaces() + { + _cancelSyncAll?.Invoke(); // no further accounts start while the window tears down - /// - /// After the wizard saved a new account: create its pane item (sorted into the - /// account section) and select it, which shows its workspace. - /// - private void OnAccountAdded(AccountWizardViewModel viewModel) - { - Account? account = _accountsStore.FindByName(viewModel.Name.Trim()); - if (account is null) - { - _log.Error($"Account '{viewModel.Name}' was saved but cannot be found; restart to see it."); - return; - } + foreach((Guid id, WorkspaceEntry workspace) in _workspaces) + { + if(_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler)) + { + workspace.ViewModel.PropertyChanged -= handler; + } + if(workspace.ViewModel.SyncCancelCommand.CanExecute(null)) + { + workspace.ViewModel.SyncCancelCommand.Execute(null); + } + workspace.Page?.Detach(); + workspace.ViewModel.Dispose(); + } + _badgeHandlers.Clear(); + _workspaces.Clear(); + } - NavigationViewItem item = CreateAccountNavItem(account); - InsertAccountNavItemSorted(item); - UpdateSyncAllEnabled(); - WorkspaceNav.SelectedItem = item; // SelectionChanged shows the new workspace - } - /// - /// After the wizard saved an edit: refresh the pane item and drop the cached - /// workspace — it still runs on the old profile — so the next visit (immediate, - /// when it is the current one) rebuilds it from the updated account. - /// - private void OnAccountEdited(Guid id) - { - Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); - if (account is null) - { - return; - } - if (_navItems.TryGetValue(id, out NavigationViewItem? item)) - { - item.Content = account.Name; - ToolTipService.SetToolTip(item, account.LastSyncSummary ?? "Never synced"); - } + // ---------------------------------------------------------------- menu bar - if (_workspaces.ContainsKey(id)) - { - EvictWorkspace(id); - if (_currentWorkspaceId == id) - { - ShowWorkspace(id); - } - } - } + private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e) + { + var dialog = new SettingsDialog( + _settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log) + { + XamlRoot = Content.XamlRoot, + }; + _log.Info("Settings opened."); + int concurrencyBefore = _settings.DefaultMaxConcurrency; + if(await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary) + { + dialog.ApplyAndSave(); // logs what changed + ApplySettings(seedQuickSyncConcurrency: _settings.DefaultMaxConcurrency != concurrencyBefore); + } + else + { + _log.Info("Settings closed without saving."); + } + } - /// - /// The context menu's Delete: confirms by name, then removes the account's - /// metadata and token, its pane item, and its cached workspace. Clones on disk - /// stay. Quick Sync takes over when the deleted account was on screen. - /// - private async Task ConfirmDeleteAccountAsync(Guid id) - { - Account? account = _accountsStore.GetAll().FirstOrDefault(a => a.Id == id); - if (account is null) - { - return; - } - var confirm = new ContentDialog - { - Title = "Delete account", - Content = $"Delete '{account.Name}'? The saved profile and its stored token " - + "are removed. Repositories already cloned to disk are not touched.", - PrimaryButtonText = "Delete", - CloseButtonText = "Cancel", - DefaultButton = ContentDialogButton.Close, - XamlRoot = Content.XamlRoot, - }; - if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary) - { - _log.Info($"Delete of account '{account.Name}' canceled."); - return; - } - try - { - _accountsStore.Delete(id); // the store logs the deletion - } - catch (Exception ex) - { - _log.Error($"Deleting account '{account.Name}' failed: {ex.Message}", ex); - await ShowMessageAsync( - "Delete account", $"The account could not be deleted.\n{ex.Message}"); - return; - } + private void ExitMenuItem_Click(object sender, RoutedEventArgs e) => Close(); - EvictWorkspace(id); - if (_navItems.TryGetValue(id, out NavigationViewItem? item)) - { - _navItems.Remove(id); - WorkspaceNav.MenuItems.Remove(item); - } - UpdateSyncAllEnabled(); - if (_currentWorkspaceId == id) - { - _currentWorkspaceId = Guid.Empty; - WorkspaceNav.SelectedItem = QuickSyncNavItem; // SelectionChanged shows it - } - } - /// - /// Removes a cached workspace: unhooks its badge subscription, cancels any - /// in-flight sync, disposes the view model, and clears its pane badge. The nav - /// item itself stays — delete removes it, edit rebuilds the workspace behind it. - /// - private void EvictWorkspace(Guid id) - { - if (!_workspaces.TryGetValue(id, out WorkspaceEntry? workspace)) - { - return; - } + /// The one log window, re-activated while open; null when closed. + private LogWindow? _logWindow; - if (_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler)) - { - workspace.ViewModel.PropertyChanged -= handler; - _badgeHandlers.Remove(id); - } - if (workspace.ViewModel.SyncCancelCommand.CanExecute(null)) - { - workspace.ViewModel.SyncCancelCommand.Execute(null); - } - workspace.Page?.Detach(); - workspace.ViewModel.Dispose(); - _workspaces.Remove(id); - if (_navItems.TryGetValue(id, out NavigationViewItem? item)) - { - item.InfoBadge = null; - } - } - // ---------------------------------------------------------------- sync all + private void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e) + { + // Non-modal by design: the log stays open and live-updating beside the + // main window while syncs run. + if(_logWindow is null) + { + _logWindow = new LogWindow(_log); + _logWindow.Closed += (_, _) => _logWindow = null; + _log.Info("Activity log window opened."); + } + _logWindow.Activate(); + } - /// - /// The 'Sync all' footer command: runs every account workspace sequentially in - /// pane order via , then shows a short summary. - /// A second activation while running cancels the queue after the in-flight - /// account (that account's own sync is left to finish). - /// - private async Task ToggleSyncAllAsync() - { - if (_cancelSyncAll is not null) - { - _log.Info("Sync all: cancel requested; the account in flight will finish."); - _cancelSyncAll(); - return; - } - _log.Info("Sync all requested."); - // Account workspaces in pane order, created (not shown) when never visited. - var accountWorkspaces = new List(); - foreach (object menuItem in WorkspaceNav.MenuItems) - { - if (menuItem is NavigationViewItem { Tag: Guid id } - && id != Guid.Empty - && EnsureWorkspace(id) is { } workspace) - { - accountWorkspaces.Add(workspace.ViewModel); - } - } - if (accountWorkspaces.Count == 0) - { - return; - } - using var cts = new CancellationTokenSource(); - _cancelSyncAll = cts.Cancel; - SyncAllNavItem.Content = "Cancel sync all"; - try - { - // Badges from the previous pass make way for this one; the coordinator - // announces Queued for every scheduled account immediately. It runs on - // this (UI) thread, so the callback may touch the pane directly. - _syncAllStates.Clear(); - var coordinator = new SyncAllCoordinator(_log) - { - AccountStateChanged = (accountId, state) => - { - _syncAllStates[accountId] = state; - OnWorkspaceStateChanged(accountId); - }, - }; - SyncAllResult result = await coordinator.RunAsync(accountWorkspaces, cts.Token); + private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e) + { + _log.Info($"Opening {RepoUrl} in the browser."); + await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl)); + } - string accounts = result.Ran == 1 ? "1 account" : $"{result.Ran} accounts"; - string message = result.WasCanceled - ? $"Sync all was canceled: {accounts} synced, {result.Skipped} skipped." - : $"Sync all finished: {accounts} synced, {result.Skipped} skipped."; - await ShowMessageAsync("Sync all", message); - } - catch (Exception ex) - { - // Unexpected only: the coordinator reports per-account trouble by - // skipping, and DialogGuard turns a blocked summary dialog into a - // no-op instead of a throw; the activity log carries the - // per-account outcomes either way. - _log.Error($"Sync all did not finish cleanly: {ex.Message}", ex); - } - finally - { - _cancelSyncAll = null; - SyncAllNavItem.Content = "Sync all"; - UpdateSyncAllEnabled(); - } - } - /// - /// 'Sync all' is only actionable with at least one account — or while a run is - /// in flight, when the item must stay enabled so it can be canceled. - /// - private void UpdateSyncAllEnabled() - => SyncAllNavItem.IsEnabled = - _cancelSyncAll is not null || _navItems.Keys.Any(id => id != Guid.Empty); - /// - /// Best effort on close: stop the 'Sync all' queue, cancel in-flight git work in - /// every still-cached workspace (edits and deletes evict entries earlier, so this - /// covers whatever remains) so clones are not killed mid-checkout (a killed clone - /// is repaired on the next run), then dispose the view models and drop the badge - /// subscriptions. - /// - private void DisposeWorkspaces() - { - _cancelSyncAll?.Invoke(); // no further accounts start while the window tears down + private async void AboutMenuItem_Click(object sender, RoutedEventArgs e) + { + _log.Info("About dialog opened."); + var dialog = new AboutDialog { XamlRoot = Content.XamlRoot }; + await DialogGuard.ShowAsync(dialog); + } - foreach ((Guid id, WorkspaceEntry workspace) in _workspaces) - { - if (_badgeHandlers.TryGetValue(id, out PropertyChangedEventHandler? handler)) - { - workspace.ViewModel.PropertyChanged -= handler; - } - if (workspace.ViewModel.SyncCancelCommand.CanExecute(null)) - { - workspace.ViewModel.SyncCancelCommand.Execute(null); - } - workspace.Page?.Detach(); - workspace.ViewModel.Dispose(); - } - _badgeHandlers.Clear(); - _workspaces.Clear(); - } - // ---------------------------------------------------------------- menu bar - private async void SettingsMenuItem_Click(object sender, RoutedEventArgs e) - { - var dialog = new SettingsDialog( - _settings, _tokenVault, () => WinRT.Interop.WindowNative.GetWindowHandle(this), _log) - { - XamlRoot = Content.XamlRoot, - }; - _log.Info("Settings opened."); - int concurrencyBefore = _settings.DefaultMaxConcurrency; - if (await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary) - { - dialog.ApplyAndSave(); // logs what changed - ApplySettings(seedQuickSyncConcurrency: _settings.DefaultMaxConcurrency != concurrencyBefore); - } - else - { - _log.Info("Settings closed without saving."); - } - } + /// + /// Help > Check for updates: every phase is visible in UpdateBar (#39) — + /// checking with an indeterminate bar, the outcome inline (up to date / failed), + /// then, after the user confirms, a determinate download bar until the process + /// restarts. The menu item is disabled for the duration so it cannot be re-run. + /// Only "update available" still asks with a dialog, because that is a decision. + /// + private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e) + => await RunUpdateFlowAsync(); - private void ExitMenuItem_Click(object sender, RoutedEventArgs e) => Close(); - /// The one log window, re-activated while open; null when closed. - private LogWindow? _logWindow; - private void ActivityLogMenuItem_Click(object sender, RoutedEventArgs e) - { - // Non-modal by design: the log stays open and live-updating beside the - // main window while syncs run. - if (_logWindow is null) - { - _logWindow = new LogWindow(_log); - _logWindow.Closed += (_, _) => _logWindow = null; - _log.Info("Activity log window opened."); - } - _logWindow.Activate(); - } + private async Task RunUpdateFlowAsync() + { + if(_updateInFlight) + { + return; + } + _updateInFlight = true; + CheckUpdatesMenuItem.IsEnabled = false; + try + { + if(!_updateService.IsSupported) + { + _log.Info("Update check requested, but this is not an installed build."); + ShowUpdateBar(InfoBarSeverity.Informational, "Updates are only available in installed builds.", busy: false); + return; + } - private async void GitHubMenuItem_Click(object sender, RoutedEventArgs e) - { - _log.Info($"Opening {RepoUrl} in the browser."); - await Windows.System.Launcher.LaunchUriAsync(new Uri(RepoUrl)); - } + ShowUpdateBar(InfoBarSeverity.Informational, "Checking for updates…", busy: true); + UpdateCheckResult result = await _updateService.CheckAsync(); + if(result.Error is not null) + { + ShowUpdateBar(InfoBarSeverity.Error, $"Could not check for updates. {result.Error}", busy: false); + return; + } - private async void AboutMenuItem_Click(object sender, RoutedEventArgs e) - { - _log.Info("About dialog opened."); - var dialog = new AboutDialog { XamlRoot = Content.XamlRoot }; - await DialogGuard.ShowAsync(dialog); - } + if(result.AvailableVersion is null) + { + string current = _updateService.CurrentVersion is string v ? $" (v{v})" : ""; + ShowUpdateBar(InfoBarSeverity.Success, $"You are up to date{current}.", busy: false); + return; + } - /// - /// Help > Check for updates: every phase is visible in UpdateBar (#39) — - /// checking with an indeterminate bar, the outcome inline (up to date / failed), - /// then, after the user confirms, a determinate download bar until the process - /// restarts. The menu item is disabled for the duration so it cannot be re-run. - /// Only "update available" still asks with a dialog, because that is a decision. - /// - private async void CheckForUpdatesMenuItem_Click(object sender, RoutedEventArgs e) - => await RunUpdateFlowAsync(); + ShowUpdateBar( + InfoBarSeverity.Informational, + $"gclo v{result.AvailableVersion} is available.", + busy: false); + var confirm = new ContentDialog + { + Title = "Update available", + Content = $"gclo v{result.AvailableVersion} is available. " + + "The app will restart to finish installing the update.", + PrimaryButtonText = "Update and restart", + CloseButtonText = "Not now", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = Content.XamlRoot, + }; + if(await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary) + { + _log.Info($"Update to v{result.AvailableVersion} declined for now."); + ShowUpdateBar( + InfoBarSeverity.Informational, + $"gclo v{result.AvailableVersion} is available. Run Help > Check for updates again when you are ready to install it.", + busy: false); + return; + } - private async Task RunUpdateFlowAsync() - { - if (_updateInFlight) - { - return; - } - _updateInFlight = true; - CheckUpdatesMenuItem.IsEnabled = false; - try - { - if (!_updateService.IsSupported) - { - _log.Info("Update check requested, but this is not an installed build."); - ShowUpdateBar(InfoBarSeverity.Informational, "Updates are only available in installed builds.", busy: false); - return; - } + string version = result.AvailableVersion; + ShowUpdateBar(InfoBarSeverity.Informational, $"Downloading gclo v{version}…", busy: true, determinate: true); + var progress = new Progress(percent => + { + UpdateProgressBar.Value = percent; + UpdateBar.Message = $"Downloading gclo v{version}… {percent}%"; + }); - ShowUpdateBar(InfoBarSeverity.Informational, "Checking for updates…", busy: true); - UpdateCheckResult result = await _updateService.CheckAsync(); - if (result.Error is not null) - { - ShowUpdateBar(InfoBarSeverity.Error, $"Could not check for updates. {result.Error}", busy: false); - return; - } + // On success this exits the process to restart into the new version, + // so reaching the line below means the update did not go through. + string? error = await _updateService.DownloadAndApplyAsync(progress); + if(error is not null) + { + ShowUpdateBar(InfoBarSeverity.Error, $"Update failed. {error}", busy: false); + } + } + finally + { + _updateInFlight = false; + CheckUpdatesMenuItem.IsEnabled = true; + } + } - if (result.AvailableVersion is null) - { - string current = _updateService.CurrentVersion is string v ? $" (v{v})" : ""; - ShowUpdateBar(InfoBarSeverity.Success, $"You are up to date{current}.", busy: false); - return; - } - ShowUpdateBar( - InfoBarSeverity.Informational, - $"gclo v{result.AvailableVersion} is available.", - busy: false); - var confirm = new ContentDialog - { - Title = "Update available", - Content = $"gclo v{result.AvailableVersion} is available. " - + "The app will restart to finish installing the update.", - PrimaryButtonText = "Update and restart", - CloseButtonText = "Not now", - DefaultButton = ContentDialogButton.Primary, - XamlRoot = Content.XamlRoot, - }; - if (await DialogGuard.ShowAsync(confirm) != ContentDialogResult.Primary) - { - _log.Info($"Update to v{result.AvailableVersion} declined for now."); - ShowUpdateBar( - InfoBarSeverity.Informational, - $"gclo v{result.AvailableVersion} is available. Run Help > Check for updates again when you are ready to install it.", - busy: false); - return; - } - string version = result.AvailableVersion; - ShowUpdateBar(InfoBarSeverity.Informational, $"Downloading gclo v{version}…", busy: true, determinate: true); - var progress = new Progress(percent => - { - UpdateProgressBar.Value = percent; - UpdateBar.Message = $"Downloading gclo v{version}… {percent}%"; - }); + private void ShowUpdateBar(InfoBarSeverity severity, string message, bool busy, bool determinate = false) + { + UpdateBar.ActionButton = null; // only the startup offer carries one + UpdateBar.Severity = severity; + UpdateBar.Message = message; + UpdateBar.IsClosable = !busy; + UpdateProgressBar.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + UpdateProgressBar.IsIndeterminate = busy && !determinate; + UpdateProgressBar.Value = 0; + UpdateBar.IsOpen = true; + } - // On success this exits the process to restart into the new version, - // so reaching the line below means the update did not go through. - string? error = await _updateService.DownloadAndApplyAsync(progress); - if (error is not null) - { - ShowUpdateBar(InfoBarSeverity.Error, $"Update failed. {error}", busy: false); - } - } - finally - { - _updateInFlight = false; - CheckUpdatesMenuItem.IsEnabled = true; - } - } - private void ShowUpdateBar(InfoBarSeverity severity, string message, bool busy, bool determinate = false) - { - UpdateBar.ActionButton = null; // only the startup offer carries one - UpdateBar.Severity = severity; - UpdateBar.Message = message; - UpdateBar.IsClosable = !busy; - UpdateProgressBar.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; - UpdateProgressBar.IsIndeterminate = busy && !determinate; - UpdateProgressBar.Value = 0; - UpdateBar.IsOpen = true; - } - private async Task ShowMessageAsync(string title, string message) - { - var dialog = new ContentDialog - { - Title = title, - Content = message, - CloseButtonText = "OK", - XamlRoot = Content.XamlRoot, - }; - await DialogGuard.ShowAsync(dialog); - } + private async Task ShowMessageAsync(string title, string message) + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = "OK", + XamlRoot = Content.XamlRoot, + }; + await DialogGuard.ShowAsync(dialog); + } - [System.Runtime.InteropServices.DllImport("user32.dll")] - private static extern uint GetDpiForWindow(IntPtr hwnd); - } + + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr hwnd); } diff --git a/gclo/PathRecoveryDialog.xaml.cs b/gclo/PathRecoveryDialog.xaml.cs index 2b2d0ce..98b3831 100644 --- a/gclo/PathRecoveryDialog.xaml.cs +++ b/gclo/PathRecoveryDialog.xaml.cs @@ -1,149 +1,101 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.Collections.Generic; using System.Linq; -using CommunityToolkit.Mvvm.ComponentModel; using gclo.Engine; using gclo.ViewModels; using Microsoft.UI.Xaml.Controls; -namespace gclo + +namespace gclo; + + +/// +/// Asks the user how to recover a repository whose tree contains Windows-invalid +/// paths: rename each offending name (prefilled with the validator's suggestion when +/// there is one) or skip the file or folder entirely. Rows the validator has no safe +/// rename for (case-only collisions, duplicate destinations) start as Skip — the +/// same default the CLI's --sanitize-paths applies — so accepting the defaults +/// never produces a plan that is guaranteed to fail (#30). "Apply and check out" +/// pre-flights the choices through and keeps the +/// dialog open with the problems listed when they would fail. +/// carries the chosen after a sound apply and stays null +/// when the dialog is dismissed ("Skip this repo"). When WSL can run git on this +/// machine the dialog also offers "Clone in WSL instead" (#8), which skips the +/// Windows checkout entirely: then reports that choice. +/// +/// As with every ContentDialog, the caller must set XamlRoot before +/// ShowAsync. +/// +public sealed partial class PathRecoveryDialog : ContentDialog { - /// - /// Asks the user how to recover a repository whose tree contains Windows-invalid - /// paths: rename each offending name (prefilled with the validator's suggestion when - /// there is one) or skip the file or folder entirely. Rows the validator has no safe - /// rename for (case-only collisions, duplicate destinations) start as Skip — the - /// same default the CLI's --sanitize-paths applies — so accepting the defaults - /// never produces a plan that is guaranteed to fail (#30). "Apply and check out" - /// pre-flights the choices through and keeps the - /// dialog open with the problems listed when they would fail. - /// carries the chosen after a sound apply and stays null - /// when the dialog is dismissed ("Skip this repo"). When WSL can run git on this - /// machine the dialog also offers "Clone in WSL instead" (#8), which skips the - /// Windows checkout entirely: then reports that choice. - /// - /// As with every ContentDialog, the caller must set XamlRoot before - /// ShowAsync. - /// - public sealed partial class PathRecoveryDialog : ContentDialog - { - private readonly List _rows; + private readonly List _rows; - /// The user's recovery choice, or null when the dialog was dismissed. - public PathRecoveryDecision? Decision { get; private set; } + /// The user's recovery choice, or null when the dialog was dismissed. + public PathRecoveryDecision? Decision { get; private set; } - /// The chosen renames/skips when is an apply; null otherwise. - public PathRecovery? Result => (Decision as PathRecoveryDecision.Apply)?.Recovery; - /// Offers the "Clone in WSL instead" button (see ). - public PathRecoveryDialog(string repoName, IReadOnlyList paths, bool wslAvailable = false) - { - ArgumentNullException.ThrowIfNull(repoName); - ArgumentNullException.ThrowIfNull(paths); - InitializeComponent(); - IntroText.Text = - $"'{repoName}' contains paths that are legal in git but cannot be created on Windows. " - + "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout." - + (wslAvailable - ? " Or clone the repository unchanged inside WSL (~/gclo// in your default distribution), where these paths are legal." - : ""); - if (wslAvailable) - { - SecondaryButtonText = "Clone in WSL instead"; - } + /// The chosen renames/skips when is an apply; null otherwise. + public PathRecovery? Result => (Decision as PathRecoveryDecision.Apply)?.Recovery; - _rows = new List(paths.Count); - foreach (InvalidPathInfo path in paths) - { - _rows.Add(new PathRecoveryRow(path)); - } - RowsControl.ItemsSource = _rows; - } - private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) - { - PathRecoveryPlanner.Plan plan = PathRecoveryPlanner.Build( - _rows.Select(row => new PathRecoveryPlanner.Choice(row.RepoPath, row.BuildReplacementPath(), row.Skip)) - .ToList()); - if (plan.Problems.Count > 0) - { - // Stay open and say which rows need a different choice. - args.Cancel = true; - ProblemsBar.Message = string.Join(Environment.NewLine, plan.Problems); - ProblemsBar.IsOpen = true; - return; - } + /// - ProblemsBar.IsOpen = false; - Decision = new PathRecoveryDecision.Apply(plan.Recovery); - } + /// /// Offers the "Clone in WSL instead" button (see ). + public PathRecoveryDialog(string repoName, IReadOnlyList paths, bool wslAvailable = false) + { + ArgumentNullException.ThrowIfNull(repoName); + ArgumentNullException.ThrowIfNull(paths); + InitializeComponent(); - private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) - => Decision = new PathRecoveryDecision.CloneInWsl(); - } + IntroText.Text = + $"'{repoName}' contains paths that are legal in git but cannot be created on Windows. " + + "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout." + + (wslAvailable + ? " Or clone the repository unchanged inside WSL (~/gclo// in your default distribution), where these paths are legal." + : ""); + if(wslAvailable) + { + SecondaryButtonText = "Clone in WSL instead"; + } - /// - /// One offending path in : the original repo path and - /// reason (read-only), plus the user's replacement name for the offending segment and - /// the per-row Skip choice. always ends with - /// the offending segment, so the rename applies to the last segment only. - /// - public sealed partial class PathRecoveryRow : ObservableObject - { - /// Repo path up to the offending segment: "" or "dir/sub/" (trailing slash). - private readonly string _parentPrefix; + _rows = new List(paths.Count); + foreach(InvalidPathInfo path in paths) + { + _rows.Add(new PathRecoveryRow(path)); + } + RowsControl.ItemsSource = _rows; + } - /// The offending (last) segment of . - private readonly string _originalSegment; - /// Initial TextBox value: the validator's suggestion, or the original segment. - private readonly string _prefill; - internal PathRecoveryRow(InvalidPathInfo info) - { - RepoPath = info.RepoPath; - Reason = info.Reason; - int slash = info.RepoPath.LastIndexOf('/'); - _parentPrefix = slash < 0 ? "" : info.RepoPath[..(slash + 1)]; - _originalSegment = info.RepoPath[(slash + 1)..]; - _prefill = info.SuggestedName ?? _originalSegment; - NewName = _prefill; - // No safe rename exists (case-only collision, duplicate destination): the - // original name would fail again, so the row defaults to Skip. - Skip = info.SuggestedName is null; - } + private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + PathRecoveryPlanner.Plan plan = PathRecoveryPlanner.Build( + _rows.Select(row => new PathRecoveryPlanner.Choice(row.RepoPath, row.BuildReplacementPath(), row.Skip)) + .ToList()); - /// Full repo path (forward slashes) of the offending file or folder. - public string RepoPath { get; } + if(plan.Problems.Count > 0) + { + // Stay open and say which rows need a different choice. + args.Cancel = true; + ProblemsBar.Message = string.Join(Environment.NewLine, plan.Problems); + ProblemsBar.IsOpen = true; + return; + } - /// Why the path cannot be created on Windows. - public string Reason { get; } + ProblemsBar.IsOpen = false; + Decision = new PathRecoveryDecision.Apply(plan.Recovery); + } - /// Replacement name for the offending segment, edited by the user. - [ObservableProperty] - public partial string NewName { get; set; } - /// When set, the whole file or folder is omitted from the checkout. - [ObservableProperty] - public partial bool Skip { get; set; } - /// The rename box is only editable while the row is not skipped. - public bool RenameEnabled => !Skip; - - partial void OnSkipChanged(bool value) => OnPropertyChanged(nameof(RenameEnabled)); - - /// - /// Full replacement repo path for a non-skipped row, or null when the name is - /// unchanged and no rename is needed. An emptied TextBox falls back to the - /// prefill; the engine re-validates the effective path set on apply, so a - /// still-invalid manual edit surfaces as a fresh set of invalid paths. - /// - internal string? BuildReplacementPath() - { - string segment = string.IsNullOrWhiteSpace(NewName) ? _prefill : NewName; - return segment == _originalSegment ? null : _parentPrefix + segment; - } - } + private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + => Decision = new PathRecoveryDecision.CloneInWsl(); } diff --git a/gclo/PathRecoveryRow.cs b/gclo/PathRecoveryRow.cs new file mode 100644 index 0000000..5a36f0d --- /dev/null +++ b/gclo/PathRecoveryRow.cs @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using gclo.Engine; + + +namespace gclo; + + +/// +/// One offending path in : the original repo path and +/// reason (read-only), plus the user's replacement name for the offending segment and +/// the per-row Skip choice. always ends with +/// the offending segment, so the rename applies to the last segment only. +/// +public sealed partial class PathRecoveryRow : ObservableObject +{ + /// Repo path up to the offending segment: "" or "dir/sub/" (trailing slash). + private readonly string _parentPrefix; + + /// The offending (last) segment of . + private readonly string _originalSegment; + + /// Initial TextBox value: the validator's suggestion, or the original segment. + private readonly string _prefill; + + + + internal PathRecoveryRow(InvalidPathInfo info) + { + RepoPath = info.RepoPath; + Reason = info.Reason; + int slash = info.RepoPath.LastIndexOf('/'); + _parentPrefix = slash < 0 ? "" : info.RepoPath[..(slash + 1)]; + _originalSegment = info.RepoPath[(slash + 1)..]; + _prefill = info.SuggestedName ?? _originalSegment; + NewName = _prefill; + // No safe rename exists (case-only collision, duplicate destination): the + // original name would fail again, so the row defaults to Skip. + Skip = info.SuggestedName is null; + } + + + + /// Full repo path (forward slashes) of the offending file or folder. + public string RepoPath { get; } + + /// Why the path cannot be created on Windows. + public string Reason { get; } + + /// Replacement name for the offending segment, edited by the user. + [ObservableProperty] + public partial string NewName { get; set; } + + /// When set, the whole file or folder is omitted from the checkout. + [ObservableProperty] + public partial bool Skip { get; set; } + + + + /// The rename box is only editable while the row is not skipped. + public bool RenameEnabled => !Skip; + + + + partial void OnSkipChanged(bool value) => OnPropertyChanged(nameof(RenameEnabled)); + + + + /// + /// Full replacement repo path for a non-skipped row, or null when the name is + /// unchanged and no rename is needed. An emptied TextBox falls back to the + /// prefill; the engine re-validates the effective path set on apply, so a + /// still-invalid manual edit surfaces as a fresh set of invalid paths. + /// + internal string? BuildReplacementPath() + { + string segment = string.IsNullOrWhiteSpace(NewName) ? _prefill : NewName; + return string.Equals(segment, _originalSegment, StringComparison.Ordinal) ? null : _parentPrefix + segment; + } +} diff --git a/gclo/Program.cs b/gclo/Program.cs index 8378b46..6026c45 100644 --- a/gclo/Program.cs +++ b/gclo/Program.cs @@ -1,31 +1,37 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using Velopack; -namespace gclo -{ - /// - /// Custom entry point. The XAML-generated Main is disabled (DISABLE_XAML_GENERATED_MAIN - /// in gclo.csproj) so Velopack can run before any UI exists: during install, update, and - /// uninstall Velopack launches the exe with special arguments, handles them in - /// VelopackApp.Run, and may exit the process. In non-Velopack contexts (F5, - /// loose builds, MSIX packages) that call is inert and the app starts normally. - /// - public static class Program - { - [STAThread] - static void Main(string[] args) - { - // Must be first: handles Velopack install/update/uninstall hooks and may exit. - VelopackApp.Build().Run(); - // The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim. - global::WinRT.ComWrappersSupport.InitializeComWrappers(); - global::Microsoft.UI.Xaml.Application.Start((p) => - { - var context = new global::Microsoft.UI.Dispatching.DispatcherQueueSynchronizationContext(global::Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); - global::System.Threading.SynchronizationContext.SetSynchronizationContext(context); - _ = new App(); - }); - } - } +namespace gclo; + + +/// +/// Custom entry point. The XAML-generated Main is disabled (DISABLE_XAML_GENERATED_MAIN +/// in gclo.csproj) so Velopack can run before any UI exists: during install, update, and +/// uninstall Velopack launches the exe with special arguments, handles them in +/// VelopackApp.Run, and may exit the process. In non-Velopack contexts (F5, +/// loose builds, MSIX packages) that call is inert and the app starts normally. +/// +public static class Program +{ + [STAThread] + private static void Main() + { + // Must be first: handles Velopack install/update/uninstall hooks and may exit. + VelopackApp.Build().Run(); + + // The remainder mirrors the XAML-generated Main (obj\...\App.g.i.cs) verbatim. + global::WinRT.ComWrappersSupport.InitializeComWrappers(); + global::Microsoft.UI.Xaml.Application.Start((p) => + { + var context = new global::Microsoft.UI.Dispatching.DispatcherQueueSynchronizationContext(global::Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); + global::System.Threading.SynchronizationContext.SetSynchronizationContext(context); + _ = new App(); + }); + } } diff --git a/gclo/Services/UpdateCheckResult.cs b/gclo/Services/UpdateCheckResult.cs new file mode 100644 index 0000000..23c1b8d --- /dev/null +++ b/gclo/Services/UpdateCheckResult.cs @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.Services; + + +/// +/// Result of an update check. is the newer version found, +/// or null when already up to date; is non-null when the check itself failed. +/// +public sealed record UpdateCheckResult(string? AvailableVersion, string? Error); diff --git a/gclo/Services/UpdateService.cs b/gclo/Services/UpdateService.cs index 83e4a29..1b85714 100644 --- a/gclo/Services/UpdateService.cs +++ b/gclo/Services/UpdateService.cs @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.Reflection; using System.Threading; @@ -6,13 +11,9 @@ using gclo.Engine; using Velopack; using Velopack.Sources; + namespace gclo.Services; -/// -/// Result of an update check. is the newer version found, -/// or null when already up to date; is non-null when the check itself failed. -/// -public sealed record UpdateCheckResult(string? AvailableVersion, string? Error); /// /// Self-update via Velopack, with releases hosted on the app's public GitHub repository. @@ -24,148 +25,162 @@ public sealed record UpdateCheckResult(string? AvailableVersion, string? Error); /// public sealed class UpdateService { - private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo"; + private const string RepoUrl = "https://github.com/KofTwentyTwo/gclo"; - private readonly IActivityLog _log; - private UpdateManager? _manager; - private UpdateInfo? _pendingUpdate; + private readonly IActivityLog _log; - /// Creates the service; receives every update-flow transition. - public UpdateService(IActivityLog? log = null) - { - _log = log ?? new NullActivityLog(); - } + private UpdateManager? _manager; - /// True when the app was installed by Velopack and can check for and apply updates. - public bool IsSupported - { - get - { - try - { - return GetManager().IsInstalled; - } - catch - { - return false; - } - } - } + private UpdateInfo? _pendingUpdate; - /// The installed version as Velopack sees it, or null outside installed builds. - public string? CurrentVersion - { - get - { - try - { - return GetManager().CurrentVersion?.ToString(); - } - catch - { - return null; - } - } - } - /// - /// Checks GitHub releases for a newer version and remembers it for - /// . Never throws: failures come back in - /// . - /// - public async Task CheckAsync() - { - UpdateManager manager; - try - { - manager = GetManager(); - if (!manager.IsInstalled) - { - _log.Info("Update check skipped: not an installed build."); - return new UpdateCheckResult(null, "Updates are only available in installed builds."); - } - } - catch (Exception ex) - { - _log.Error($"Update check failed before contacting GitHub: {ex.Message}", ex); - return new UpdateCheckResult(null, ex.Message); - } - string current = manager.CurrentVersion?.ToString() ?? "unknown"; - _log.Info($"Update check started (installed v{current})."); - try - { - UpdateInfo? update = await manager.CheckForUpdatesAsync().ConfigureAwait(false); - _pendingUpdate = update; - if (update is null) - { - _log.Info($"Up to date: v{current} is the latest on this channel."); - return new UpdateCheckResult(null, null); - } + /// Creates the service; receives every update-flow transition. + public UpdateService(IActivityLog? log = null) + { + _log = log ?? new NullActivityLog(); + } - string available = update.TargetFullRelease.Version.ToString(); - _log.Info($"Update available: v{available} (installed v{current})."); - return new UpdateCheckResult(available, null); - } - catch (Exception ex) - { - _pendingUpdate = null; - _log.Error($"Update check failed: {ex.Message}", ex); - return new UpdateCheckResult(null, ex.Message); - } - } - /// - /// Downloads the update found by the last successful , applies it, - /// and restarts the app, reporting download completion (0-100) through - /// when given. On success this never returns (the process - /// exits to restart); otherwise it returns an error message. Never throws. - /// - public async Task DownloadAndApplyAsync(IProgress? progress = null) - { - UpdateManager? manager = _manager; - UpdateInfo? update = _pendingUpdate; - if (manager is null || update is null) - { - _log.Error("Update apply requested with no update ready; check for updates first."); - return "No update is ready to install; check for updates first."; - } - string version = update.TargetFullRelease.Version.ToString(); - _log.Info($"Downloading update v{version}."); - try - { - await manager.DownloadUpdatesAsync( - update, percent => progress?.Report(percent), CancellationToken.None).ConfigureAwait(false); - _log.Info($"Update v{version} downloaded; restarting to apply it."); - manager.ApplyUpdatesAndRestart(update.TargetFullRelease); + /// True when the app was installed by Velopack and can check for and apply updates. + public bool IsSupported + { + get + { + try + { + return GetManager().IsInstalled; + } + catch + { + return false; + } + } + } + + + + /// The installed version as Velopack sees it, or null outside installed builds. + public string? CurrentVersion + { + get + { + try + { + return GetManager().CurrentVersion?.ToString(); + } + catch + { return null; - } - catch (Exception ex) - { - _log.Error($"Update to v{version} failed: {ex.Message}", ex); - return ex.Message; - } - } + } + } + } - /// - /// Created lazily so merely constructing the service can never fail; callers wrap this - /// in try/catch and treat a throwing manager as "updates not supported". - /// A prerelease install (semver has a '-suffix') runs on the dev channel, whose - /// packages are attached to GitHub *prerelease* releases — GithubSource must be - /// told to look at prereleases or dev installs can never find any update at all. - /// Stable installs keep prereleases out of consideration; Velopack's channel - /// filtering then matches packages to the installed channel either way. - /// - private UpdateManager GetManager() - { - if (_manager is null) - { - bool prerelease = BuildVersion - .Describe(Assembly.GetExecutingAssembly()) - .Contains('-'); - _manager = new UpdateManager(new GithubSource(RepoUrl, null, prerelease)); - } - return _manager; - } + + + /// + /// Checks GitHub releases for a newer version and remembers it for + /// . Never throws: failures come back in + /// . + /// + public async Task CheckAsync() + { + UpdateManager manager; + try + { + manager = GetManager(); + if(!manager.IsInstalled) + { + _log.Info("Update check skipped: not an installed build."); + return new UpdateCheckResult(null, "Updates are only available in installed builds."); + } + } + catch(Exception ex) + { + _log.Error($"Update check failed before contacting GitHub: {ex.Message}", ex); + return new UpdateCheckResult(null, ex.Message); + } + + string current = manager.CurrentVersion?.ToString() ?? "unknown"; + _log.Info($"Update check started (installed v{current})."); + try + { + UpdateInfo? update = await manager.CheckForUpdatesAsync().ConfigureAwait(false); + _pendingUpdate = update; + if(update is null) + { + _log.Info($"Up to date: v{current} is the latest on this channel."); + return new UpdateCheckResult(null, null); + } + + string available = update.TargetFullRelease.Version.ToString(); + _log.Info($"Update available: v{available} (installed v{current})."); + return new UpdateCheckResult(available, null); + } + catch(Exception ex) + { + _pendingUpdate = null; + _log.Error($"Update check failed: {ex.Message}", ex); + return new UpdateCheckResult(null, ex.Message); + } + } + + + + /// + /// Downloads the update found by the last successful , applies it, + /// and restarts the app, reporting download completion (0-100) through + /// when given. On success this never returns (the process + /// exits to restart); otherwise it returns an error message. Never throws. + /// + public async Task DownloadAndApplyAsync(IProgress? progress = null) + { + UpdateManager? manager = _manager; + UpdateInfo? update = _pendingUpdate; + if(manager is null || update is null) + { + _log.Error("Update apply requested with no update ready; check for updates first."); + return "No update is ready to install; check for updates first."; + } + + string version = update.TargetFullRelease.Version.ToString(); + _log.Info($"Downloading update v{version}."); + try + { + await manager.DownloadUpdatesAsync( + update, percent => progress?.Report(percent), CancellationToken.None).ConfigureAwait(false); + _log.Info($"Update v{version} downloaded; restarting to apply it."); + manager.ApplyUpdatesAndRestart(update.TargetFullRelease); + return null; + } + catch(Exception ex) + { + _log.Error($"Update to v{version} failed: {ex.Message}", ex); + return ex.Message; + } + } + + + + /// + /// Created lazily so merely constructing the service can never fail; callers wrap this + /// in try/catch and treat a throwing manager as "updates not supported". + /// A prerelease install (semver has a '-suffix') runs on the dev channel, whose + /// packages are attached to GitHub *prerelease* releases — GithubSource must be + /// told to look at prereleases or dev installs can never find any update at all. + /// Stable installs keep prereleases out of consideration; Velopack's channel + /// filtering then matches packages to the installed channel either way. + /// + private UpdateManager GetManager() + { + if(_manager is null) + { + bool prerelease = BuildVersion + .Describe(Assembly.GetExecutingAssembly()) + .Contains('-'); + _manager = new UpdateManager(new GithubSource(RepoUrl, null, prerelease)); + } + return _manager; + } } diff --git a/gclo/SettingsDialog.xaml.cs b/gclo/SettingsDialog.xaml.cs index 91d4b0b..bb4075c 100644 --- a/gclo/SettingsDialog.xaml.cs +++ b/gclo/SettingsDialog.xaml.cs @@ -1,179 +1,199 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.Collections.Generic; using gclo.Engine; using gclo.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Windows.Storage; -namespace gclo + +namespace gclo; + + +/// +/// Modal editor for plus the optional default GitHub +/// token (kept in the , never in settings.json). The +/// dialog only edits and persists values; the caller owns side effects like +/// applying the theme. +/// +/// WinUI 3 requires XamlRoot to be set before ShowAsync. Typical +/// usage from a Window: +/// +/// var dialog = new SettingsDialog(settings, vault, () => hwnd) { XamlRoot = Content.XamlRoot }; +/// if (await dialog.ShowAsync() == ContentDialogResult.Primary) +/// { +/// dialog.ApplyAndSave(); +/// } +/// +/// +public sealed partial class SettingsDialog : ContentDialog { - /// - /// Modal editor for plus the optional default GitHub - /// token (kept in the , never in settings.json). The - /// dialog only edits and persists values; the caller owns side effects like - /// applying the theme. - /// - /// WinUI 3 requires XamlRoot to be set before ShowAsync. Typical - /// usage from a Window: - /// - /// var dialog = new SettingsDialog(settings, vault, () => hwnd) { XamlRoot = Content.XamlRoot }; - /// if (await dialog.ShowAsync() == ContentDialogResult.Primary) - /// { - /// dialog.ApplyAndSave(); - /// } - /// - /// - public sealed partial class SettingsDialog : ContentDialog - { - private readonly AppSettings _settings; - private readonly ITokenVault _vault; - private readonly IActivityLog _log; - // A ContentDialog has no HWND of its own; the host window supplies one for pickers. - private readonly Func _windowHandleProvider; + private readonly AppSettings _settings; - /// Set by the Remove link; the deletion happens on Save. - private bool _removeSavedToken; + private readonly ITokenVault _vault; - /// - /// Creates the dialog and populates the controls from ; - /// records what a save changed (field names and values, - /// never the token). - /// - public SettingsDialog(AppSettings settings, ITokenVault vault, Func windowHandleProvider, IActivityLog? log = null) - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(vault); - ArgumentNullException.ThrowIfNull(windowHandleProvider); - _settings = settings; - _vault = vault; - _log = log ?? new NullActivityLog(); - _windowHandleProvider = windowHandleProvider; - InitializeComponent(); + private readonly IActivityLog _log; - TargetFolderBox.Text = settings.DefaultTargetFolder; - ConcurrencyBox.Value = settings.DefaultMaxConcurrency; - ThemeBox.SelectedIndex = settings.Theme switch - { - "Light" => 1, - "Dark" => 2, - _ => 0, - }; - SplashToggle.IsOn = settings.ShowSplashScreen; - SplashDurationBox.Value = settings.SplashMilliseconds; + // A ContentDialog has no HWND of its own; the host window supplies one for pickers. + private readonly Func _windowHandleProvider; - // The saved token is never re-materialized into the box — the box stays - // empty and means "unchanged"; only typing a new value replaces it. - RefreshTokenState(hasSaved: _vault.TryRetrieve(AppSettings.DefaultTokenVaultId) is not null); - } + /// Set by the Remove link; the deletion happens on Save. + private bool _removeSavedToken; - /// - /// Copies the edited values back into the instance the - /// dialog was constructed with, persists them, and applies the default-token - /// change (store or remove) to the vault. Call after ShowAsync returned - /// . - /// - public void ApplyAndSave() - { - var changes = new List(); - string folder = TargetFolderBox.Text.Trim(); - if (folder != _settings.DefaultTargetFolder) - { - changes.Add($"default folder '{_settings.DefaultTargetFolder}' -> '{folder}'"); - _settings.DefaultTargetFolder = folder; - } - // NumberBox.Value is NaN when the field was cleared; keep the previous value then. - double value = ConcurrencyBox.Value; - if (!double.IsNaN(value)) - { - int concurrency = Math.Clamp( - (int)Math.Round(value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency); - if (concurrency != _settings.DefaultMaxConcurrency) - { - changes.Add($"default parallelism {_settings.DefaultMaxConcurrency} -> {concurrency}"); - _settings.DefaultMaxConcurrency = concurrency; - } - } + /// + /// Creates the dialog and populates the controls from ; + /// records what a save changed (field names and values, + /// never the token). + /// + public SettingsDialog(AppSettings settings, ITokenVault vault, Func windowHandleProvider, IActivityLog? log = null) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(vault); + ArgumentNullException.ThrowIfNull(windowHandleProvider); + _settings = settings; + _vault = vault; + _log = log ?? new NullActivityLog(); + _windowHandleProvider = windowHandleProvider; + InitializeComponent(); - string theme = ThemeBox.SelectedIndex switch - { - 1 => "Light", - 2 => "Dark", - _ => "System", - }; - if (theme != _settings.Theme) - { - changes.Add($"theme {_settings.Theme} -> {theme}"); - _settings.Theme = theme; - } + TargetFolderBox.Text = settings.DefaultTargetFolder; + ConcurrencyBox.Value = settings.DefaultMaxConcurrency; + ThemeBox.SelectedIndex = settings.Theme switch + { + "Light" => 1, + "Dark" => 2, + _ => 0, + }; + SplashToggle.IsOn = settings.ShowSplashScreen; + SplashDurationBox.Value = settings.SplashMilliseconds; - if (SplashToggle.IsOn != _settings.ShowSplashScreen) - { - changes.Add($"splash screen {(SplashToggle.IsOn ? "on" : "off")}"); - _settings.ShowSplashScreen = SplashToggle.IsOn; - } - double splash = SplashDurationBox.Value; - if (!double.IsNaN(splash)) - { - int milliseconds = Math.Clamp( - (int)Math.Round(splash), - AppSettings.MinSplashMilliseconds, - AppSettings.MaxSplashMilliseconds); - if (milliseconds != _settings.SplashMilliseconds) - { - changes.Add($"splash duration {_settings.SplashMilliseconds} -> {milliseconds} ms"); - _settings.SplashMilliseconds = milliseconds; - } - } + // The saved token is never re-materialized into the box — the box stays + // empty and means "unchanged"; only typing a new value replaces it. + RefreshTokenState(hasSaved: _vault.TryRetrieve(AppSettings.DefaultTokenVaultId) is not null); + } - _settings.Save(); - string typed = DefaultTokenBox.Password; - if (typed.Length > 0) - { - _vault.Store(AppSettings.DefaultTokenVaultId, typed); - changes.Add("default token stored"); - } - else if (_removeSavedToken) - { - _vault.Delete(AppSettings.DefaultTokenVaultId); - changes.Add("default token removed"); - } - _log.Info(changes.Count == 0 - ? "Settings saved (no changes)." - : "Settings saved: " + string.Join("; ", changes) + "."); - } + /// + /// Copies the edited values back into the instance the + /// dialog was constructed with, persists them, and applies the default-token + /// change (store or remove) to the vault. Call after ShowAsync returned + /// . + /// + public void ApplyAndSave() + { + var changes = new List(); - private void RemoveTokenButton_Click(object sender, RoutedEventArgs e) - { - _removeSavedToken = true; - DefaultTokenBox.Password = ""; - TokenStateText.Text = "Saved token will be removed on Save."; - RemoveTokenButton.Visibility = Visibility.Collapsed; - } + string folder = TargetFolderBox.Text.Trim(); + if(!string.Equals(folder, _settings.DefaultTargetFolder, StringComparison.Ordinal)) + { + changes.Add($"default folder '{_settings.DefaultTargetFolder}' -> '{folder}'"); + _settings.DefaultTargetFolder = folder; + } - private void RefreshTokenState(bool hasSaved) - { - TokenStateText.Text = hasSaved - ? "A default token is saved. Leave the box empty to keep it." - : "No default token saved."; - RemoveTokenButton.Visibility = hasSaved ? Visibility.Visible : Visibility.Collapsed; - } + // NumberBox.Value is NaN when the field was cleared; keep the previous value then. + double value = ConcurrencyBox.Value; + if(!double.IsNaN(value)) + { + int concurrency = Math.Clamp( + (int)Math.Round(value), AppSettings.MinConcurrency, AppSettings.MaxConcurrency); + if(concurrency != _settings.DefaultMaxConcurrency) + { + changes.Add($"default parallelism {_settings.DefaultMaxConcurrency} -> {concurrency}"); + _settings.DefaultMaxConcurrency = concurrency; + } + } - private async void BrowseButton_Click(object sender, RoutedEventArgs e) - { - var picker = new Windows.Storage.Pickers.FolderPicker(); - picker.FileTypeFilter.Add("*"); // required in packaged apps + string theme = ThemeBox.SelectedIndex switch + { + 1 => "Light", + 2 => "Dark", + _ => "System", + }; + if(!string.Equals(theme, _settings.Theme, StringComparison.Ordinal)) + { + changes.Add($"theme {_settings.Theme} -> {theme}"); + _settings.Theme = theme; + } - WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider()); + if(SplashToggle.IsOn != _settings.ShowSplashScreen) + { + changes.Add($"splash screen {(SplashToggle.IsOn ? "on" : "off")}"); + _settings.ShowSplashScreen = SplashToggle.IsOn; + } + double splash = SplashDurationBox.Value; + if(!double.IsNaN(splash)) + { + int milliseconds = Math.Clamp( + (int)Math.Round(splash), + AppSettings.MinSplashMilliseconds, + AppSettings.MaxSplashMilliseconds); + if(milliseconds != _settings.SplashMilliseconds) + { + changes.Add($"splash duration {_settings.SplashMilliseconds} -> {milliseconds} ms"); + _settings.SplashMilliseconds = milliseconds; + } + } - var folder = await picker.PickSingleFolderAsync(); - if (folder is not null) - { - TargetFolderBox.Text = folder.Path; - } - } - } + _settings.Save(); + + string typed = DefaultTokenBox.Password; + if(typed.Length > 0) + { + _vault.Store(AppSettings.DefaultTokenVaultId, typed); + changes.Add("default token stored"); + } + else if(_removeSavedToken) + { + _vault.Delete(AppSettings.DefaultTokenVaultId); + changes.Add("default token removed"); + } + + _log.Info(changes.Count == 0 + ? "Settings saved (no changes)." + : "Settings saved: " + string.Join("; ", changes) + "."); + } + + + + private void RemoveTokenButton_Click(object sender, RoutedEventArgs e) + { + _removeSavedToken = true; + DefaultTokenBox.Password = ""; + TokenStateText.Text = "Saved token will be removed on Save."; + RemoveTokenButton.Visibility = Visibility.Collapsed; + } + + + + private void RefreshTokenState(bool hasSaved) + { + TokenStateText.Text = hasSaved + ? "A default token is saved. Leave the box empty to keep it." + : "No default token saved."; + RemoveTokenButton.Visibility = hasSaved ? Visibility.Visible : Visibility.Collapsed; + } + + + + private async void BrowseButton_Click(object sender, RoutedEventArgs e) + { + var picker = new Windows.Storage.Pickers.FolderPicker(); + picker.FileTypeFilter.Add("*"); // required in packaged apps + + WinRT.Interop.InitializeWithWindow.Initialize(picker, _windowHandleProvider()); + + StorageFolder? folder = await picker.PickSingleFolderAsync(); + if(folder is not null) + { + TargetFolderBox.Text = folder.Path; + } + } } diff --git a/gclo/SplashOverlay.xaml.cs b/gclo/SplashOverlay.xaml.cs index 4d4df88..8d149c8 100644 --- a/gclo/SplashOverlay.xaml.cs +++ b/gclo/SplashOverlay.xaml.cs @@ -1,49 +1,58 @@ -using System; +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System.Reflection; using System.Threading.Tasks; using Microsoft.UI.Xaml.Controls; -namespace gclo + +namespace gclo; + + +/// +/// Branded startup splash rendered as a full-window overlay inside +/// . An overlay instead of a separate splash window by +/// design: closing a real window while assistive technology or UI automation is +/// querying it crashes the process with a stowed XAML exception, whereas fading +/// out and removing an element is ordinary, race-free XAML work. +/// +public sealed partial class SplashOverlay : UserControl { - /// - /// Branded startup splash rendered as a full-window overlay inside - /// . An overlay instead of a separate splash window by - /// design: closing a real window while assistive technology or UI automation is - /// querying it crashes the process with a stowed XAML exception, whereas fading - /// out and removing an element is ordinary, race-free XAML work. - /// - public sealed partial class SplashOverlay : UserControl - { - private bool _dismissed; + private bool _dismissed; - /// Builds the overlay and stamps the build identity line. - public SplashOverlay() - { - InitializeComponent(); - VersionText.Text = gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly()); - } - /// - /// Fades the overlay out and removes it from its parent panel. Safe to call - /// more than once; only the first call does anything. - /// - public async Task DismissAsync() - { - if (_dismissed) - { - return; - } - _dismissed = true; - // Input must fall through to the app the moment dismissal starts. - IsHitTestVisible = false; + /// Builds the overlay and stamps the build identity line. + public SplashOverlay() + { + InitializeComponent(); + VersionText.Text = gclo.Engine.BuildVersion.Describe(Assembly.GetExecutingAssembly()); + } - var completion = new TaskCompletionSource(); - FadeOut.Completed += (_, _) => completion.TrySetResult(); - FadeOut.Begin(); - await completion.Task; - (Parent as Panel)?.Children.Remove(this); - } - } + + /// + /// Fades the overlay out and removes it from its parent panel. Safe to call + /// more than once; only the first call does anything. + /// + public async Task DismissAsync() + { + if(_dismissed) + { + return; + } + _dismissed = true; + + // Input must fall through to the app the moment dismissal starts. + IsHitTestVisible = false; + + var completion = new TaskCompletionSource(); + FadeOut.Completed += (_, _) => completion.TrySetResult(); + FadeOut.Begin(); + await completion.Task; + + (Parent as Panel)?.Children.Remove(this); + } } diff --git a/gclo/StatusFormat.cs b/gclo/StatusFormat.cs index ee003ca..5d9c706 100644 --- a/gclo/StatusFormat.cs +++ b/gclo/StatusFormat.cs @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using gclo.Engine; using gclo.ViewModels; using Microsoft.UI; @@ -5,152 +10,186 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; + namespace gclo; + /// Static helpers for x:Bind function bindings in WorkspacePage.xaml. public static class StatusFormat { - /// - /// The theme the window actually shows, kept current by MainWindow. Function-bound - /// brushes resolve against it: Application.Current.Resources follows the - /// application theme fixed at startup, so after an in-session switch (or an OS - /// flip in System mode) it would keep handing out the old theme's colors (#31). - /// - public static ElementTheme CurrentTheme { get; set; } = ElementTheme.Default; + /// + /// The theme the window actually shows, kept current by MainWindow. Function-bound + /// brushes resolve against it: Application.Current.Resources follows the + /// application theme fixed at startup, so after an in-session switch (or an OS + /// flip in System mode) it would keep handing out the old theme's colors (#31). + /// + public static ElementTheme CurrentTheme { get; set; } = ElementTheme.Default; - /// - /// Theme- and high-contrast-aware brush for a status. Resolved from the theme - /// dictionary matching on every call; x:Bind - /// re-invokes this whenever the bound Status changes, and MainWindow asks every - /// row to re-evaluate when the theme changes. - /// - public static Brush BrushFor(SyncStatus status) => ResourceBrush(status switch - { - SyncStatus.Cloning or SyncStatus.Pulling => "SystemFillColorAttentionBrush", - SyncStatus.Done => "SystemFillColorSuccessBrush", - SyncStatus.Failed => "SystemFillColorCriticalBrush", - SyncStatus.Canceled => "SystemFillColorCautionBrush", - _ => "TextFillColorSecondaryBrush", - }); - /// - /// Segoe Fluent Icons glyph for a status, shown beside the status text so state - /// is never communicated by color alone. - /// - public static string GlyphFor(SyncStatus status) => status switch - { - SyncStatus.Queued => "", // Recent (clock) - SyncStatus.Cloning => "", // Download - SyncStatus.Pulling => "", // Sync - SyncStatus.Done => "", // CheckMark - SyncStatus.Failed => "", // Error - SyncStatus.Canceled => "", // Cancel - _ => "", - }; - /// Screen-reader name for a row's path-recovery link. - public static string ResolveAutomationName(string repoName) - => $"Resolve invalid paths for {repoName}"; + /// + /// Theme- and high-contrast-aware brush for a status. Resolved from the theme + /// dictionary matching on every call; x:Bind + /// re-invokes this whenever the bound Status changes, and MainWindow asks every + /// row to re-evaluate when the theme changes. + /// + public static Brush BrushFor(SyncStatus status) => ResourceBrush(status switch + { + SyncStatus.Cloning or SyncStatus.Pulling => "SystemFillColorAttentionBrush", + SyncStatus.Done => "SystemFillColorSuccessBrush", + SyncStatus.Failed => "SystemFillColorCriticalBrush", + SyncStatus.Canceled => "SystemFillColorCautionBrush", + _ => "TextFillColorSecondaryBrush", + }); - /// A brush resource resolved for (see ). - public static Brush ThemedBrush(string key) => ResourceBrush(key); - private static Brush ResourceBrush(string key) - { - // Explicit Light/Dark: read the matching theme dictionary directly. The WinUI - // control resources merge their brushes under "Light" and "Default" (dark); - // high contrast and Default fall back to the application-theme lookup. - string? dictionary = CurrentTheme switch - { - ElementTheme.Light => "Light", - ElementTheme.Dark => "Default", - _ => null, - }; - if (dictionary is not null && TryFindThemed(Application.Current.Resources, dictionary, key, out Brush? themed)) - { - return themed; - } - return Application.Current.Resources.TryGetValue(key, out object? value) && value is Brush brush - ? brush - // Unreachable with XamlControlsResources merged; a visible-but-neutral - // fallback beats crashing the row template if a key ever disappears. - : new SolidColorBrush(Colors.Gray); - } + /// + /// Segoe Fluent Icons glyph for a status, shown beside the status text so state + /// is never communicated by color alone. + /// + public static string GlyphFor(SyncStatus status) => status switch + { + SyncStatus.Queued => "", // Recent (clock) + SyncStatus.Cloning => "", // Download + SyncStatus.Pulling => "", // Sync + SyncStatus.Done => "", // CheckMark + SyncStatus.Failed => "", // Error + SyncStatus.Canceled => "", // Cancel + _ => "", + }; - private static bool TryFindThemed( - ResourceDictionary resources, string theme, string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Brush? brush) - { - if (resources.ThemeDictionaries.TryGetValue(theme, out object? themed) - && themed is ResourceDictionary themeDictionary - && themeDictionary.TryGetValue(key, out object? value) - && value is Brush found) - { - brush = found; + + + /// Screen-reader name for a row's path-recovery link. + public static string ResolveAutomationName(string repoName) + => $"Resolve invalid paths for {repoName}"; + + + + /// A brush resource resolved for (see ). + public static Brush ThemedBrush(string key) => ResourceBrush(key); + + + + private static Brush ResourceBrush(string key) + { + // Explicit Light/Dark: read the matching theme dictionary directly. The WinUI + // control resources merge their brushes under "Light" and "Default" (dark); + // high contrast and Default fall back to the application-theme lookup. + string? dictionary = CurrentTheme switch + { + ElementTheme.Light => "Light", + ElementTheme.Dark => "Default", + _ => null, + }; + if(dictionary is not null && TryFindThemed(Application.Current.Resources, dictionary, key, out Brush? themed)) + { + return themed; + } + + return Application.Current.Resources.TryGetValue(key, out object? value) && value is Brush brush + ? brush + // Unreachable with XamlControlsResources merged; a visible-but-neutral + // fallback beats crashing the row template if a key ever disappears. + : new SolidColorBrush(Colors.Gray); + } + + + + private static bool TryFindThemed( + ResourceDictionary resources, string theme, string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Brush? brush) + { + if(resources.ThemeDictionaries.TryGetValue(theme, out object? themed) + && themed is ResourceDictionary themeDictionary + && themeDictionary.TryGetValue(key, out object? value) + && value is Brush found) + { + brush = found; + return true; + } + foreach(ResourceDictionary merged in resources.MergedDictionaries) + { + if(TryFindThemed(merged, theme, key, out brush)) + { return true; - } - foreach (ResourceDictionary merged in resources.MergedDictionaries) - { - if (TryFindThemed(merged, theme, key, out brush)) - { - return true; - } - } - brush = null; - return false; - } + } + } + brush = null; + return false; + } - public static Visibility VisibleIf(bool value) - => value ? Visibility.Visible : Visibility.Collapsed; - /// Inverse of : collapsed when is true. - public static Visibility VisibleIfNot(bool value) - => value ? Visibility.Collapsed : Visibility.Visible; - /// Visible only once has text; hides empty preview lines. - public static Visibility VisibleIfNotEmpty(string value) - => string.IsNullOrEmpty(value) ? Visibility.Collapsed : Visibility.Visible; + public static Visibility VisibleIf(bool value) + => value ? Visibility.Visible : Visibility.Collapsed; - /// InfoBar severity for a run outcome (None and Canceled read as informational). - public static InfoBarSeverity SeverityFor(RunResultKind kind) => kind switch - { - RunResultKind.Success => InfoBarSeverity.Success, - RunResultKind.PartialFailure => InfoBarSeverity.Warning, - RunResultKind.Error => InfoBarSeverity.Error, - _ => InfoBarSeverity.Informational, // Canceled, None - }; - /// Short InfoBar title for a run outcome; empty for . - public static string TitleFor(RunResultKind kind) => kind switch - { - RunResultKind.Success => "Sync complete", - RunResultKind.PartialFailure => "Completed with failures", - RunResultKind.Canceled => "Canceled", - RunResultKind.Error => "Sync failed", - _ => "", - }; - /// Repository count with explicit pluralization, e.g. "87 repositories". - public static string RepoCountText(int count) - => count == 1 ? "1 repository" : $"{count} repositories"; + /// Inverse of : collapsed when is true. + public static Visibility VisibleIfNot(bool value) + => value ? Visibility.Collapsed : Visibility.Visible; - /// Toolbar selection summary, e.g. "12 of 87 selected". - public static string SelectionSummary(int selected, int total) - => $"{selected} of {total} selected"; - /// Run progress shown beside the toolbar's progress bar, e.g. "34 of 87". - public static string RunProgressText(int completed, int total) - => $"{completed} of {total}"; - /// Screen-reader name for a repository's progress bar in the active strip. - public static string ProgressAutomationName(string repoName) - => $"{repoName} sync progress"; + /// Visible only once has text; hides empty preview lines. + public static Visibility VisibleIfNotEmpty(string value) + => string.IsNullOrEmpty(value) ? Visibility.Collapsed : Visibility.Visible; - /// Maps the persisted theme string to a XAML theme ("System"/unknown => Default). - public static ElementTheme ToElementTheme(string theme) => theme switch - { - "Light" => ElementTheme.Light, - "Dark" => ElementTheme.Dark, - _ => ElementTheme.Default, - }; + + + /// InfoBar severity for a run outcome (None and Canceled read as informational). + public static InfoBarSeverity SeverityFor(RunResultKind kind) => kind switch + { + RunResultKind.Success => InfoBarSeverity.Success, + RunResultKind.PartialFailure => InfoBarSeverity.Warning, + RunResultKind.Error => InfoBarSeverity.Error, + _ => InfoBarSeverity.Informational, // Canceled, None + }; + + + + /// Short InfoBar title for a run outcome; empty for . + public static string TitleFor(RunResultKind kind) => kind switch + { + RunResultKind.Success => "Sync complete", + RunResultKind.PartialFailure => "Completed with failures", + RunResultKind.Canceled => "Canceled", + RunResultKind.Error => "Sync failed", + _ => "", + }; + + + + /// Repository count with explicit pluralization, e.g. "87 repositories". + public static string RepoCountText(int count) + => count == 1 ? "1 repository" : $"{count} repositories"; + + + + /// Toolbar selection summary, e.g. "12 of 87 selected". + public static string SelectionSummary(int selected, int total) + => $"{selected} of {total} selected"; + + + + /// Run progress shown beside the toolbar's progress bar, e.g. "34 of 87". + public static string RunProgressText(int completed, int total) + => $"{completed} of {total}"; + + + + /// Screen-reader name for a repository's progress bar in the active strip. + public static string ProgressAutomationName(string repoName) + => $"{repoName} sync progress"; + + + + /// Maps the persisted theme string to a XAML theme ("System"/unknown => Default). + public static ElementTheme ToElementTheme(string theme) => theme switch + { + "Light" => ElementTheme.Light, + "Dark" => ElementTheme.Dark, + _ => ElementTheme.Default, + }; } diff --git a/gclo/UiTestFixture.cs b/gclo/UiTestFixture.cs index 60394b0..e8ce20f 100644 --- a/gclo/UiTestFixture.cs +++ b/gclo/UiTestFixture.cs @@ -1,78 +1,101 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using gclo.Engine; -namespace gclo + +namespace gclo; + + +/// +/// Offline seam for the FlaUI suite (#54, #33): when the process runs with +/// GCLO_UITEST_FIXTURE=1 and a workspace's token is exactly +/// , GitHub and git are answered by this fixture instead of the +/// network - a fixed organization with three repositories, and a git client that +/// succeeds without touching disk. Any other token (even with the variable set) goes +/// to the real services, so the existing offline-input tests keep their meaning +/// (a dummy token still gets GitHub's 401). Production never sets the variable. +/// +internal static class UiTestFixture { - /// - /// Offline seam for the FlaUI suite (#54, #33): when the process runs with - /// GCLO_UITEST_FIXTURE=1 and a workspace's token is exactly - /// , GitHub and git are answered by this fixture instead of the - /// network - a fixed organization with three repositories, and a git client that - /// succeeds without touching disk. Any other token (even with the variable set) goes - /// to the real services, so the existing offline-input tests keep their meaning - /// (a dummy token still gets GitHub's 401). Production never sets the variable. - /// - internal static class UiTestFixture - { - /// The magic token that routes a workspace to the fixture. - public const string Token = "gclo-uitest-fixture-token"; + /// The magic token that routes a workspace to the fixture. + public const string Token = "gclo-uitest-fixture-token"; - /// The organization the fixture lists. - public const string Organization = "fixture-org"; + /// The organization the fixture lists. + public const string Organization = "fixture-org"; - /// True when the process was started for the UI suite with the fixture enabled. - public static bool IsEnabled { get; } = - Environment.GetEnvironmentVariable("GCLO_UITEST_FIXTURE") == "1"; + /// True when the process was started for the UI suite with the fixture enabled. + public static bool IsEnabled { get; } = string.Equals(Environment.GetEnvironmentVariable("GCLO_UITEST_FIXTURE"), "1", StringComparison.Ordinal); - private static readonly IReadOnlyList Repositories = - [ - new("alpha", "https://github.com/fixture-org/alpha.git", "main", IsArchived: false), - new("bravo", "https://github.com/fixture-org/bravo.git", "develop", IsArchived: true), - new("charlie", "https://github.com/fixture-org/charlie.git", "main", IsArchived: false), - ]; + private static readonly IReadOnlyList s_repositories = + [ + new("alpha", "https://github.com/fixture-org/alpha.git", "main", IsArchived: false), + new("bravo", "https://github.com/fixture-org/bravo.git", "develop", IsArchived: true), + new("charlie", "https://github.com/fixture-org/charlie.git", "main", IsArchived: false), + ]; - public static IRepositoryLister WrapRepositoryLister(IRepositoryLister real) - => IsEnabled ? new RepositoryLister(real) : real; - public static IOrganizationLister WrapOrganizationLister(IOrganizationLister real) - => IsEnabled ? new OrganizationLister(real) : real; - public static IGitClient WrapGitClient(IGitClient real) - => IsEnabled ? new GitClient(real) : real; + public static IRepositoryLister WrapRepositoryLister(IRepositoryLister real) + => IsEnabled ? new RepositoryLister(real) : real; - private sealed class RepositoryLister(IRepositoryLister real) : IRepositoryLister - { - public Task> ListOrganizationRepositoriesAsync( - string organization, string token, CancellationToken cancellationToken = default) - => token == Token - ? Task.FromResult(Repositories) - : real.ListOrganizationRepositoriesAsync(organization, token, cancellationToken); - } - private sealed class OrganizationLister(IOrganizationLister real) : IOrganizationLister - { - public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) - => token == Token - ? Task.FromResult>([Organization]) - : real.ListOrganizationsAsync(token, cancellationToken); - } - /// Succeeds without writing anything; the real client handles every other token. - private sealed class GitClient(IGitClient real) : IGitClient - { - public bool IsValidRepository(string path) => false; + public static IOrganizationLister WrapOrganizationLister(IOrganizationLister real) + => IsEnabled ? new OrganizationLister(real) : real; - public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken) - => token == Token ? Task.CompletedTask : real.CloneAsync(url, path, token, onProgress, cancellationToken); - public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken) - => token == Token ? Task.CompletedTask : real.FetchAndPullAsync(path, token, cancellationToken); - public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) - => real.ApplyRecoveryAsync(path, recovery, cancellationToken); - } - } + public static IGitClient WrapGitClient(IGitClient real) + => IsEnabled ? new GitClient(real) : real; + + + + private sealed class RepositoryLister(IRepositoryLister real) : IRepositoryLister + { + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + => string.Equals(token, Token +, StringComparison.Ordinal) ? Task.FromResult(s_repositories) + : real.ListOrganizationRepositoriesAsync(organization, token, cancellationToken); + } + + + + private sealed class OrganizationLister(IOrganizationLister real) : IOrganizationLister + { + public Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) + => string.Equals(token, Token +, StringComparison.Ordinal) ? Task.FromResult>([Organization]) + : real.ListOrganizationsAsync(token, cancellationToken); + } + + + + /// Succeeds without writing anything; the real client handles every other token. + private sealed class GitClient(IGitClient real) : IGitClient + { + public bool IsValidRepository(string path) => false; + + + + public Task CloneAsync(string url, string path, string token, Action? onProgress, CancellationToken cancellationToken) + => string.Equals(token, Token, StringComparison.Ordinal) ? Task.CompletedTask : real.CloneAsync(url, path, token, onProgress, cancellationToken); + + + + public Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken) + => string.Equals(token, Token, StringComparison.Ordinal) ? Task.CompletedTask : real.FetchAndPullAsync(path, token, cancellationToken); + + + + public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) + => real.ApplyRecoveryAsync(path, recovery, cancellationToken); + } } diff --git a/gclo/WorkspacePage.xaml.cs b/gclo/WorkspacePage.xaml.cs index b5115fd..0d0fe63 100644 --- a/gclo/WorkspacePage.xaml.cs +++ b/gclo/WorkspacePage.xaml.cs @@ -1,429 +1,508 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + using System; using System.ComponentModel; using System.IO; using System.Threading.Tasks; -using gclo.Engine; using gclo.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using WinUI.TableView; -namespace gclo + +namespace gclo; + + +/// +/// One sync workspace in two visual states: a centered connect card until the first +/// successful load, then a connection chip plus the hero repository table with its +/// attached toolbar (filter, options, refresh, sync), the pinned active strip, and +/// the results InfoBar. Hosted by 's navigation shell — +/// one instance per saved account plus one for the pinned Quick Sync entry. Pages +/// (and their view models) are cached by the shell, so a running sync keeps going +/// while another workspace is displayed. +/// +public sealed partial class WorkspacePage : UserControl { - /// - /// One sync workspace in two visual states: a centered connect card until the first - /// successful load, then a connection chip plus the hero repository table with its - /// attached toolbar (filter, options, refresh, sync), the pinned active strip, and - /// the results InfoBar. Hosted by 's navigation shell — - /// one instance per saved account plus one for the pinned Quick Sync entry. Pages - /// (and their view models) are cached by the shell, so a running sync keeps going - /// while another workspace is displayed. - /// - public sealed partial class WorkspacePage : UserControl - { - // A UserControl has no HWND of its own; the host window supplies one for pickers. - private readonly Func _windowHandleProvider; + // A UserControl has no HWND of its own; the host window supplies one for pickers. + private readonly Func _windowHandleProvider; - /// The workspace state this page renders; target of every x:Bind. - public WorkspaceViewModel ViewModel { get; } + /// The workspace state this page renders; target of every x:Bind. + public WorkspaceViewModel ViewModel { get; } - /// - /// Raised by the chip's "Edit account…" (account workspaces only) with the account - /// id; the shell answers by opening the edit wizard. Null when nothing is wired. - /// - public Func? EditAccountRequested { get; set; } + /// + /// Raised by the chip's "Edit account…" (account workspaces only) with the account + /// id; the shell answers by opening the edit wizard. Null when nothing is wired. + /// + public Func? EditAccountRequested { get; set; } - /// - /// Raised by the chip's "Save as account…" (Quick Sync only) with the connection - /// in effect; the shell answers by opening the add wizard seeded with it. - /// - public Func? SaveAsAccountRequested { get; set; } + /// + /// Raised by the chip's "Save as account…" (Quick Sync only) with the connection + /// in effect; the shell answers by opening the add wizard seeded with it. + /// + public Func? SaveAsAccountRequested { get; set; } - // Handlers kept so Detach can unhook exactly what the constructor hooked. - private readonly System.Collections.Specialized.NotifyCollectionChangedEventHandler _onReposChanged; - private readonly System.Collections.Specialized.NotifyCollectionChangedEventHandler _onFilteredChanged; + // Handlers kept so Detach can unhook exactly what the constructor hooked. + private readonly System.Collections.Specialized.NotifyCollectionChangedEventHandler _onReposChanged; - /// - /// Wires the page to its (caller-owned) view model. The - /// returns the host window's HWND, - /// needed to initialize the folder picker. - /// - public WorkspacePage(WorkspaceViewModel viewModel, Func windowHandleProvider) - { - ArgumentNullException.ThrowIfNull(viewModel); - ArgumentNullException.ThrowIfNull(windowHandleProvider); - ViewModel = viewModel; - _windowHandleProvider = windowHandleProvider; - InitializeComponent(); + private readonly System.Collections.Specialized.NotifyCollectionChangedEventHandler _onFilteredChanged; - ViewModel.AnnouncementRequested += AnnounceToAssistiveTechnology; - ViewModel.PropertyChanged += OnViewModelPropertyChanged; - // The chip's repo count, the toolbar's selection summary, and the - // empty-filter placeholder are set from code: they derive from collection - // counts, which raise no property change an x:Bind function could ride on. - _onReposChanged = (_, _) => UpdateDerivedTexts(); - _onFilteredChanged = (_, _) => UpdateDerivedTexts(); - ViewModel.Repos.CollectionChanged += _onReposChanged; - ViewModel.FilteredRepos.CollectionChanged += _onFilteredChanged; - // The view model stays UI-free: when ResolvePathsCommand needs the user's - // path-recovery choices, it calls back through here and the page answers - // with PathRecoveryDialog. - ViewModel.RecoveryInteraction = ShowPathRecoveryDialogAsync; + /// + /// Wires the page to its (caller-owned) view model. The + /// returns the host window's HWND, + /// needed to initialize the folder picker. + /// + public WorkspacePage(WorkspaceViewModel viewModel, Func windowHandleProvider) + { + ArgumentNullException.ThrowIfNull(viewModel); + ArgumentNullException.ThrowIfNull(windowHandleProvider); + ViewModel = viewModel; + _windowHandleProvider = windowHandleProvider; + InitializeComponent(); - // Both hosts of the shared connection form need the window handle for - // their folder picker. - ConnectForm.WindowHandleProvider = _windowHandleProvider; - EditForm.WindowHandleProvider = _windowHandleProvider; + ViewModel.AnnouncementRequested += AnnounceToAssistiveTechnology; + ViewModel.PropertyChanged += OnViewModelPropertyChanged; - // SelectorBar starts with no selection; the view model's filter default is - // All, so select that item (the resulting SelectionChanged is a no-op set). - FilterSelectorBar.SelectedItem = FilterAllItem; - UpdateDerivedTexts(); - } + // The chip's repo count, the toolbar's selection summary, and the + // empty-filter placeholder are set from code: they derive from collection + // counts, which raise no property change an x:Bind function could ride on. + _onReposChanged = (_, _) => UpdateDerivedTexts(); + _onFilteredChanged = (_, _) => UpdateDerivedTexts(); + ViewModel.Repos.CollectionChanged += _onReposChanged; + ViewModel.FilteredRepos.CollectionChanged += _onFilteredChanged; - /// - /// Label for the org-subfolder checkbox in the Options flyout; names the actual - /// organization once one is chosen (the connection form has its own copy). - /// - public string OrgSubfolderLabel(string organization) - => string.IsNullOrWhiteSpace(organization) - ? "Create org subfolder" - : $"Create {organization.Trim()} subfolder"; + // The view model stays UI-free: when ResolvePathsCommand needs the user's + // path-recovery choices, it calls back through here and the page answers + // with PathRecoveryDialog. + ViewModel.RecoveryInteraction = ShowPathRecoveryDialogAsync; - /// True when this workspace is backed by a saved account (vs Quick Sync). - public bool IsAccountWorkspace => ViewModel.AccountId is not null; + // Both hosts of the shared connection form need the window handle for + // their folder picker. + ConnectForm.WindowHandleProvider = _windowHandleProvider; + EditForm.WindowHandleProvider = _windowHandleProvider; - /// - /// Unhooks every subscription this page made on its (longer-lived) view model, so - /// a page the shell releases while its workspace is idle can be collected: the - /// view model's event lists would otherwise pin the whole visual tree (#30). - /// The view model itself is untouched; a fresh page re-hydrates from it. - /// - public void Detach() - { - ViewModel.AnnouncementRequested -= AnnounceToAssistiveTechnology; - ViewModel.PropertyChanged -= OnViewModelPropertyChanged; - ViewModel.Repos.CollectionChanged -= _onReposChanged; - ViewModel.FilteredRepos.CollectionChanged -= _onFilteredChanged; - ViewModel.RecoveryInteraction = null; - } + // SelectorBar starts with no selection; the view model's filter default is + // All, so select that item (the resulting SelectionChanged is a no-op set). + FilterSelectorBar.SelectedItem = FilterAllItem; + UpdateDerivedTexts(); + } - /// - /// Composed automation name for a sortable column header, e.g. - /// "Name, sorted ascending" or "Branch, not sorted". - /// - public static string SortAutomationName(string column, string? sortColumn, bool sortDescending) - => column != sortColumn - ? $"{column}, not sorted" - : sortDescending - ? $"{column}, sorted descending" - : $"{column}, sorted ascending"; - // ---- TableView bridge (#33): the control raises sort requests, the view model - // decides. Sorting (first and second click) and ClearSorting (the control's - // third-click "unsorted" step) both hand the column to SortCommand, which - // cycles ascending <-> descending; the indicator and the header's automation - // name are then written back from SortColumn/SortDescending. - private void RepoTable_Sorting(object sender, TableViewSortingEventArgs e) - { - e.Handled = true; // never let the control re-sort its own collection view - if (e.Column?.Tag is string column) + /// + /// Label for the org-subfolder checkbox in the Options flyout; names the actual + /// organization once one is chosen (the connection form has its own copy). + /// + public string OrgSubfolderLabel(string organization) + => string.IsNullOrWhiteSpace(organization) + ? "Create org subfolder" + : $"Create {organization.Trim()} subfolder"; + + + + /// True when this workspace is backed by a saved account (vs Quick Sync). + public bool IsAccountWorkspace => ViewModel.AccountId is not null; + + + + /// + /// Unhooks every subscription this page made on its (longer-lived) view model, so + /// a page the shell releases while its workspace is idle can be collected: the + /// view model's event lists would otherwise pin the whole visual tree (#30). + /// The view model itself is untouched; a fresh page re-hydrates from it. + /// + public void Detach() + { + ViewModel.AnnouncementRequested -= AnnounceToAssistiveTechnology; + ViewModel.PropertyChanged -= OnViewModelPropertyChanged; + ViewModel.Repos.CollectionChanged -= _onReposChanged; + ViewModel.FilteredRepos.CollectionChanged -= _onFilteredChanged; + ViewModel.RecoveryInteraction = null; + } + + + + /// + /// Composed automation name for a sortable column header, e.g. + /// "Name, sorted ascending" or "Branch, not sorted". + /// + public static string SortAutomationName(string column, string? sortColumn, bool sortDescending) + => !string.Equals(column, sortColumn +, StringComparison.Ordinal) ? $"{column}, not sorted" + : sortDescending + ? $"{column}, sorted descending" + : $"{column}, sorted ascending"; + + + + // ---- TableView bridge (#33): the control raises sort requests, the view model + // decides. Sorting (first and second click) and ClearSorting (the control's + // third-click "unsorted" step) both hand the column to SortCommand, which + // cycles ascending <-> descending; the indicator and the header's automation + // name are then written back from SortColumn/SortDescending. + + private void RepoTable_Sorting(object sender, TableViewSortingEventArgs e) + { + e.Handled = true; // never let the control re-sort its own collection view + if(e.Column?.Tag is string column) + { + ViewModel.SortCommand.Execute(column); + } + } + + + + private void RepoTable_ClearSorting(object sender, TableViewClearSortingEventArgs e) + { + e.Handled = true; + if(e.Column?.Tag is string column) + { + ViewModel.SortCommand.Execute(column); + } + } + + + + private void RepoTable_Loaded(object sender, RoutedEventArgs e) => SyncSortIndicators(); + + + + /// + /// Writes the view model's sort state onto the columns: the header's sort + /// indicator and its composed automation name ("Name, sorted ascending"). + /// Columns whose header control is not realized yet keep the static name from + /// their HeaderStyle until the next call. + /// + private void SyncSortIndicators() + { + foreach(TableViewColumn column in RepoTable.Columns) + { + if(column.Tag is not string key || !column.CanSort) + { + continue; + } + bool active = string.Equals(key, ViewModel.SortColumn, StringComparison.Ordinal); + column.SortDirection = active + ? (ViewModel.SortDescending ? SortDirection.Descending : SortDirection.Ascending) + : null; + if(column.HeaderControl is { } header) + { + AutomationProperties.SetName(header, SortAutomationName(key, ViewModel.SortColumn, ViewModel.SortDescending)); + } + } + } + + + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if(e.PropertyName is nameof(WorkspaceViewModel.SortColumn) or nameof(WorkspaceViewModel.SortDescending)) + { + SyncSortIndicators(); + } + else if(string.Equals(e.PropertyName, nameof(WorkspaceViewModel.StatusText), StringComparison.Ordinal)) + { + // LiveSetting alone does not announce: XAML never raises + // LiveRegionChanged automatically, so each StatusText update must raise + // it here. Enqueued so the binding has pushed the new text first. + DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged); + } + else if(e.PropertyName is nameof(WorkspaceViewModel.SelectedCount) + or nameof(WorkspaceViewModel.LoadedOrganization)) + { + UpdateDerivedTexts(); + } + else if(string.Equals(e.PropertyName, nameof(WorkspaceViewModel.Filter), StringComparison.Ordinal)) + { + // The status filter can now change from the header flyout too; keep + // the toolbar chips in agreement. Selecting the already-selected item + // (or the echo from the chips' own handler) is a no-op. + foreach(SelectorBarItem item in FilterSelectorBar.Items) + { + if(item.Tag is string tag && string.Equals(tag, ViewModel.Filter.ToString(), StringComparison.Ordinal)) { - ViewModel.SortCommand.Execute(column); + FilterSelectorBar.SelectedItem = item; + break; } - } + } + } + } - private void RepoTable_ClearSorting(object sender, TableViewClearSortingEventArgs e) - { - e.Handled = true; - if (e.Column?.Tag is string column) - { - ViewModel.SortCommand.Execute(column); - } - } - private void RepoTable_Loaded(object sender, RoutedEventArgs e) => SyncSortIndicators(); - /// - /// Writes the view model's sort state onto the columns: the header's sort - /// indicator and its composed automation name ("Name, sorted ascending"). - /// Columns whose header control is not realized yet keep the static name from - /// their HeaderStyle until the next call. - /// - private void SyncSortIndicators() - { - foreach (TableViewColumn column in RepoTable.Columns) - { - if (column.Tag is not string key || !column.CanSort) - { - continue; - } - bool active = key == ViewModel.SortColumn; - column.SortDirection = active - ? (ViewModel.SortDescending ? SortDirection.Descending : SortDirection.Ascending) - : null; - if (column.HeaderControl is { } header) - { - AutomationProperties.SetName(header, SortAutomationName(key, ViewModel.SortColumn, ViewModel.SortDescending)); - } - } - } + /// + /// Refreshes the count-derived texts: the chip's repository count, the + /// toolbar's "N of M selected" summary, and the empty-filter placeholder. + /// + private void UpdateDerivedTexts() + { + ChipRepoCountText.Text = StatusFormat.RepoCountText(ViewModel.Repos.Count); + SelectionSummaryText.Text = + StatusFormat.SelectionSummary(ViewModel.SelectedCount, ViewModel.Repos.Count); + EmptyFilterText.Visibility = + ViewModel.FilteredRepos.Count == 0 && ViewModel.Repos.Count > 0 + ? Visibility.Visible + : Visibility.Collapsed; - private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName is nameof(WorkspaceViewModel.SortColumn) or nameof(WorkspaceViewModel.SortDescending)) - { - SyncSortIndicators(); - } - else if (e.PropertyName == nameof(WorkspaceViewModel.StatusText)) - { - // LiveSetting alone does not announce: XAML never raises - // LiveRegionChanged automatically, so each StatusText update must raise - // it here. Enqueued so the binding has pushed the new text first. - DispatcherQueue.TryEnqueue(RaiseStatusLiveRegionChanged); - } - else if (e.PropertyName is nameof(WorkspaceViewModel.SelectedCount) - or nameof(WorkspaceViewModel.LoadedOrganization)) - { - UpdateDerivedTexts(); - } - else if (e.PropertyName == nameof(WorkspaceViewModel.Filter)) - { - // The status filter can now change from the header flyout too; keep - // the toolbar chips in agreement. Selecting the already-selected item - // (or the echo from the chips' own handler) is a no-op. - foreach (SelectorBarItem item in FilterSelectorBar.Items) - { - if (item.Tag is string tag && tag == ViewModel.Filter.ToString()) - { - FilterSelectorBar.SelectedItem = item; - break; - } - } - } - } + bool loadedNothing = ViewModel.HasLoadedRepos && ViewModel.Repos.Count == 0; + EmptyTableText.Text = loadedNothing + ? $"No repositories found in '{ViewModel.LoadedOrganization}'. " + + "Check the name, or make sure the token can see its repositories." + : ""; + EmptyTableText.Visibility = loadedNothing ? Visibility.Visible : Visibility.Collapsed; + } - /// - /// Refreshes the count-derived texts: the chip's repository count, the - /// toolbar's "N of M selected" summary, and the empty-filter placeholder. - /// - private void UpdateDerivedTexts() - { - ChipRepoCountText.Text = StatusFormat.RepoCountText(ViewModel.Repos.Count); - SelectionSummaryText.Text = - StatusFormat.SelectionSummary(ViewModel.SelectedCount, ViewModel.Repos.Count); - EmptyFilterText.Visibility = - ViewModel.FilteredRepos.Count == 0 && ViewModel.Repos.Count > 0 - ? Visibility.Visible - : Visibility.Collapsed; - bool loadedNothing = ViewModel.HasLoadedRepos && ViewModel.Repos.Count == 0; - EmptyTableText.Text = loadedNothing - ? $"No repositories found in '{ViewModel.LoadedOrganization}'. " - + "Check the name, or make sure the token can see its repositories." - : ""; - EmptyTableText.Visibility = loadedNothing ? Visibility.Visible : Visibility.Collapsed; - } - private void FilterSelectorBar_SelectionChanged( - SelectorBar sender, SelectorBarSelectionChangedEventArgs args) - { - if (sender.SelectedItem?.Tag is string tag && Enum.TryParse(tag, out RepoFilter filter)) - { - ViewModel.Filter = filter; - } - } + private void FilterSelectorBar_SelectionChanged( + SelectorBar sender, SelectorBarSelectionChangedEventArgs args) + { + if(sender.SelectedItem?.Tag is string tag && Enum.TryParse(tag, out RepoFilter filter)) + { + ViewModel.Filter = filter; + } + } - // -------------------------------------------------------- column filters - // - // The flyout inputs bind OneWay from the view model and write back through - // these handlers; the view model setters no-op on equal values, so the echo - // from a programmatic Text/SelectedIndex update never loops. - private void NameFilterBox_TextChanged(object sender, TextChangedEventArgs e) - => ViewModel.NameFilter = ((TextBox)sender).Text; - private void ClearNameFilter_Click(object sender, RoutedEventArgs e) - => ViewModel.NameFilter = ""; + // -------------------------------------------------------- column filters + // + // The flyout inputs bind OneWay from the view model and write back through + // these handlers; the view model setters no-op on equal values, so the echo + // from a programmatic Text/SelectedIndex update never loops. - private void BranchFilterBox_TextChanged(object sender, TextChangedEventArgs e) - => ViewModel.BranchFilter = ((TextBox)sender).Text; + private void NameFilterBox_TextChanged(object sender, TextChangedEventArgs e) + => ViewModel.NameFilter = ((TextBox)sender).Text; - private void ClearBranchFilter_Click(object sender, RoutedEventArgs e) - => ViewModel.BranchFilter = ""; - private void StatusFilterChoices_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (((RadioButtons)sender).SelectedIndex is >= 0 and int index) - { - ViewModel.Filter = (RepoFilter)index; // list order mirrors the enum - } - } - private void ArchivedFilterChoices_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - ViewModel.ArchivedFilter = ((RadioButtons)sender).SelectedIndex switch - { - 1 => true, - 2 => false, - _ => null, - }; - } + private void ClearNameFilter_Click(object sender, RoutedEventArgs e) + => ViewModel.NameFilter = ""; - /// Flyout radio index for the status filter; list order mirrors the enum. - public int StatusFilterIndex(RepoFilter filter) => (int)filter; - /// Flyout radio index for the archived filter: All / Archived only / Not archived. - public int ArchivedFilterIndex(bool? filter) => filter switch - { - true => 1, - false => 2, - null => 0, - }; - /// Funnel tint: accent while that column's filter is active. - public Microsoft.UI.Xaml.Media.Brush TextFilterIndicatorBrush(string filter) - => FilterIndicatorBrush(filter.Length > 0); + private void BranchFilterBox_TextChanged(object sender, TextChangedEventArgs e) + => ViewModel.BranchFilter = ((TextBox)sender).Text; - public Microsoft.UI.Xaml.Media.Brush StatusFilterIndicatorBrush(RepoFilter filter) - => FilterIndicatorBrush(filter != RepoFilter.All); - public Microsoft.UI.Xaml.Media.Brush ArchivedFilterIndicatorBrush(bool? filter) - => FilterIndicatorBrush(filter is not null); - private static Microsoft.UI.Xaml.Media.Brush FilterIndicatorBrush(bool active) - => (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[ - active ? "AccentTextFillColorPrimaryBrush" : "TextFillColorSecondaryBrush"]; + private void ClearBranchFilter_Click(object sender, RoutedEventArgs e) + => ViewModel.BranchFilter = ""; - /// Composed automation name for a text-filter funnel, carrying its state. - public string TextFilterAutomationName(string column, string filter) - => filter.Length == 0 - ? $"Filter by {column}" - : $"Filter by {column}, filtering on '{filter}'"; - public string StatusFilterAutomationName(RepoFilter filter) - => filter == RepoFilter.All - ? "Filter by status" - : $"Filter by status, showing {filter}"; - public string ArchivedFilterAutomationName(bool? filter) => filter switch - { - true => "Filter by archived, showing archived only", - false => "Filter by archived, showing not archived", - null => "Filter by archived", - }; + private void StatusFilterChoices_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if(((RadioButtons)sender).SelectedIndex is >= 0 and int index) + { + ViewModel.Filter = (RepoFilter)index; // list order mirrors the enum + } + } - private void Toolbar_SizeChanged(object sender, SizeChangedEventArgs e) - => UpdateToolbarLayout(); - /// - /// Picks the toolbar layout from measured widths: the action buttons sit beside - /// the filter bar only when both fit on one row, otherwise they drop to their - /// own right-aligned row. Measuring (instead of a window-width trigger) makes - /// this respond to every source of width pressure — window size, Windows text - /// scaling, localization, and count-dependent label growth. The idle/running - /// action groups swap by visibility, so the wider of the two is what must fit; - /// a collapsed group keeps its last arranged width, which is exactly the width - /// it will need when it comes back. - /// - private void UpdateToolbarLayout() - { - // Two ColumnSpacing gaps (filter | summary | actions); the summary itself - // may trim to nothing, so it claims no reserved width here. - double gaps = ToolbarGrid.ColumnSpacing * 2; - double actions = Math.Max(IdleActions.ActualWidth, RunningActions.ActualWidth); - bool fits = FilterSelectorBar.ActualWidth + gaps + actions <= ToolbarGrid.ActualWidth; - VisualStateManager.GoToState( - this, fits ? "ToolbarSingleRow" : "ToolbarTwoRow", useTransitions: false); - } - // The chip's Edit link opens its attached flyout (HyperlinkButton has no - // Flyout property of its own). - private void EditButton_Click(object sender, RoutedEventArgs e) - => FlyoutBase.ShowAttachedFlyout((FrameworkElement)sender); + private void ArchivedFilterChoices_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + ViewModel.ArchivedFilter = ((RadioButtons)sender).SelectedIndex switch + { + 1 => true, + 2 => false, + _ => null, + }; + } - private async void EditAccountButton_Click(object sender, RoutedEventArgs e) - { - EditFlyout.Hide(); - if (ViewModel.AccountId is Guid id && EditAccountRequested is { } edit) - { - await edit(id); - } - } - private async void SaveAsAccountButton_Click(object sender, RoutedEventArgs e) - { - EditFlyout.Hide(); - if (SaveAsAccountRequested is { } save) - { - await save(new AccountWizardSeed( - ViewModel.Token, - ViewModel.Organization, - ViewModel.TargetFolder, - ViewModel.CreateOrgSubfolder, - ViewModel.MaxConcurrency)); - } - } - private void RaiseStatusLiveRegionChanged() - { - var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(StatusTextBlock) - ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(StatusTextBlock); - peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged); - } + /// Flyout radio index for the status filter; list order mirrors the enum. + public int StatusFilterIndex(RepoFilter filter) => (int)filter; - /// - /// Raises a UIA notification so screen readers hear per-repo failures. Routed - /// through the ListView's peer: panels like Grid have no automation peer. - /// ImportantMostRecent coalesces bursts when many repositories fail at once. - /// - private void AnnounceToAssistiveTechnology(string message) - { - if (!DispatcherQueue.HasThreadAccess) - { - DispatcherQueue.TryEnqueue(() => AnnounceToAssistiveTechnology(message)); - return; - } - var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoTable) - ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoTable); - peer?.RaiseNotificationEvent( - Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted, - Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent, - message, - "gclo-repo-status"); - } - /// - /// Shows for a repository whose checkout was - /// blocked by Windows-invalid paths. Returns the user's recovery choice, or null - /// when the dialog was dismissed (or the row carries no path details). - /// - private async Task ShowPathRecoveryDialogAsync(RepoItemViewModel item) - { - if (item.InvalidPaths is not { Count: > 0 } paths) - { - return null; - } + /// Flyout radio index for the archived filter: All / Archived only / Not archived. + public int ArchivedFilterIndex(bool? filter) => filter switch + { + true => 1, + false => 2, + null => 0, + }; - var dialog = new PathRecoveryDialog(item.Name, paths, ViewModel.IsWslCloneAvailable) { XamlRoot = XamlRoot }; - await DialogGuard.ShowAsync(dialog); - return dialog.Decision; // blocked by another open dialog reads as dismissed - } - private async void OpenFolderButton_Click(object sender, RoutedEventArgs e) - { - string root = ViewModel.EffectiveTargetRoot; - if (Directory.Exists(root)) - { - ViewModel.NoteFolderOpened(); - await Windows.System.Launcher.LaunchFolderPathAsync(root); - } - } - } + + /// Funnel tint: accent while that column's filter is active. + public Microsoft.UI.Xaml.Media.Brush TextFilterIndicatorBrush(string filter) + => FilterIndicatorBrush(filter.Length > 0); + + + + public Microsoft.UI.Xaml.Media.Brush StatusFilterIndicatorBrush(RepoFilter filter) + => FilterIndicatorBrush(filter != RepoFilter.All); + + + + public Microsoft.UI.Xaml.Media.Brush ArchivedFilterIndicatorBrush(bool? filter) + => FilterIndicatorBrush(filter is not null); + + + + private static Microsoft.UI.Xaml.Media.Brush FilterIndicatorBrush(bool active) + => (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources[ + active ? "AccentTextFillColorPrimaryBrush" : "TextFillColorSecondaryBrush"]; + + + + /// Composed automation name for a text-filter funnel, carrying its state. + public string TextFilterAutomationName(string column, string filter) + => filter.Length == 0 + ? $"Filter by {column}" + : $"Filter by {column}, filtering on '{filter}'"; + + + + public string StatusFilterAutomationName(RepoFilter filter) + => filter == RepoFilter.All + ? "Filter by status" + : $"Filter by status, showing {filter}"; + + + + public string ArchivedFilterAutomationName(bool? filter) => filter switch + { + true => "Filter by archived, showing archived only", + false => "Filter by archived, showing not archived", + null => "Filter by archived", + }; + + + + private void Toolbar_SizeChanged(object sender, SizeChangedEventArgs e) + => UpdateToolbarLayout(); + + + + /// + /// Picks the toolbar layout from measured widths: the action buttons sit beside + /// the filter bar only when both fit on one row, otherwise they drop to their + /// own right-aligned row. Measuring (instead of a window-width trigger) makes + /// this respond to every source of width pressure — window size, Windows text + /// scaling, localization, and count-dependent label growth. The idle/running + /// action groups swap by visibility, so the wider of the two is what must fit; + /// a collapsed group keeps its last arranged width, which is exactly the width + /// it will need when it comes back. + /// + private void UpdateToolbarLayout() + { + // Two ColumnSpacing gaps (filter | summary | actions); the summary itself + // may trim to nothing, so it claims no reserved width here. + double gaps = ToolbarGrid.ColumnSpacing * 2; + double actions = Math.Max(IdleActions.ActualWidth, RunningActions.ActualWidth); + bool fits = FilterSelectorBar.ActualWidth + gaps + actions <= ToolbarGrid.ActualWidth; + VisualStateManager.GoToState( + this, fits ? "ToolbarSingleRow" : "ToolbarTwoRow", useTransitions: false); + } + + + + // The chip's Edit link opens its attached flyout (HyperlinkButton has no + // Flyout property of its own). + private void EditButton_Click(object sender, RoutedEventArgs e) + => FlyoutBase.ShowAttachedFlyout((FrameworkElement)sender); + + + + private async void EditAccountButton_Click(object sender, RoutedEventArgs e) + { + EditFlyout.Hide(); + if(ViewModel.AccountId is Guid id && EditAccountRequested is { } edit) + { + await edit(id); + } + } + + + + private async void SaveAsAccountButton_Click(object sender, RoutedEventArgs e) + { + EditFlyout.Hide(); + if(SaveAsAccountRequested is { } save) + { + await save(new AccountWizardSeed( + ViewModel.Token, + ViewModel.Organization, + ViewModel.TargetFolder, + ViewModel.CreateOrgSubfolder, + ViewModel.MaxConcurrency)); + } + } + + + + private void RaiseStatusLiveRegionChanged() + { + AutomationPeer peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(StatusTextBlock) + ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(StatusTextBlock); + peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged); + } + + + + /// + /// Raises a UIA notification so screen readers hear per-repo failures. Routed + /// through the ListView's peer: panels like Grid have no automation peer. + /// ImportantMostRecent coalesces bursts when many repositories fail at once. + /// + private void AnnounceToAssistiveTechnology(object? sender, AnnouncementEventArgs e) + { + if(!DispatcherQueue.HasThreadAccess) + { + DispatcherQueue.TryEnqueue(() => AnnounceToAssistiveTechnology(sender, e)); + return; + } + + AutomationPeer peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.FromElement(RepoTable) + ?? Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(RepoTable); + peer?.RaiseNotificationEvent( + Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationKind.ActionCompleted, + Microsoft.UI.Xaml.Automation.Peers.AutomationNotificationProcessing.ImportantMostRecent, + e.Message, + "gclo-repo-status"); + } + + + + /// + /// Shows for a repository whose checkout was + /// blocked by Windows-invalid paths. Returns the user's recovery choice, or null + /// when the dialog was dismissed (or the row carries no path details). + /// + private async Task ShowPathRecoveryDialogAsync(RepoItemViewModel item) + { + if(item.InvalidPaths is not { Count: > 0 } paths) + { + return null; + } + + var dialog = new PathRecoveryDialog(item.Name, paths, ViewModel.IsWslCloneAvailable) { XamlRoot = XamlRoot }; + await DialogGuard.ShowAsync(dialog); + return dialog.Decision; // blocked by another open dialog reads as dismissed + } + + + + private async void OpenFolderButton_Click(object sender, RoutedEventArgs e) + { + string root = ViewModel.EffectiveTargetRoot; + if(Directory.Exists(root)) + { + ViewModel.NoteFolderOpened(); + await Windows.System.Launcher.LaunchFolderPathAsync(root); + } + } } diff --git a/gclo/gclo.csproj b/gclo/gclo.csproj index a936eb6..7fbc212 100644 --- a/gclo/gclo.csproj +++ b/gclo/gclo.csproj @@ -13,6 +13,7 @@ false true enable + disable $(DefineConstants);DISABLE_XAML_GENERATED_MAIN @@ -43,11 +44,11 @@ - - - - - + + + + + diff --git a/gclo/packages.lock.json b/gclo/packages.lock.json index c87700e..ea71a94 100644 --- a/gclo/packages.lock.json +++ b/gclo/packages.lock.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "dependencies": { "net10.0-windows10.0.19041": { "CommunityToolkit.Mvvm": { @@ -8,6 +8,12 @@ "resolved": "8.4.2", "contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg==" }, + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.296, )", + "resolved": "3.0.296", + "contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg==" + }, "Microsoft.Windows.SDK.BuildTools": { "type": "Direct", "requested": "[10.0.28000.2705, )", @@ -47,14 +53,6 @@ "Microsoft.WindowsAppSDK.WinUI": "1.8.260528001" } }, - "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", @@ -168,11 +166,6 @@ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8" } }, - "Octokit": { - "type": "Transitive", - "resolved": "14.0.0", - "contentHash": "jGOuTH1l+TCpJH+fwYOp7USzHDuGfN1jKbLz3J2COwyn+wL08eynvpnM6rY2qkzIEXum3PN2p2QkP3BW/p9Qcw==" - }, "System.Numerics.Tensors": { "type": "Transitive", "resolved": "9.0.0", @@ -191,6 +184,21 @@ "CommunityToolkit.Mvvm": "[8.4.2, )", "gclo.Engine": "[1.0.1-dev, )" } + }, + "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-windows10.0.19041/win-arm64": { diff --git a/packaging/chocolatey/tools/chocolateyinstall.ps1 b/packaging/chocolatey/tools/chocolateyinstall.ps1 index 748428d..bcd2c96 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyinstall.ps1 @@ -1,16 +1,18 @@ +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# Installs the self-contained gclo CLI zip into the package's tools folder; -# Chocolatey shims gclo.exe onto PATH automatically. The URL and checksum are -# updated per stable release by .github/scripts/Update-PackageManifests.ps1. +################################################################################ +## Installs the self-contained gclo CLI zip into the package's tools folder; ## +## Chocolatey shims gclo.exe onto PATH automatically. The URL and checksum ## +## are updated per stable release by Update-PackageManifests.ps1. ## +################################################################################ $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$packageArgs = @{ - packageName = 'gclo' - unzipLocation = $toolsDir - url64bit = 'https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip' - checksum64 = '42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee' - checksumType64 = 'sha256' -} +$packageArgs = @{} +$packageArgs.packageName = 'gclo' +$packageArgs.unzipLocation = $toolsDir +$packageArgs.url64bit = 'https://github.com/KofTwentyTwo/gclo/releases/download/v1.0.0/gclo-cli-win-x64.zip' +$packageArgs.checksum64 = '42805ca95a4f4d306df04f47a2b9aba54eee3df11cda8e3f201fe5a58311feee' +$packageArgs.checksumType64 = 'sha256' Install-ChocolateyZipPackage @packageArgs