From 901a5ae9ccbe83f8565296f512cde3fcc63b6cf1 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 10:38:33 -0500 Subject: [PATCH 1/2] style: adopt the Kingsrook C# layout and the standard .NET quality gate The standard .editorconfig and Directory.Build.props from KofTwentyTwo/standards (3-space indent, Allman braces, `if(` without a space, file-scoped namespaces, var only when apparent, `s_` static fields, IDE0055 and the Meziantou analyzers as build errors, NuGetAudit on the whole graph, CI-conditional locked restore), central package management in Directory.Packages.props, LF line endings via .gitattributes, and the SPDX header on every C# file. Members are separated by three blank lines, one top-level type per file (24 types moved into their own files), ArgumentException parameter names, bounded regex match times, the announcement event in the EventHandler shape, and the CLI sync command split into option parsing, the run, and summary reporting. Repository-specific adjustments, each justified in .editorconfig: namespaces keep the product name `gclo`; MA0051 flags only extremes (160 lines / 100 statements); the app project keeps CA1822, IDE0051/52 (XAML-referenced handlers; dotnet format's fixer would delete them) and IDE0060 (handler signatures) off; XAML stays at 4 spaces. PowerShell scripts use the standard PSScriptAnalyzer settings: strict mode, comment-based help, Allman and 3-space layout, no Write-Host; the NuGet update script now edits Directory.Packages.props. The CI format gate runs at --severity warn and PSScriptAnalyzer. Behavior is unchanged: Engine 422, CLI 129 and FlaUI 11 tests pass, line coverage stays at 100%. Closes #65 Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- .editorconfig | 246 +- .gitattributes | 13 +- .github/scripts/Update-NuGetPackages.ps1 | 376 +- .github/scripts/Update-PackageManifests.ps1 | 112 +- .github/workflows/ci.yml | 17 +- Directory.Build.props | 47 +- Directory.Packages.props | 33 + gclo.Cli.Tests/AccountsCommandTests.cs | 643 +-- gclo.Cli.Tests/AssemblyInfo.cs | 5 + gclo.Cli.Tests/CliTestSupport.cs | 199 +- gclo.Cli.Tests/OptionReaderTests.cs | 148 +- gclo.Cli.Tests/OrgsCommandTests.cs | 207 +- gclo.Cli.Tests/PackagingManifestTests.cs | 149 +- gclo.Cli.Tests/ProgressPrinterTests.cs | 161 +- gclo.Cli.Tests/RepoFilterSpecTests.cs | 133 +- gclo.Cli.Tests/ReposCommandTests.cs | 239 +- gclo.Cli.Tests/SyncCommandTests.cs | 1027 ++-- gclo.Cli.Tests/TokenOptionsTests.cs | 276 +- gclo.Cli.Tests/gclo.Cli.Tests.csproj | 8 +- gclo.Cli.Tests/packages.lock.json | 47 +- gclo.Cli/AccountSummary.cs | 24 + gclo.Cli/AccountsCommand.cs | 709 +-- gclo.Cli/CliErrorException.cs | 40 + gclo.Cli/CliJson.cs | 75 - gclo.Cli/CliJsonContext.cs | 23 + gclo.Cli/CliUsageException.cs | 18 + gclo.Cli/CommandLine.cs | 163 - gclo.Cli/DryRunEntry.cs | 10 + gclo.Cli/ExitCodes.cs | 47 + gclo.Cli/OptionReader.cs | 109 + gclo.Cli/OrgsCommand.cs | 167 +- gclo.Cli/OutputMode.cs | 23 + gclo.Cli/Program.cs | 235 +- gclo.Cli/ProgressPrinter.cs | 205 +- gclo.Cli/RepoFilterSpec.cs | 106 +- gclo.Cli/RepoSummary.cs | 10 + gclo.Cli/ReposCommand.cs | 223 +- gclo.Cli/SyncCommand.cs | 1042 ++-- gclo.Cli/SyncFailure.cs | 14 + gclo.Cli/SyncInvalidPath.cs | 10 + gclo.Cli/SyncJsonResult.cs | 23 + gclo.Cli/SyncProgressLine.cs | 10 + gclo.Cli/SyncSanitized.cs | 14 + gclo.Cli/TokenOptions.cs | 284 +- gclo.Cli/packages.lock.json | 45 +- .../AccountWizardViewModelTests.cs | 963 ++-- .../AccountsStoreConsistencyTests.cs | 831 ++-- gclo.Engine.Tests/AccountsStoreTests.cs | 364 +- gclo.Engine.Tests/ActionLoggingTests.cs | 324 +- gclo.Engine.Tests/ActivityLogTests.cs | 310 +- gclo.Engine.Tests/AppSettingsTests.cs | 145 +- gclo.Engine.Tests/BuildVersionTests.cs | 48 +- gclo.Engine.Tests/CoverageResidueTests.cs | 437 +- gclo.Engine.Tests/Fakes.cs | 350 +- gclo.Engine.Tests/GitHubListerTests.cs | 832 ++-- gclo.Engine.Tests/GitTestHelpers.cs | 210 +- .../InvalidRepositoryPathsExceptionTests.cs | 52 +- gclo.Engine.Tests/LibGit2GitClientTests.cs | 1054 ++-- gclo.Engine.Tests/OrgSyncEngineTests.cs | 806 ++-- gclo.Engine.Tests/PathRecoveryPlannerTests.cs | 123 +- gclo.Engine.Tests/PathRecoveryTests.cs | 941 ++-- .../RepositoryPathResolverTests.cs | 284 +- gclo.Engine.Tests/SyncAllCoordinatorTests.cs | 595 +-- gclo.Engine.Tests/SyncOverloadTests.cs | 215 +- gclo.Engine.Tests/SyncRequestTests.cs | 24 +- gclo.Engine.Tests/VaultEdgeTests.cs | 201 +- gclo.Engine.Tests/VaultTests.cs | 135 +- .../WindowsPathValidationTests.cs | 562 ++- gclo.Engine.Tests/WorkspaceViewModelTests.cs | 4226 +++++++++-------- gclo.Engine.Tests/WslClonerTests.cs | 397 +- gclo.Engine.Tests/gclo.Engine.Tests.csproj | 10 +- gclo.Engine.Tests/packages.lock.json | 47 +- gclo.Engine/BuildVersion.cs | 73 +- gclo.Engine/FileActivityLog.cs | 129 +- gclo.Engine/GitHubAccessException.cs | 38 +- gclo.Engine/GitHubAccessKind.cs | 23 + gclo.Engine/GitHubAccountKind.cs | 20 + gclo.Engine/GitHubOrganizationLister.cs | 163 +- gclo.Engine/GitHubRepo.cs | 10 + gclo.Engine/GitHubRepositoryLister.cs | 255 +- gclo.Engine/IActivityLog.cs | 26 +- gclo.Engine/IGitClient.cs | 58 +- gclo.Engine/IGitHubGateway.cs | 123 +- gclo.Engine/IOrganizationLister.cs | 20 +- gclo.Engine/IProcessRunner.cs | 26 + gclo.Engine/IRepositoryLister.cs | 12 +- gclo.Engine/IWslCloner.cs | 33 + gclo.Engine/InvalidPathInfo.cs | 6 + .../InvalidRepositoryPathsException.cs | 42 +- gclo.Engine/LibGit2GitClient.cs | 1527 +++--- gclo.Engine/NullActivityLog.cs | 33 + gclo.Engine/OctokitGateway.cs | 89 + gclo.Engine/OctokitGatewayCache.cs | 38 + gclo.Engine/OrgSyncEngine.cs | 302 +- gclo.Engine/PathRecovery.cs | 6 + gclo.Engine/ProcessResult.cs | 10 + gclo.Engine/ProcessRunner.cs | 85 + gclo.Engine/RepoDescriptor.cs | 6 + gclo.Engine/RepoProgress.cs | 6 + gclo.Engine/RepositoryPathResolver.cs | 166 +- gclo.Engine/SyncRequest.cs | 22 +- gclo.Engine/SyncStatus.cs | 30 +- gclo.Engine/SyncSummary.cs | 6 + gclo.Engine/WindowsPathValidator.cs | 443 +- gclo.Engine/WslAvailability.cs | 12 + gclo.Engine/WslCloneException.cs | 14 + gclo.Engine/WslCloneResult.cs | 13 + gclo.Engine/WslCloner.cs | 365 +- gclo.Engine/gclo.Engine.csproj | 4 +- gclo.Engine/packages.lock.json | 8 +- gclo.UiTests/AppSession.cs | 981 ++-- gclo.UiTests/RepoTableTests.cs | 235 +- gclo.UiTests/SmokeTests.cs | 323 +- gclo.UiTests/UiSessionTests.cs | 26 +- gclo.UiTests/gclo.UiTests.csproj | 8 +- gclo.UiTests/packages.lock.json | 8 +- gclo.ViewModels/Account.cs | 48 +- .../AccountConsistencyException.cs | 24 +- gclo.ViewModels/AccountWizardSeed.cs | 14 +- gclo.ViewModels/AccountWizardViewModel.cs | 586 +-- gclo.ViewModels/AccountsJsonContext.cs | 20 + gclo.ViewModels/AccountsStore.cs | 574 +-- gclo.ViewModels/AnnouncementEventArgs.cs | 26 + gclo.ViewModels/AppSettings.cs | 199 +- gclo.ViewModels/AppSettingsJsonContext.cs | 20 + gclo.ViewModels/CredentialManagerVault.cs | 362 +- gclo.ViewModels/GcloPaths.cs | 32 +- gclo.ViewModels/ITokenVault.cs | 34 +- gclo.ViewModels/InMemoryVault.cs | 39 +- gclo.ViewModels/PathRecoveryDecision.cs | 27 +- gclo.ViewModels/PathRecoveryPlanner.cs | 101 +- gclo.ViewModels/RepoFilter.cs | 22 +- gclo.ViewModels/RepoItemViewModel.cs | 221 +- gclo.ViewModels/RunResultKind.cs | 26 +- gclo.ViewModels/SyncAllAccountState.cs | 26 + gclo.ViewModels/SyncAllCoordinator.cs | 202 +- gclo.ViewModels/SyncAllResult.cs | 15 + gclo.ViewModels/WorkspaceViewModel.cs | 2423 +++++----- gclo.ViewModels/gclo.ViewModels.csproj | 4 +- gclo.ViewModels/packages.lock.json | 34 +- gclo/AboutDialog.xaml.cs | 90 +- gclo/AccessibleTableView.cs | 144 +- gclo/AccessibleTableViewPeer.cs | 72 + gclo/AccountWizardDialog.xaml.cs | 382 +- gclo/App.xaml.cs | 138 +- gclo/Attribution.cs | 14 + gclo/ColumnHeaderContent.cs | 36 + gclo/ConnectionFormControl.xaml.cs | 211 +- gclo/DialogGuard.cs | 84 +- gclo/EditableComboBox.cs | 112 +- gclo/LogWindow.xaml.cs | 317 +- gclo/MainWindow.xaml.cs | 2121 +++++---- gclo/PathRecoveryDialog.xaml.cs | 196 +- gclo/PathRecoveryRow.cs | 85 + gclo/Program.cs | 58 +- gclo/Services/UpdateCheckResult.cs | 13 + gclo/Services/UpdateService.cs | 291 +- gclo/SettingsDialog.xaml.cs | 326 +- gclo/SplashOverlay.xaml.cs | 85 +- gclo/StatusFormat.cs | 289 +- gclo/UiTestFixture.cs | 139 +- gclo/WorkspacePage.xaml.cs | 807 ++-- gclo/gclo.csproj | 11 +- gclo/packages.lock.json | 36 +- .../chocolatey/tools/chocolateyinstall.ps1 | 22 +- 165 files changed, 21591 insertions(+), 17591 deletions(-) create mode 100644 Directory.Packages.props create mode 100644 gclo.Cli/AccountSummary.cs create mode 100644 gclo.Cli/CliErrorException.cs delete mode 100644 gclo.Cli/CliJson.cs create mode 100644 gclo.Cli/CliJsonContext.cs create mode 100644 gclo.Cli/CliUsageException.cs delete mode 100644 gclo.Cli/CommandLine.cs create mode 100644 gclo.Cli/DryRunEntry.cs create mode 100644 gclo.Cli/ExitCodes.cs create mode 100644 gclo.Cli/OptionReader.cs create mode 100644 gclo.Cli/OutputMode.cs create mode 100644 gclo.Cli/RepoSummary.cs create mode 100644 gclo.Cli/SyncFailure.cs create mode 100644 gclo.Cli/SyncInvalidPath.cs create mode 100644 gclo.Cli/SyncJsonResult.cs create mode 100644 gclo.Cli/SyncProgressLine.cs create mode 100644 gclo.Cli/SyncSanitized.cs create mode 100644 gclo.Engine/GitHubAccessKind.cs create mode 100644 gclo.Engine/GitHubAccountKind.cs create mode 100644 gclo.Engine/GitHubRepo.cs create mode 100644 gclo.Engine/IProcessRunner.cs create mode 100644 gclo.Engine/IWslCloner.cs create mode 100644 gclo.Engine/NullActivityLog.cs create mode 100644 gclo.Engine/OctokitGateway.cs create mode 100644 gclo.Engine/OctokitGatewayCache.cs create mode 100644 gclo.Engine/ProcessResult.cs create mode 100644 gclo.Engine/ProcessRunner.cs create mode 100644 gclo.Engine/WslAvailability.cs create mode 100644 gclo.Engine/WslCloneException.cs create mode 100644 gclo.Engine/WslCloneResult.cs create mode 100644 gclo.ViewModels/AccountsJsonContext.cs create mode 100644 gclo.ViewModels/AnnouncementEventArgs.cs create mode 100644 gclo.ViewModels/AppSettingsJsonContext.cs create mode 100644 gclo.ViewModels/SyncAllAccountState.cs create mode 100644 gclo.ViewModels/SyncAllResult.cs create mode 100644 gclo/AccessibleTableViewPeer.cs create mode 100644 gclo/Attribution.cs create mode 100644 gclo/ColumnHeaderContent.cs create mode 100644 gclo/PathRecoveryRow.cs create mode 100644 gclo/Services/UpdateCheckResult.cs 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..cb150a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,7 +174,18 @@ 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 + $findings = @(Invoke-ScriptAnalyzer -Path .github/scripts -Recurse -Settings ./PSScriptAnalyzerSettings.psd1) + $findings += @(Invoke-ScriptAnalyzer -Path packaging/chocolatey/tools -Recurse -Settings ./PSScriptAnalyzerSettings.psd1) + 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 -- 2.54.0 From b35e2fe26e22f13b43a7f861683236140baaa165 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 9 Oct 2026 11:10:07 -0500 Subject: [PATCH 2/2] ci: analyze PowerShell scripts one file at a time PSScriptAnalyzer 1.25.0 intermittently fails with "Object reference not set to an instance of an object" when Invoke-ScriptAnalyzer is handed a directory (it passed on the same scripts an hour earlier, and locally the same call crashes on some runs and not others). Handing it one script per call never crashed in repeated local runs, so the format job now loops over the scripts. Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb150a7..e59cc00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,8 +184,13 @@ jobs: shell: pwsh run: | Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force - $findings = @(Invoke-ScriptAnalyzer -Path .github/scripts -Recurse -Settings ./PSScriptAnalyzerSettings.psd1) - $findings += @(Invoke-ScriptAnalyzer -Path packaging/chocolatey/tools -Recurse -Settings ./PSScriptAnalyzerSettings.psd1) + # 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.' -- 2.54.0