From 7fe27bf9c9f21df93ab69b0db64dd448f17e35b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:54:39 -0500 Subject: [PATCH 1/6] Bump coverlet.collector from 6.0.4 to 10.0.1 (#13) --- updated-dependencies: - dependency-name: coverlet.collector dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gclo.Engine.Tests/gclo.Engine.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gclo.Engine.Tests/gclo.Engine.Tests.csproj b/gclo.Engine.Tests/gclo.Engine.Tests.csproj index 211803d..c2046aa 100644 --- a/gclo.Engine.Tests/gclo.Engine.Tests.csproj +++ b/gclo.Engine.Tests/gclo.Engine.Tests.csproj @@ -8,7 +8,7 @@ - + -- 2.54.0 From 6e38c54baee268a536b027f678f41a31decf7632 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:54:54 -0500 Subject: [PATCH 2/6] Bump the nuget-minor-patch group with 1 update (#12) Bumps xunit.runner.visualstudio from 3.1.4 to 3.1.5 --- updated-dependencies: - dependency-name: xunit.runner.visualstudio dependency-version: 3.1.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gclo.Engine.Tests/gclo.Engine.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gclo.Engine.Tests/gclo.Engine.Tests.csproj b/gclo.Engine.Tests/gclo.Engine.Tests.csproj index c2046aa..1e77a0b 100644 --- a/gclo.Engine.Tests/gclo.Engine.Tests.csproj +++ b/gclo.Engine.Tests/gclo.Engine.Tests.csproj @@ -11,7 +11,7 @@ - + -- 2.54.0 From 4ff9be9506aec9ebdf83c8aadfadc13708f91802 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 10:55:45 -0500 Subject: [PATCH 3/6] Dependabot targets dev, matching the branch model Dependency PRs land on the integration branch and reach main through the same gated dev->main flow as all other changes. Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ec903be..1e51612 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,8 @@ version: 2 updates: - package-ecosystem: nuget directory: "/" + # Updates land on dev like all other work and reach main via the gated PR flow. + target-branch: dev schedule: interval: weekly day: monday @@ -21,6 +23,7 @@ updates: - package-ecosystem: github-actions directory: "/" + target-branch: dev schedule: interval: weekly day: monday -- 2.54.0 From e8821c25697caff758a108ebf7b44a9185980b06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:01:05 +0000 Subject: [PATCH 4/6] Bump Microsoft.NET.Test.Sdk from 17.14.1 to 18.7.0 (#14) --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.7.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gclo.Engine.Tests/gclo.Engine.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gclo.Engine.Tests/gclo.Engine.Tests.csproj b/gclo.Engine.Tests/gclo.Engine.Tests.csproj index 1e77a0b..bb452e5 100644 --- a/gclo.Engine.Tests/gclo.Engine.Tests.csproj +++ b/gclo.Engine.Tests/gclo.Engine.Tests.csproj @@ -9,7 +9,7 @@ - + -- 2.54.0 From 75764bda4ec8438ea70fd36fa7a34a5f94631e6a Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 11:16:39 -0500 Subject: [PATCH 5/6] Local quality-gate enforcement: analyzers, style-in-build, CRLF (#5) - Directory.Build.props: TreatWarningsAsErrors, AnalysisLevel latest-recommended, EnforceCodeStyleInBuild, Deterministic - .editorconfig codifying the existing style; every suppression has an inline justification (incl. CA1822 for x:Bind codegen, proven needed) - .gitattributes pins CRLF working trees (the format gate enforces end_of_line, so checkout line endings must be deterministic) - Analyzer fixes: MainViewModel is IDisposable (owns its CTS, window disposes on close), explicit CancellationToken.None in test gates, discarded App construction result All gates pass with enforcement active: 0-warning build, 111/111 tests, clean format verify. Co-Authored-By: Claude Fable 5 --- .editorconfig | 61 +++++++++++++++++++++++++ .gitattributes | 4 ++ Directory.Build.props | 13 ++++++ gclo.Engine.Tests/MainViewModelTests.cs | 6 ++- gclo.Engine.Tests/OrgSyncEngineTests.cs | 5 +- gclo.ViewModels/MainViewModel.cs | 10 +++- gclo/App.xaml.cs | 12 ++--- gclo/LogViewerDialog.xaml.cs | 2 +- gclo/MainWindow.xaml.cs | 5 +- gclo/Program.cs | 2 +- 10 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 Directory.Build.props diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..42cd04b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,61 @@ +# 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). +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# MSBuild, config, and workflow files use 2-space indentation. +[*.{csproj,props,targets,slnx,xml,json,yml,yaml}] +indent_size = 2 + +[*.cs] +# System usings first, as the codebase already does (used by IDE "Sort Usings"). +dotnet_sort_system_directives_first = true + +# Braces on every control-flow block; the codebase is already 100% compliant. +csharp_prefer_braces = true: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 + +# 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 + +# 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 + +# Naming: interfaces are I-prefixed; types and members are PascalCase. +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 +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.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_style.pascal_case.capitalization = pascal_case + +[gclo/**.cs] +# 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 + +[gclo.Engine.Tests/**.cs] +# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention. +dotnet_diagnostic.CA1707.severity = none +# CA1861: inline array arguments keep test data at the assertion site; each test runs once, +# so the "repeated allocation" concern does not apply. +dotnet_diagnostic.CA1861.severity = none diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b8c94f8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# 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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..f03de8c --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + true + latest-recommended + true + true + + + diff --git a/gclo.Engine.Tests/MainViewModelTests.cs b/gclo.Engine.Tests/MainViewModelTests.cs index 6f2142c..61026b3 100644 --- a/gclo.Engine.Tests/MainViewModelTests.cs +++ b/gclo.Engine.Tests/MainViewModelTests.cs @@ -198,7 +198,8 @@ public sealed class MainViewModelTests _git.CloneHandler = async (url, path, token, onProgress, ct) => { Interlocked.Increment(ref started); - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30)); + // 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(); }; @@ -418,7 +419,8 @@ public sealed class MainViewModelTests _git.CloneHandler = async (url, path, token, onProgress, ct) => { Interlocked.Increment(ref started); - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30)); + // 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); diff --git a/gclo.Engine.Tests/OrgSyncEngineTests.cs b/gclo.Engine.Tests/OrgSyncEngineTests.cs index 2d29382..a633722 100644 --- a/gclo.Engine.Tests/OrgSyncEngineTests.cs +++ b/gclo.Engine.Tests/OrgSyncEngineTests.cs @@ -105,8 +105,9 @@ public sealed class OrgSyncEngineTests : IDisposable try { // Pile up here until the test releases the gate. The timeout is a safety - // valve so a regression cannot hang the test run. - await gate.Task.WaitAsync(TimeSpan.FromSeconds(30)); + // 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 { diff --git a/gclo.ViewModels/MainViewModel.cs b/gclo.ViewModels/MainViewModel.cs index 70ea639..4676c28 100644 --- a/gclo.ViewModels/MainViewModel.cs +++ b/gclo.ViewModels/MainViewModel.cs @@ -17,7 +17,7 @@ namespace gclo.ViewModels; /// table, then clones or updates the selected subset with live /// per-repo progress. re-runs just the failures. /// -public sealed partial class MainViewModel : ObservableObject +public sealed partial class MainViewModel : ObservableObject, IDisposable { private readonly IRepositoryLister _lister; private readonly IGitClient _git; @@ -165,6 +165,14 @@ public sealed partial class MainViewModel : ObservableObject // continuations below stay on the UI thread and may touch Organizations directly. partial void OnTokenChanged(string value) => _ = RefreshOrganizationsAsync(); + /// Stops and releases the in-flight org lookup, if any. + public void Dispose() + { + _orgLoadCts?.Cancel(); + _orgLoadCts?.Dispose(); + _orgLoadCts = null; + } + /// Debounced: each token edit cancels the previous lookup. private async Task RefreshOrganizationsAsync() { diff --git a/gclo/App.xaml.cs b/gclo/App.xaml.cs index b4a1509..856e1e9 100644 --- a/gclo/App.xaml.cs +++ b/gclo/App.xaml.cs @@ -1,4 +1,9 @@ -using Microsoft.UI.Xaml; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Data; @@ -6,11 +11,6 @@ using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; using Microsoft.UI.Xaml.Shapes; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; using Windows.ApplicationModel; using Windows.ApplicationModel.Activation; using Windows.Foundation; diff --git a/gclo/LogViewerDialog.xaml.cs b/gclo/LogViewerDialog.xaml.cs index 728436c..270685b 100644 --- a/gclo/LogViewerDialog.xaml.cs +++ b/gclo/LogViewerDialog.xaml.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.IO; -using Microsoft.UI.Xaml.Controls; using gclo.Engine; +using Microsoft.UI.Xaml.Controls; namespace gclo { diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 683fad6..6c41e0c 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -1,11 +1,11 @@ using System; using System.IO; using System.Threading.Tasks; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; using gclo.Engine; using gclo.Services; using gclo.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; namespace gclo { @@ -55,6 +55,7 @@ namespace gclo { ViewModel.SyncCancelCommand.Execute(null); } + ViewModel.Dispose(); }; } diff --git a/gclo/Program.cs b/gclo/Program.cs index 6c47355..8378b46 100644 --- a/gclo/Program.cs +++ b/gclo/Program.cs @@ -24,7 +24,7 @@ namespace gclo { var context = new global::Microsoft.UI.Dispatching.DispatcherQueueSynchronizationContext(global::Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); global::System.Threading.SynchronizationContext.SetSynchronizationContext(context); - new App(); + _ = new App(); }); } } -- 2.54.0 From 54fa3624a2b461c047db664665533fc1d70ba164 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 3 Jul 2026 11:36:54 -0500 Subject: [PATCH 6/6] Apply 28 adversarially-verified audit findings (#5) Bugs the audit caught in the recovery feature: - Rename composition is now prefix-aware: renaming a directory AND its descendant composes instead of producing contradictory paths - ApplyRecoveryCore merges the persisted recovery with the incoming one, so recovery-managed repos gaining new invalid paths are fixable incrementally (GUI dialog and --sanitize-paths) - Org-lookup loading spinner no longer sticks when superseded by a short/cleared token Simplification and correctness: - Path validation + core.longpaths gated on Windows; other platforms use plain checkouts (keeps the cross-platform CLI story true) - Removed speculative/dead surface: ApplyRecoveryAsync token param, UpdateCheckResult.IsDownloaded, unused DownloadAndApplyAsync params - One validation implementation (Validate(Tree) delegates to ValidatePaths); one run-summary string; shared concurrency constants - Test fixtures deduplicated into GitTestHelpers.cs (~100 lines of triplication removed); 3 new regression tests (114 total) - gclo.Engine NuGet-ready: XML docs generated and completed across the public surface, package metadata, PACKAGE.md readme - Degraded org listing for classic PATs without read:org (personal login only) instead of a hard failure; --token-file reads first non-blank line - Docs corrected to shipped reality: README GUI walkthrough, recovery paragraphs, CLI examples/log section, release runbook recovery steps, CONTRIBUTING architecture Closes #5 Co-Authored-By: Claude Fable 5 --- CONTRIBUTING.md | 2 +- README.md | 19 +- docs/CLI.md | 27 ++- docs/RELEASING.md | 48 +++++- gclo.Cli/OrgsCommand.cs | 2 +- gclo.Cli/Program.cs | 2 +- gclo.Cli/SyncCommand.cs | 7 +- gclo.Cli/TokenOptions.cs | 6 +- gclo.Engine.Tests/Fakes.cs | 14 +- gclo.Engine.Tests/GitTestHelpers.cs | 119 +++++++++++++ gclo.Engine.Tests/LibGit2GitClientTests.cs | 56 +----- gclo.Engine.Tests/MainViewModelTests.cs | 52 +++--- gclo.Engine.Tests/OrgSyncEngineTests.cs | 34 +--- gclo.Engine.Tests/PathRecoveryTests.cs | 162 ++++++++---------- gclo.Engine.Tests/SyncOverloadTests.cs | 21 +-- .../WindowsPathValidationTests.cs | 65 +------ gclo.Engine/FileActivityLog.cs | 8 + gclo.Engine/GitHubOrganizationLister.cs | 18 +- gclo.Engine/GitHubRepositoryLister.cs | 1 + gclo.Engine/IGitClient.cs | 15 +- .../InvalidRepositoryPathsException.cs | 2 + gclo.Engine/LibGit2GitClient.cs | 138 ++++++++++----- gclo.Engine/OrgSyncEngine.cs | 4 + gclo.Engine/PACKAGE.md | 10 ++ gclo.Engine/PathRecovery.cs | 8 +- gclo.Engine/SyncStatus.cs | 11 ++ gclo.Engine/SyncSummary.cs | 6 + gclo.Engine/WindowsPathValidator.cs | 55 +++--- gclo.Engine/gclo.Engine.csproj | 12 +- gclo.ViewModels/AppSettings.cs | 4 +- gclo.ViewModels/MainViewModel.cs | 42 +++-- gclo.ViewModels/RepoItemViewModel.cs | 2 - gclo/App.xaml.cs | 23 +-- gclo/Services/UpdateService.cs | 32 ++-- gclo/SettingsDialog.xaml.cs | 2 +- 35 files changed, 575 insertions(+), 454 deletions(-) create mode 100644 gclo.Engine.Tests/GitTestHelpers.cs create mode 100644 gclo.Engine/PACKAGE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ddccc3..bbdb1a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ Running the build, test, and format commands above locally before pushing will c ## Architecture rule: no business logic in the UI project -The `gclo` WinUI project contains views, view models, and app-level services (settings, self-update) — **nothing else**. All sync/business logic lives in `gclo.Engine`, a plain class library behind the `IRepositoryLister` / `IGitClient` / `IOrganizationLister` interfaces, so it stays testable without the app and is shared by the CLI (`gclo.Cli`). If you find yourself writing GitHub or git logic in the `gclo` project, it belongs in the engine. +The `gclo` WinUI project contains XAML views, dialogs, and self-update plumbing — **nothing else**. View models and settings persistence live in `gclo.ViewModels`, a UI-framework-free library the test suite exercises headlessly. All sync/GitHub/git logic lives in `gclo.Engine`, a plain class library behind the `IRepositoryLister` / `IGitClient` / `IOrganizationLister` interfaces, shared by the scriptable CLI head (`gclo.Cli`). If you find yourself writing logic in the `gclo` project, it belongs in a library — GitHub or git logic in the engine, presentation state in the view models. ## Code style diff --git a/README.md b/README.md index 1b70506..d9b1b5c 100644 --- a/README.md +++ b/README.md @@ -36,28 +36,33 @@ The token is used in memory for the duration of a sync and is never stored on di ## Using the GUI - + 1. **Paste your token first.** After a moment, the **Organization or account** dropdown fills with everything the token can sync — your personal account is listed first, then your organizations alphabetically. If the token cannot list organizations (a fine-grained PAT, or a classic PAT without `read:org`), the status line says so; the dropdown is editable, so just type the name. 2. **Pick the organization or account** from the dropdown. -3. **Choose a target folder** — type a path or click **Browse...**. Each repository syncs into `\`. +3. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `` subfolder** to keep several organizations under one root; the live preview line underneath always shows exactly where repositories will land (e.g. `C:\src\acme\my-repo`). 4. **Set parallelism** if you like (1–64, default 8): how many git operations run at once. -5. **Press Sync.** Every repository appears in the list and moves through its states live: Queued, Cloning (with transfer percentage), Pulling, Done, Failed, or Canceled. The overall progress bar, completed/total counter, and an end-of-run summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`) track the whole run. +5. **Press Load repos.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it; click it again to flip the direction. +6. **Uncheck anything you don't want.** The header checkbox selects or clears every row at once. +7. **Press Sync selected.** Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`). - + -6. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. +8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, **Retry failed** re-runs exactly the failed rows, and **Open folder** opens the target folder in Explorer. -**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest. A failed repo shows its error message inline in the list, and failed clones are cleaned up so a partial checkout is never mistaken for a valid repo on the next run. gclo also checks every path in a repository against Windows file-system rules *before* checkout: paths that are legal in git but impossible on Windows (invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case) mark the repo Failed with a per-path list of reasons instead of a cryptic checkout error — and nothing half-written is left on disk. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). +**When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Most failed clones are cleaned up, so a partial checkout is never mistaken for a valid repo on the next run. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). **Settings** (File > Settings…) let you set the default target folder, the default parallelism, and the theme (System, Light, or Dark). +**Activity log:** View > Activity log… shows the tail of today's log and links to the logs folder. Logs live under `%LOCALAPPDATA%\gclo\logs`, one file per day; they record run parameters and per-repo failures and never contain your token (see [SECURITY.md](SECURITY.md)). + **Updates:** Help > Check for updates… downloads and applies the latest release for your channel. Self-update is only available in installed builds (not the portable zip or a local debug build). ## Using the CLI diff --git a/docs/CLI.md b/docs/CLI.md index 1d2169b..666db8f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -74,14 +74,20 @@ names (`aux`, `con`, `nul`, `com1`, ...), characters like `:`, `?`, or `*`, names ending in a dot or a space, and pairs of paths that differ only by case. gclo validates every incoming tree *before* it touches the working tree, so such a repository fails cleanly — nothing is half-checked-out — and the offending -paths are listed on stderr (at most 10, plus a count of the rest): +paths are listed on stderr (at most 10, plus a count of the rest). Each line +reports the path of the offending *segment* itself — a bad directory name like +`aux` is reported once as `aux`, not repeated for every file inside it: ``` legacy-repo Failed 2 path(s) in this repository cannot be created on Windows: ... -legacy-repo aux/driver.c ('aux' is a reserved Windows device name) +legacy-repo aux ('aux' is a reserved Windows device name) legacy-repo docs/spec?.md (contains a character that is invalid on Windows) ``` +This validation — like the automatic `core.longpaths` handling — applies on +Windows only: on Linux and macOS these paths are perfectly legal, so gclo +performs a plain checkout and `--sanitize-paths` has nothing to do. + With `--sanitize-paths`, gclo checks such a repository out anyway: - Each offending path is renamed on disk to a safe suggested name @@ -94,8 +100,10 @@ With `--sanitize-paths`, gclo checks such a repository out anyway: `Finished: 3 cloned (1 with sanitized paths), 41 updated, 0 failed, 0 canceled of 45.` - The mapping is remembered inside the repository (`.git\gclo-recovery.json`) and reapplied by later syncs. A later sync that - brings *new* invalid paths not covered by the stored mapping fails again with - those paths listed. + brings *new* invalid paths not covered by the stored mapping fails with those + paths listed — sync again with `--sanitize-paths` (or use the GUI's + **Resolve…** dialog) and the new renames and skips are merged into the stored + mapping. Only the working tree is renamed — the repository's history and index still hold the original paths, so `git status` reports the renamed and skipped files as @@ -135,12 +143,21 @@ gclo needs a GitHub Personal Access Token for both the API and the git transport | --- | --- | | *(none)* | Reads the `GITHUB_TOKEN` environment variable. | | `--token-env VAR` | Reads environment variable `VAR`. | -| `--token-file PATH` | Reads the first non-blank content line of `PATH` (trimmed). | +| `--token-file PATH` | Reads the first non-blank line of `PATH`, trimmed. | | `--token-stdin` | Reads one line from standard input — made for piping from a secret store. | The options are mutually exclusive. A missing or empty token prints an error to stderr and exits with code 2. +## Activity log + +Every invocation appends to a daily activity log file, `gclo-yyyy-MM-dd.log`, +under `%LOCALAPPDATA%\gclo\logs` (on Linux/macOS: the platform's local +application data folder). It records the run's parameters, per-repository +failures, and path-sanitization notes — the same log the desktop app shows +under **View > Activity log**. Tokens are never written to the log, or anywhere +else on disk; see [SECURITY.md](../SECURITY.md). + ## Exit codes | Code | Meaning | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8903def..33d1662 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -99,13 +99,15 @@ via `wingetcreate update KofTwentyTwo.gclo ... --submit`. ``` 4. **Watch the workflow.** The `Release` workflow appears under the Actions - tab. It will, in order: + tab. It is a single job that will, in order: - validate the tag and derive version/channel, - build the solution (x64, warnings as errors) and run the tests, - publish and zip the CLI, - publish the WinUI app unpackaged and pack it with Velopack, - - create the GitHub Release (marked prerelease for `dev`) and upload all - assets, + - create a **draft** GitHub Release (marked prerelease for `dev`), upload + all assets and the generated changelog to it, then **publish** it — this + repository uses immutable releases, so everything must land while the + release is still a draft, - push `gclo.Engine` to nuget.org (if `NUGET_API_KEY` is set), - submit the winget manifest update (stable only, if `WINGET_TOKEN` is set). @@ -115,17 +117,47 @@ via `wingetcreate update KofTwentyTwo.gclo ... --submit`. first release) should all be attached. If the winget step ran, check your PR on microsoft/winget-pkgs. -6. **If something failed** after the release was created (for example the - winget submission), fix the cause and re-run only the failed job from the - Actions UI. The NuGet push uses `--skip-duplicate`, and asset uploads use - `--clobber`, so re-runs are safe. +6. **If something failed**, where it failed decides the recovery. The workflow + is a single job, so "re-run failed jobs" always re-runs everything from the + start — and because releases in this repository are **immutable**, a + published release's tag and assets are frozen and cannot be re-uploaded. + + - **Failed before "Publish the completed release"** (build, tests, + packaging, asset upload, changelog): nothing is public yet. Delete the + draft release if one was created — drafts can always be deleted: + + ```powershell + gh release delete v1.2.3 --yes + ``` + + Then fix the cause and re-run the job from the Actions UI; the tag is + unchanged and everything is rebuilt from it. + + - **Failed after the release was published** (the NuGet push or the winget + submission): the release itself is fine but frozen — do **not** re-run + the job, which would try to recreate it. Run the missing step manually + instead: + + ```powershell + # NuGet push (needs an API key with push rights for gclo.Engine) + dotnet pack gclo.Engine -c Release -p:Version=1.2.3 -o artifacts/nuget + dotnet nuget push "artifacts/nuget/*.nupkg" --api-key --source https://api.nuget.org/v3/index.json --skip-duplicate + + # winget manifest update (stable releases only) + Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + .\wingetcreate.exe update KofTwentyTwo.gclo --urls https://github.com/KofTwentyTwo/gclo/releases/download/v1.2.3/gclo-stable-Setup.exe --version 1.2.3 --submit --token + ``` + + If the published release itself is broken, do not try to fix it in + place — delete it and its tag (below) and cut a new patch version. ### Deleting a bad release If a release is broken, delete the GitHub Release *and* the tag, fix the problem, and tag again with a **new** patch version. Never reuse a version number that was published — installed apps and package managers may have -already seen it. +already seen it. With immutable releases this is the only fix for a bad +published release: its assets cannot be swapped in place. ```powershell gh release delete v1.2.3 --yes diff --git a/gclo.Cli/OrgsCommand.cs b/gclo.Cli/OrgsCommand.cs index 1aee487..da1d6fb 100644 --- a/gclo.Cli/OrgsCommand.cs +++ b/gclo.Cli/OrgsCommand.cs @@ -17,7 +17,7 @@ internal static class OrgsCommand Options: --token-env Read the token from environment variable VAR (default: GITHUB_TOKEN when no token option is given). - --token-file Read the token from the first line of a file. + --token-file Read the token from the first non-blank line of a file. --token-stdin Read the token as one line from standard input. --json Print a single-line JSON array instead. --help Show this help. diff --git a/gclo.Cli/Program.cs b/gclo.Cli/Program.cs index e078330..fe55bc0 100644 --- a/gclo.Cli/Program.cs +++ b/gclo.Cli/Program.cs @@ -26,7 +26,7 @@ internal static class Program are visible to every other process on the machine (Task Manager, 'ps', WMI queries), so a token passed that way would leak. Provide it with: --token-env read it from environment variable VAR - --token-file read the first line of a file + --token-file read the first non-blank line of a file --token-stdin read one line from standard input (pipe it from a secret store) When no token option is given, the GITHUB_TOKEN environment variable is used. diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index 7baa787..7180545 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -28,7 +28,7 @@ internal static class SyncCommand repository counts as cloned. --token-env Read the token from environment variable VAR (default: GITHUB_TOKEN when no token option is given). - --token-file Read the token from the first line of a file. + --token-file Read the token from the first non-blank line of a file. --token-stdin Read the token as one line from standard input. --json Print no progress lines; end with one line of JSON: {"total":N,"cloned":N,"updated":N,"failed":N,"canceled":N, @@ -169,7 +169,7 @@ internal static class SyncCommand int sanitized = 0; foreach (PendingRecovery pending in progress.DrainPendingRecoveries()) { - if (await TrySanitizeAsync(git, progress, log, target.Trim(), token, pending, cancellationToken) + if (await TrySanitizeAsync(git, progress, log, target.Trim(), pending, cancellationToken) .ConfigureAwait(false)) { sanitized++; @@ -225,7 +225,6 @@ internal static class SyncCommand SyncProgressHandler progress, IActivityLog log, string targetRoot, - string token, PendingRecovery pending, CancellationToken cancellationToken) { @@ -234,7 +233,7 @@ internal static class SyncCommand { cancellationToken.ThrowIfCancellationRequested(); PathRecovery recovery = BuildRecovery(pending.Paths); - await git.ApplyRecoveryAsync(path, token, recovery, cancellationToken).ConfigureAwait(false); + await git.ApplyRecoveryAsync(path, recovery, cancellationToken).ConfigureAwait(false); string detail = DescribeRecovery(recovery); Console.Error.WriteLine( diff --git a/gclo.Cli/TokenOptions.cs b/gclo.Cli/TokenOptions.cs index 6ff578b..afc394a 100644 --- a/gclo.Cli/TokenOptions.cs +++ b/gclo.Cli/TokenOptions.cs @@ -79,9 +79,9 @@ internal sealed class TokenOptions string? line; try { - // First line only: token files often end with a newline, and some - // editors append trailing blank lines. - line = File.ReadLines(path).FirstOrDefault(); + // 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) { diff --git a/gclo.Engine.Tests/Fakes.cs b/gclo.Engine.Tests/Fakes.cs index 626e757..3952779 100644 --- a/gclo.Engine.Tests/Fakes.cs +++ b/gclo.Engine.Tests/Fakes.cs @@ -29,7 +29,7 @@ public sealed class FakeRepositoryLister : IRepositoryLister public sealed record CloneCall(string Url, string LocalPath, string Token); public sealed record PullCall(string LocalPath, string Token); -public sealed record ApplyRecoveryCall(string LocalPath, string Token, PathRecovery Recovery); +public sealed record ApplyRecoveryCall(string LocalPath, PathRecovery Recovery); /// /// An whose behavior is configured with delegates. @@ -53,9 +53,9 @@ public sealed class FakeGitClient : IGitClient public Func FetchAndPullHandler { get; set; } = (_, _, _) => Task.CompletedTask; - /// Body of (path, token, recovery, ct). Default: completes immediately. - public Func ApplyRecoveryHandler { get; set; } - = (_, _, _, _) => Task.CompletedTask; + /// Body of (path, recovery, ct). Default: completes immediately. + public Func ApplyRecoveryHandler { get; set; } + = (_, _, _) => Task.CompletedTask; public IReadOnlyList CloneCalls => _cloneCalls.ToArray(); public IReadOnlyList PullCalls => _pullCalls.ToArray(); @@ -88,10 +88,10 @@ public sealed class FakeGitClient : IGitClient await FetchAndPullHandler(path, token, cancellationToken).ConfigureAwait(false); } - public async Task ApplyRecoveryAsync(string path, string token, PathRecovery recovery, CancellationToken cancellationToken) + public async Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) { - _applyRecoveryCalls.Enqueue(new ApplyRecoveryCall(path, token, recovery)); - await ApplyRecoveryHandler(path, token, recovery, cancellationToken).ConfigureAwait(false); + _applyRecoveryCalls.Enqueue(new ApplyRecoveryCall(path, recovery)); + await ApplyRecoveryHandler(path, recovery, cancellationToken).ConfigureAwait(false); } } diff --git a/gclo.Engine.Tests/GitTestHelpers.cs b/gclo.Engine.Tests/GitTestHelpers.cs new file mode 100644 index 0000000..dce2fc3 --- /dev/null +++ b/gclo.Engine.Tests/GitTestHelpers.cs @@ -0,0 +1,119 @@ +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 +/// database because Windows-invalid names can never be created on a working tree +/// directly. +/// +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); + + /// 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); + } + } + + /// 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; + } + + /// 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; + } + + 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. + } + } +} diff --git a/gclo.Engine.Tests/LibGit2GitClientTests.cs b/gclo.Engine.Tests/LibGit2GitClientTests.cs index ed0781e..eeb488f 100644 --- a/gclo.Engine.Tests/LibGit2GitClientTests.cs +++ b/gclo.Engine.Tests/LibGit2GitClientTests.cs @@ -1,5 +1,6 @@ using gclo.Engine; using LibGit2Sharp; +using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; @@ -65,7 +66,7 @@ public sealed class LibGit2GitClientTests : IDisposable // 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 = CreateRepoWithWindowsInvalidFileName(); + string source = CreateForgedRepo(_root, ("bad:name.txt", "unwritable on Windows")); string target = NewPath("partial-clone"); var ex = await Assert.ThrowsAsync( @@ -290,58 +291,7 @@ public sealed class LibGit2GitClientTests : IDisposable using var repo = new Repository(workdir); File.WriteAllText(Path.Combine(workdir, fileName), content); Commands.Stage(repo, fileName); - var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + var signature = MakeSignature(); return repo.Commit(message, signature, signature).Sha; } - - /// - /// Builds a repository whose single commit contains a file name that Windows rejects. - /// The tree is written through the object database, so nothing ever touches the disk - /// under that name until a clone tries to check it out. - /// - private string CreateRepoWithWindowsInvalidFileName() - { - string path = NewPath("invalid-name-source"); - Repository.Init(path); - using var repo = new Repository(path); - - var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("unwritable on Windows"u8.ToArray())); - var treeDefinition = new TreeDefinition().Add("bad:name.txt", blob, Mode.NonExecutableFile); - var tree = repo.ObjectDatabase.CreateTree(treeDefinition); - var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); - var commit = repo.ObjectDatabase.CreateCommit( - signature, signature, "commit with a Windows-invalid file name", tree, [], prettifyMessage: false); - repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id); - return path; - } - - private static string HeadSha(string workdir) - { - using var repo = new Repository(workdir); - return repo.Head.Tip.Sha; - } - - /// - /// Same approach as the class under test: pack files under .git are written read-only - /// on Windows, so clear attributes before deleting. - /// - private static void TryDeleteDirectory(string path) - { - try - { - if (!Directory.Exists(path)) - { - return; - } - foreach (var 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/MainViewModelTests.cs b/gclo.Engine.Tests/MainViewModelTests.cs index 61026b3..ec5312a 100644 --- a/gclo.Engine.Tests/MainViewModelTests.cs +++ b/gclo.Engine.Tests/MainViewModelTests.cs @@ -1,6 +1,6 @@ -using System.Diagnostics; using gclo.Engine; using gclo.ViewModels; +using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; @@ -27,9 +27,6 @@ public sealed class MainViewModelTests debounce ?? TimeSpan.FromMilliseconds(1), new NullActivityLog()); - private static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false) - => new(name, $"https://example.test/acme/{name}.git", branch, archived); - /// /// 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 @@ -49,17 +46,6 @@ public sealed class MainViewModelTests return vm; } - private 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); - } - } - // ---------------------------------------------------------------- command gating [Fact] @@ -322,7 +308,6 @@ public sealed class MainViewModelTests Assert.Equal([row], asked); ApplyRecoveryCall call = Assert.Single(_git.ApplyRecoveryCalls); Assert.Equal(Path.Combine(vm.EffectiveTargetRoot, "alpha"), call.LocalPath); - Assert.Equal("token-1234567890", call.Token); Assert.Same(recovery, call.Recovery); Assert.Equal(SyncStatus.Done, row.Status); @@ -362,7 +347,7 @@ public sealed class MainViewModelTests { new("aux_", "differs only by case from 'AUX_'", null), }; - _git.ApplyRecoveryHandler = (_, _, _, _) => + _git.ApplyRecoveryHandler = (_, _, _) => Task.FromException(new InvalidRepositoryPathsException(stillInvalid)); var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); @@ -385,7 +370,7 @@ public sealed class MainViewModelTests public async Task ResolvePaths_ApplyFailsForAnotherReason_ClearsPayload() { MakeAlphaFailWithInvalidPaths(); - _git.ApplyRecoveryHandler = (_, _, _, _) => + _git.ApplyRecoveryHandler = (_, _, _) => Task.FromException(new IOException("disk full")); var vm = await CreateLoadedViewModelAsync(Repo("alpha")); await vm.SyncCommand.ExecuteAsync(null); @@ -568,15 +553,42 @@ public sealed class MainViewModelTests } [Fact] - public async Task TokenChange_EmptyOrgList_ExplainsAndAllowsManualEntry() + 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.Contains("read:org", vm.StatusText); + 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] diff --git a/gclo.Engine.Tests/OrgSyncEngineTests.cs b/gclo.Engine.Tests/OrgSyncEngineTests.cs index a633722..35baf16 100644 --- a/gclo.Engine.Tests/OrgSyncEngineTests.cs +++ b/gclo.Engine.Tests/OrgSyncEngineTests.cs @@ -1,5 +1,5 @@ -using System.Diagnostics; using gclo.Engine; +using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; @@ -15,43 +15,13 @@ public sealed class OrgSyncEngineTests : IDisposable private readonly FakeGitClient _git = new(); private readonly RecordingProgress _progress = new(); - public void Dispose() - { - try - { - if (Directory.Exists(_targetRoot)) - { - Directory.Delete(_targetRoot, recursive: true); - } - } - catch - { - // Best effort; stray empty temp dirs are harmless. - } - } + public void Dispose() => TryDeleteDirectory(_targetRoot); private OrgSyncEngine CreateEngine() => new(_lister, _git); private SyncRequest CreateRequest(int maxConcurrency = 8) => new("acme", "test-token", _targetRoot, maxConcurrency); - private static RepoDescriptor Repo(string name) - => new(name, $"https://example.test/acme/{name}.git", "main", IsArchived: false); - - private static RepoDescriptor[] Repos(params string[] names) => names.Select(Repo).ToArray(); - - /// Polls until is true; fails the test after a generous timeout. - private 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); - } - } - // ---------------------------------------------------------------- (a) [Fact] diff --git a/gclo.Engine.Tests/PathRecoveryTests.cs b/gclo.Engine.Tests/PathRecoveryTests.cs index dd40a48..fb725db 100644 --- a/gclo.Engine.Tests/PathRecoveryTests.cs +++ b/gclo.Engine.Tests/PathRecoveryTests.cs @@ -1,6 +1,7 @@ using System.Text; using gclo.Engine; using LibGit2Sharp; +using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; @@ -34,7 +35,7 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task Clone_InvalidPaths_KeepsFetchedRepo_SetsPendingMarker_AndThrowsTyped() { - string source = CreateForgedRepo(("good.txt", "good content"), ("bad:file.txt", "bad content")); + string source = CreateForgedRepo(_root, ("good.txt", "good content"), ("bad:file.txt", "bad content")); string target = NewPath("marker-clone"); var ex = await Assert.ThrowsAsync( @@ -53,11 +54,11 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task ApplyRecovery_RenamedFile_MaterializesContentUnderNewName() { - string source = CreateForgedRepo(("good.txt", "good content"), ("bad:file.txt", "bad content")); + 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, Token, recovery, CancellationToken.None); + 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"))); @@ -67,21 +68,49 @@ public sealed class PathRecoveryTests : IDisposable 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"); var recovery = Recovery(renames: [("bad:dir", "bad_dir")]); - await _client.ApplyRecoveryAsync(target, Token, recovery, CancellationToken.None); + 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"); + + var 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"), @@ -91,7 +120,7 @@ public sealed class PathRecoveryTests : IDisposable // Skip the invalid file outright and a whole (valid) directory subtree. var recovery = Recovery(skips: ["bad:one.txt", "junk"]); - await _client.ApplyRecoveryAsync(target, Token, recovery, CancellationToken.None); + 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"))); @@ -102,12 +131,12 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task ApplyRecovery_MappingStillInvalid_ThrowsListingEffectivePaths_AndWritesNothing() { - string source = CreateForgedRepo(("good.txt", "good content"), ("bad:file.txt", "bad content")); + 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, Token, recovery, CancellationToken.None)); + () => _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"); @@ -121,12 +150,12 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task ApplyRecovery_TwoOriginalsMappedToOneDestination_Throws() { - string source = CreateForgedRepo(("bad:a.txt", "content a"), ("bad:b.txt", "content b")); + 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, Token, recovery, CancellationToken.None)); + () => _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None)); Assert.Contains(ex.Paths, p => p.RepoPath == "merged.txt"); Assert.False(File.Exists(Path.Combine(target, "merged.txt"))); @@ -135,11 +164,11 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task ApplyRecovery_PersistsRecoveryJsonUnderDotGit_AndClearsMarker() { - string source = CreateForgedRepo(("bad:file.txt", "bad content")); + 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, Token, recovery, CancellationToken.None); + await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None); Assert.False(IsCheckoutPending(target)); string json = File.ReadAllText(RecoveryFilePath(target)); @@ -147,12 +176,39 @@ public sealed class PathRecoveryTests : IDisposable 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); + } + // ---------------------------------------------------------------- pull semantics [Fact] public async Task FetchAndPull_MarkerRepoWithoutRecovery_RethrowsTypedException() { - string source = CreateForgedRepo(("bad:file.txt", "bad content")); + 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. @@ -166,7 +222,7 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task FetchAndPull_MarkerRepoUpstreamFixedPaths_ChecksOutAndClearsMarker() { - string source = CreateForgedRepo(("good.txt", "good content"), ("bad:file.txt", "bad content")); + 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")); @@ -182,10 +238,10 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task FetchAndPull_WithStoredRecovery_RematerializesNewCommitsThroughMapping() { - string source = CreateForgedRepo(("bad:file.txt", "v1"), ("good.txt", "g1")); + string source = CreateForgedRepo(_root, ("bad:file.txt", "v1"), ("good.txt", "g1")); string target = await CloneExpectingInvalidPathsAsync(source, "recovery-pull"); await _client.ApplyRecoveryAsync( - target, Token, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None); + 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 @@ -202,10 +258,10 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task FetchAndPull_WithStoredRecovery_NewInvalidPathNotCovered_Throws() { - string source = CreateForgedRepo(("bad:file.txt", "v1")); + string source = CreateForgedRepo(_root, ("bad:file.txt", "v1")); string target = await CloneExpectingInvalidPathsAsync(source, "uncovered-pull"); await _client.ApplyRecoveryAsync( - target, Token, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None); + target, Recovery(renames: [("bad:file.txt", "bad_file.txt")]), CancellationToken.None); AppendForgedCommit(source, ("worse:new.txt", "not covered")); @@ -220,7 +276,7 @@ public sealed class PathRecoveryTests : IDisposable [Fact] public async Task CloneAndPull_ValidRepo_NeverWritesMarkerOrRecovery() { - string source = CreateForgedRepo(("readme.txt", "hello")); + string source = CreateForgedRepo(_root, ("readme.txt", "hello")); string target = NewPath("normal"); await _client.CloneAsync(source, target, Token, null, CancellationToken.None); @@ -304,47 +360,6 @@ public sealed class PathRecoveryTests : IDisposable (renames ?? []).ToDictionary(r => r.From, r => r.To, StringComparer.Ordinal), new HashSet(skips ?? [], StringComparer.Ordinal)); - /// Builds a repo whose single commit holds the given forged entries with per-entry content. - private string CreateForgedRepo(params (string Name, string Content)[] entries) - { - string path = NewPath("forged-" + Guid.NewGuid().ToString("N")[..8]); - Repository.Init(path); - using var repo = new Repository(path); - - var definition = new TreeDefinition(); - foreach (var (name, content) in entries) - { - var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content))); - definition.Add(name, blob, Mode.NonExecutableFile); - } - var tree = repo.ObjectDatabase.CreateTree(definition); - 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; - } - - /// Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA. - private static string AppendForgedCommit(string workdir, params (string Name, string Content)[] entries) - { - using var repo = new Repository(workdir); - var head = repo.Head.Tip; - - var definition = TreeDefinition.From(head.Tree); - foreach (var (name, content) in entries) - { - var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content))); - definition.Add(name, blob, Mode.NonExecutableFile); - } - 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; - } - /// 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) { @@ -361,9 +376,6 @@ public sealed class PathRecoveryTests : IDisposable repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id); } - private static Signature MakeSignature() - => new("tester", "tester@example.test", DateTimeOffset.Now); - private static bool IsCheckoutPending(string repoPath) { using var repo = new Repository(repoPath); @@ -375,30 +387,4 @@ public sealed class PathRecoveryTests : IDisposable using var repo = new Repository(repoPath); return Path.Combine(repo.Info.Path, "gclo-recovery.json"); } - - private static string HeadSha(string workdir) - { - using var repo = new Repository(workdir); - return repo.Head.Tip.Sha; - } - - private static void TryDeleteDirectory(string path) - { - try - { - if (!Directory.Exists(path)) - { - return; - } - foreach (var 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/SyncOverloadTests.cs b/gclo.Engine.Tests/SyncOverloadTests.cs index 18baa20..00a2eb3 100644 --- a/gclo.Engine.Tests/SyncOverloadTests.cs +++ b/gclo.Engine.Tests/SyncOverloadTests.cs @@ -1,4 +1,5 @@ using gclo.Engine; +using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; @@ -17,31 +18,13 @@ public sealed class SyncOverloadTests : IDisposable private readonly FakeGitClient _git = new(); private readonly RecordingProgress _progress = new(); - public void Dispose() - { - try - { - if (Directory.Exists(_targetRoot)) - { - Directory.Delete(_targetRoot, recursive: true); - } - } - catch - { - // Best effort; stray empty temp dirs are harmless. - } - } + public void Dispose() => TryDeleteDirectory(_targetRoot); private OrgSyncEngine CreateEngine() => new(_lister, _git); private SyncRequest CreateRequest(int maxConcurrency = 8) => new("acme", "test-token", _targetRoot, maxConcurrency); - private static RepoDescriptor Repo(string name) - => new(name, $"https://example.test/acme/{name}.git", "main", IsArchived: false); - - private static RepoDescriptor[] Repos(params string[] names) => names.Select(Repo).ToArray(); - [Fact] public async Task SyncWithList_NeverCallsTheLister() { diff --git a/gclo.Engine.Tests/WindowsPathValidationTests.cs b/gclo.Engine.Tests/WindowsPathValidationTests.cs index 8678980..7c2d976 100644 --- a/gclo.Engine.Tests/WindowsPathValidationTests.cs +++ b/gclo.Engine.Tests/WindowsPathValidationTests.cs @@ -1,5 +1,6 @@ using gclo.Engine; using LibGit2Sharp; +using static gclo.Engine.Tests.GitTestHelpers; namespace gclo.Engine.Tests; @@ -86,7 +87,7 @@ public sealed class WindowsPathValidationTests : IDisposable [Fact] public async Task Clone_MixedValidAndInvalidPaths_ReportsOnlyInvalidOnes_AndKeepsFetchedRepo() { - string source = CreateForgedRepo(("good.txt", true), ("bad:name.txt", true), ("NUL.log", true)); + string source = CreateForgedRepo(_root, "good.txt", "bad:name.txt", "NUL.log"); string target = NewPath("mixed-clone"); var ex = await Assert.ThrowsAsync( @@ -122,11 +123,11 @@ public sealed class WindowsPathValidationTests : IDisposable [Fact] public async Task FetchAndPull_IncomingCommitWithInvalidPath_ThrowsTypedException() { - string source = CreateForgedRepo(("original.txt", true)); + 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"); + AppendForgedCommit(source, ("bad:incoming.txt", "x")); var ex = await Assert.ThrowsAsync( () => _client.FetchAndPullAsync(target, Token, CancellationToken.None)); @@ -156,42 +157,6 @@ public sealed class WindowsPathValidationTests : IDisposable return WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition)); } - /// Builds a repo whose single commit holds the given forged entry names. - private string CreateForgedRepo(params (string Name, bool _)[] entries) - { - string path = NewPath("forged-" + Guid.NewGuid().ToString("N")[..8]); - Repository.Init(path); - using var repo = new Repository(path); - - var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("content"u8.ToArray())); - var definition = new TreeDefinition(); - foreach (var (name, _) in entries) - { - definition.Add(name, blob, Mode.NonExecutableFile); - } - var tree = repo.ObjectDatabase.CreateTree(definition); - var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); - var commit = repo.ObjectDatabase.CreateCommit( - signature, signature, "forged commit", tree, [], prettifyMessage: false); - repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id); - return path; - } - - /// Adds a commit on top of HEAD introducing a forged entry name. - private static void AppendForgedCommit(string workdir, string entryName) - { - using var repo = new Repository(workdir); - var head = repo.Head.Tip; - - var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream("x"u8.ToArray())); - var definition = TreeDefinition.From(head.Tree).Add(entryName, blob, Mode.NonExecutableFile); - var tree = repo.ObjectDatabase.CreateTree(definition); - var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); - var commit = repo.ObjectDatabase.CreateCommit( - signature, signature, "forged follow-up", tree, [head], prettifyMessage: false); - repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id); - } - /// Creates a repo containing one file at a deep path exceeding legacy MAX_PATH. private string CreateSourceRepoWithLongPath(string repoPath) { @@ -205,28 +170,8 @@ public sealed class WindowsPathValidationTests : IDisposable Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); File.WriteAllText(fullPath, "deep content"); Commands.Stage(repo, repoPath); - var signature = new Signature("tester", "tester@example.test", DateTimeOffset.Now); + var signature = MakeSignature(); repo.Commit("long path commit", signature, signature); return path; } - - private static void TryDeleteDirectory(string path) - { - try - { - if (!Directory.Exists(path)) - { - return; - } - foreach (var 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/FileActivityLog.cs b/gclo.Engine/FileActivityLog.cs index 8b1b71a..ce23e30 100644 --- a/gclo.Engine/FileActivityLog.cs +++ b/gclo.Engine/FileActivityLog.cs @@ -14,6 +14,7 @@ public sealed class FileActivityLog : IActivityLog { private readonly object _gate = new(); + /// Creates a log that writes under . /// /// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank. /// @@ -26,6 +27,7 @@ public sealed class FileActivityLog : IActivityLog : directory; } + /// public string LogDirectory { get; } /// Today's log file; entries roll over to a new file at midnight. @@ -34,8 +36,10 @@ public sealed class FileActivityLog : IActivityLog 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) @@ -67,14 +71,18 @@ public sealed class FileActivityLog : IActivityLog /// 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/GitHubOrganizationLister.cs b/gclo.Engine/GitHubOrganizationLister.cs index c722986..66a57cb 100644 --- a/gclo.Engine/GitHubOrganizationLister.cs +++ b/gclo.Engine/GitHubOrganizationLister.cs @@ -5,6 +5,7 @@ namespace gclo.Engine; /// Lists the authenticated user's organizations through the GitHub REST API. public sealed class GitHubOrganizationLister : IOrganizationLister { + /// public async Task> ListOrganizationsAsync(string token, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(token); @@ -24,9 +25,20 @@ public sealed class GitHubOrganizationLister : IOrganizationLister var currentUser = await client.User.Current().ConfigureAwait(false); userLogin = currentUser.Login; - organizations = await client.Organization - .GetAllForCurrent(new ApiOptions { PageSize = 100 }) - .ConfigureAwait(false); + try + { + organizations = await client.Organization + .GetAllForCurrent(new ApiOptions { PageSize = 100 }) + .ConfigureAwait(false); + } + catch (ForbiddenException ex) when (ex is not RateLimitExceededException) + { + // 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. + return [userLogin]; + } } catch (AuthorizationException ex) { diff --git a/gclo.Engine/GitHubRepositoryLister.cs b/gclo.Engine/GitHubRepositoryLister.cs index c0f1a24..ae70d2a 100644 --- a/gclo.Engine/GitHubRepositoryLister.cs +++ b/gclo.Engine/GitHubRepositoryLister.cs @@ -5,6 +5,7 @@ namespace gclo.Engine; /// Lists org repositories through the GitHub REST API via Octokit. public sealed class GitHubRepositoryLister : IRepositoryLister { + /// public async Task> ListOrganizationRepositoriesAsync( string organization, string token, CancellationToken cancellationToken = default) { diff --git a/gclo.Engine/IGitClient.cs b/gclo.Engine/IGitClient.cs index 6e9808d..cf36618 100644 --- a/gclo.Engine/IGitClient.cs +++ b/gclo.Engine/IGitClient.cs @@ -7,7 +7,11 @@ public interface IGitClient 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. @@ -17,9 +21,12 @@ public interface IGitClient /// 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. Throws when - /// the effective (post-mapping) path set is still invalid or collides; on - /// success the recovery is persisted so later pulls re-apply it to new commits. + /// 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, string token, PathRecovery recovery, CancellationToken cancellationToken); + Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken); } diff --git a/gclo.Engine/InvalidRepositoryPathsException.cs b/gclo.Engine/InvalidRepositoryPathsException.cs index 42ba83b..4c063e8 100644 --- a/gclo.Engine/InvalidRepositoryPathsException.cs +++ b/gclo.Engine/InvalidRepositoryPathsException.cs @@ -7,8 +7,10 @@ namespace gclo.Engine; /// public sealed class InvalidRepositoryPathsException : Exception { + /// 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)) { diff --git a/gclo.Engine/LibGit2GitClient.cs b/gclo.Engine/LibGit2GitClient.cs index f88d025..f95d098 100644 --- a/gclo.Engine/LibGit2GitClient.cs +++ b/gclo.Engine/LibGit2GitClient.cs @@ -20,24 +20,26 @@ public sealed class LibGit2GitClient : IGitClient private static readonly JsonSerializerOptions RecoveryJsonOptions = new() { WriteIndented = true }; + /// 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); - // The token is unused today (recovery works on already-fetched objects) but is part - // of the contract so implementations that need to re-fetch can do so. - public Task ApplyRecoveryAsync(string path, string token, PathRecovery recovery, CancellationToken cancellationToken) + /// + public Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken) => Task.Factory.StartNew( () => ApplyRecovery(path, recovery, cancellationToken), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default); @@ -48,9 +50,10 @@ public sealed class LibGit2GitClient : IGitClient var options = new CloneOptions { - // Two-phase: fetch only, then checkout after core.longpaths is set and - // every tree path is validated against Windows rules — a repo that is - // fine on Linux must fail with a structured error, not a libgit2 one. + // 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); @@ -76,9 +79,12 @@ public sealed class LibGit2GitClient : IGitClient Repository.Clone(url, path, options); using var repo = new Repository(path); - // 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); + 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); + } var tip = repo.Head.Tip; if (tip is null) @@ -86,15 +92,18 @@ public sealed class LibGit2GitClient : IGitClient return; // empty repository — nothing to check out } - var invalidPaths = WindowsPathValidator.Validate(tip.Tree); - if (invalidPaths.Count > 0) + if (OperatingSystem.IsWindows()) { - // Keep the fetched repo: all objects are already downloaded, so - // ApplyRecoveryAsync can materialize a sanitized working tree without - // touching the network. The marker tells FetchAndPull that this - // repository was never checked out. - repo.Config.Set(CheckoutPendingKey, true, ConfigurationLevel.Local); - throw new InvalidRepositoryPathsException(invalidPaths); + 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 marker tells FetchAndPull that this + // repository was never checked out. + repo.Config.Set(CheckoutPendingKey, true, ConfigurationLevel.Local); + throw new InvalidRepositoryPathsException(invalidPaths); + } } ct.ThrowIfCancellationRequested(); @@ -126,8 +135,11 @@ public sealed class LibGit2GitClient : IGitClient { using var repo = new Repository(path); - // Idempotent; also covers repositories that were cloned by other tools. - repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + 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."); @@ -153,8 +165,8 @@ public sealed class LibGit2GitClient : IGitClient // Repositories whose clone hit Windows-invalid paths never had a checkout; // they carry a pending marker (and, once the user chose renames/skips, a // persisted recovery). Both take a dedicated path — their working tree is - // materialized manually, never by a merge checkout. Everything else behaves - // exactly as before. + // materialized manually, never by a merge checkout. Every other repository + // takes the normal fetch + fast-forward pull below. string recoveryFile = GetRecoveryFilePath(repo); if (File.Exists(recoveryFile)) { @@ -199,10 +211,13 @@ public sealed class LibGit2GitClient : IGitClient } // Incoming commits can introduce Windows-invalid paths just like a clone can. - var invalidIncoming = WindowsPathValidator.Validate(tracked.Tip.Tree); - if (invalidIncoming.Count > 0) + if (OperatingSystem.IsWindows()) { - throw new InvalidRepositoryPathsException(invalidIncoming); + var invalidIncoming = WindowsPathValidator.Validate(tracked.Tip.Tree); + if (invalidIncoming.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidIncoming); + } } // Fast-forward only: a mirror tool must never manufacture merge commits. @@ -244,10 +259,13 @@ public sealed class LibGit2GitClient : IGitClient return; // the remote is still empty; nothing to pull } - var invalidPaths = WindowsPathValidator.Validate(remoteBranch.Tip.Tree); - if (invalidPaths.Count > 0) + if (OperatingSystem.IsWindows()) { - throw new InvalidRepositoryPathsException(invalidPaths); + var invalidPaths = WindowsPathValidator.Validate(remoteBranch.Tip.Tree); + if (invalidPaths.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidPaths); + } } repo.Refs.Add(targetRef, remoteBranch.Tip.Id); @@ -288,12 +306,23 @@ public sealed class LibGit2GitClient : IGitClient /// /// 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. Persists the recovery so later pulls re-apply it, then - /// clears the pending-checkout marker. + /// 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) { - repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + if (OperatingSystem.IsWindows()) + { + repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local); + } + + string recoveryFile = GetRecoveryFilePath(repo); + if (File.Exists(recoveryFile)) + { + recovery = MergeRecoveries(stored: LoadRecovery(recoveryFile), incoming: recovery); + } var tip = repo.Head.Tip ?? throw new InvalidOperationException("Repository has no commits; nothing to materialize."); @@ -333,11 +362,29 @@ public sealed class LibGit2GitClient : IGitClient repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local); } + /// + /// 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) relocates it — and, for a - /// directory, its entire subtree — to the mapped destination. + /// 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) @@ -357,11 +404,17 @@ public sealed class LibGit2GitClient : IGitClient continue; // omit the file — or the whole subtree when this is a directory } - // Renames key on the full original path; unmapped entries keep their - // own name under the (possibly renamed) parent's effective prefix. - string effectivePath = recovery.SegmentRenames.TryGetValue(originalPath, out string? mapped) - ? mapped - : effectivePrefix.Length == 0 ? entry.Name : $"{effectivePrefix}/{entry.Name}"; + // 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) { @@ -397,10 +450,13 @@ public sealed class LibGit2GitClient : IGitClient var tip = repo.Head.Tip; if (tip is not null) { - var invalidPaths = WindowsPathValidator.Validate(tip.Tree); - if (invalidPaths.Count > 0) + if (OperatingSystem.IsWindows()) { - throw new InvalidRepositoryPathsException(invalidPaths); + var invalidPaths = WindowsPathValidator.Validate(tip.Tree); + if (invalidPaths.Count > 0) + { + throw new InvalidRepositoryPathsException(invalidPaths); + } } ct.ThrowIfCancellationRequested(); @@ -445,6 +501,8 @@ public sealed class LibGit2GitClient : IGitClient => 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. private sealed record RecoveryDocument(Dictionary? SegmentRenames, List? SkippedPaths); private static void SaveRecovery(Repository repo, PathRecovery recovery) diff --git a/gclo.Engine/OrgSyncEngine.cs b/gclo.Engine/OrgSyncEngine.cs index 9be0077..8122f1e 100644 --- a/gclo.Engine/OrgSyncEngine.cs +++ b/gclo.Engine/OrgSyncEngine.cs @@ -12,6 +12,10 @@ public sealed class OrgSyncEngine private readonly IRepositoryLister _lister; private readonly IGitClient _git; + /// + /// 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)); diff --git a/gclo.Engine/PACKAGE.md b/gclo.Engine/PACKAGE.md new file mode 100644 index 0000000..1387cbc --- /dev/null +++ b/gclo.Engine/PACKAGE.md @@ -0,0 +1,10 @@ +# gclo.Engine + +The core engine of [gclo](https://github.com/KofTwentyTwo/gclo) (Git Clone Large +Organizations). It lists every repository in a GitHub organization or user account +and clones or fast-forward-pulls all of them in parallel, using LibGit2Sharp for +git transport and Octokit for the GitHub API. On Windows it validates every tree +path before checkout and offers structured rename/skip recovery for paths that +git allows but Windows cannot create. + +Used by the gclo WinUI app and the cross-platform gclo CLI. diff --git a/gclo.Engine/PathRecovery.cs b/gclo.Engine/PathRecovery.cs index 9e1244d..0d4708d 100644 --- a/gclo.Engine/PathRecovery.cs +++ b/gclo.Engine/PathRecovery.cs @@ -6,9 +6,11 @@ namespace gclo.Engine; /// instead of a normal checkout. /// /// -/// Maps an original repository path (forward-slash separated) to its full -/// replacement repository path (already joined, forward slashes). A directory -/// mapping relocates its whole subtree. +/// Maps an original repository path (forward-slash separated) to its replacement +/// path. Only the replacement's LAST segment is applied: the entry is renamed in +/// place under its parent's effective (post-rename) location, so renaming a +/// directory and one of its descendants composes. A directory mapping relocates +/// its whole subtree. /// /// /// Repository paths (files or whole directories) to omit from the working tree. diff --git a/gclo.Engine/SyncStatus.cs b/gclo.Engine/SyncStatus.cs index 299a8ce..7c96a8e 100644 --- a/gclo.Engine/SyncStatus.cs +++ b/gclo.Engine/SyncStatus.cs @@ -3,10 +3,21 @@ namespace gclo.Engine; /// Lifecycle of a single repository within an org sync. public enum SyncStatus { + /// Discovered and waiting for a worker. Queued, + + /// Being cloned for the first time. Cloning, + + /// Existing local repository being fetched and fast-forwarded. Pulling, + + /// Finished successfully. Done, + + /// Failed; the progress report carries the reason. Failed, + + /// Stopped by cancellation before or during processing. Canceled, } diff --git a/gclo.Engine/SyncSummary.cs b/gclo.Engine/SyncSummary.cs index 063b8f5..c308834 100644 --- a/gclo.Engine/SyncSummary.cs +++ b/gclo.Engine/SyncSummary.cs @@ -1,4 +1,10 @@ namespace gclo.Engine; /// Aggregate outcome of an organization sync. +/// Number of repositories the sync set out to process. +/// Repositories cloned for the first time. +/// Existing repositories fetched and fast-forwarded. +/// Repositories that failed; a failure never aborts the others. +/// Repositories canceled before or during processing. +/// Whether the run was cut short by cancellation. public sealed record SyncSummary(int Total, int Cloned, int Updated, int Failed, int Canceled, bool WasCanceled); diff --git a/gclo.Engine/WindowsPathValidator.cs b/gclo.Engine/WindowsPathValidator.cs index 274d9e7..f3aeca6 100644 --- a/gclo.Engine/WindowsPathValidator.cs +++ b/gclo.Engine/WindowsPathValidator.cs @@ -22,14 +22,21 @@ public static class WindowsPathValidator "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", }; - /// Returns every Windows-invalid path in ; empty when all are fine. + /// + /// 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) - { - var invalid = new List(); - // Lowered path -> first-seen original casing, to catch case-only collisions - // (git allows README.md and readme.md; NTFS cannot hold both). - var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + => ValidatePaths(FlattenPaths(tree)); + /// + /// 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, "")); @@ -39,36 +46,32 @@ public static class WindowsPathValidator foreach (var entry in current) { string repoPath = prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}"; - - if (ValidateSegment(entry.Name) is { } problem) + if (entry.TargetType == TreeEntryTargetType.Tree) { - invalid.Add(new InvalidPathInfo(repoPath, problem.Reason, problem.Suggestion)); - } - - if (seen.TryGetValue(repoPath, out string? original)) - { - invalid.Add(new InvalidPathInfo(repoPath, $"differs only by case from '{original}'", null)); + var subtree = (Tree)entry.Target; + if (subtree.Count == 0) + { + yield return repoPath; + } + else + { + stack.Push((subtree, repoPath)); + } } else { - seen[repoPath] = repoPath; - } - - if (entry.TargetType == TreeEntryTargetType.Tree) - { - stack.Push(((Tree)entry.Target, repoPath)); + yield return repoPath; } } } - - return invalid; } /// - /// Validates a flat set of file paths (forward-slash separated) against the same - /// rules as . Used 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 + /// 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) diff --git a/gclo.Engine/gclo.Engine.csproj b/gclo.Engine/gclo.Engine.csproj index a627cec..2e45976 100644 --- a/gclo.Engine/gclo.Engine.csproj +++ b/gclo.Engine/gclo.Engine.csproj @@ -1,11 +1,21 @@ - + net10.0 enable enable + true + Core engine for gclo (Git Clone Large Organizations): lists every repository in a GitHub organization or user account and clones or fast-forward-pulls them all in parallel, with Windows path validation and rename/skip recovery. + James Maes + MIT + https://github.com/KofTwentyTwo/gclo + PACKAGE.md + + + + diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 828ef10..574bfd0 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -1,9 +1,7 @@ -using System; -using System.IO; using System.Text.Json; using System.Text.Json.Serialization; -namespace gclo.Services; +namespace gclo.ViewModels; /// /// User preferences persisted as JSON at %LOCALAPPDATA%\gclo\settings.json. diff --git a/gclo.ViewModels/MainViewModel.cs b/gclo.ViewModels/MainViewModel.cs index 4676c28..a9d864d 100644 --- a/gclo.ViewModels/MainViewModel.cs +++ b/gclo.ViewModels/MainViewModel.cs @@ -1,11 +1,5 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using gclo.Engine; @@ -56,7 +50,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable Organization = ""; Token = ""; TargetFolder = ""; - MaxConcurrency = 8; + MaxConcurrency = AppSettings.DefaultConcurrency; StatusText = ""; AllSelected = true; } @@ -177,29 +171,37 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable 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. + IsLoadingOrgs = false; return; // not plausibly a complete PAT yet } try { - await Task.Delay(_orgLookupDebounce, cts.Token); // debounce keystrokes / rapid pastes + await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes IsLoadingOrgs = true; - var orgs = await _orgLister.ListOrganizationsAsync(token, cts.Token); - cts.Token.ThrowIfCancellationRequested(); + var orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); + lookupToken.ThrowIfCancellationRequested(); Organizations.Clear(); foreach (string org in orgs) { Organizations.Add(org); } - StatusText = orgs.Count == 0 - ? "Token accepted, but it cannot list organizations (fine-grained PAT or missing read:org?). Type an organization or account name manually." + // 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) @@ -230,7 +232,9 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable get => MaxConcurrency; set { - int clamped = double.IsNaN(value) ? 8 : (int)Math.Clamp(Math.Round(value), 1, 64); + 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 @@ -410,11 +414,11 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable SyncSummary summary = await engine.SyncAsync(request, descriptors, progress, cancellationToken); - StatusText = summary.WasCanceled - ? $"Canceled: {summary.Cloned} cloned, {summary.Updated} updated, {summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}." - : $"Finished: {summary.Cloned} cloned, {summary.Updated} updated, {summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."; - _log.Info($"Sync finished: {summary.Cloned} cloned, {summary.Updated} updated, " - + $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}."); + 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); } catch (OperationCanceledException) { @@ -485,7 +489,7 @@ public sealed partial class MainViewModel : ObservableObject, IDisposable { _log.Info($"{item.Name}: applying path recovery " + $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped)."); - await _git.ApplyRecoveryAsync(path, Token.Trim(), recovery, CancellationToken.None); + await _git.ApplyRecoveryAsync(path, recovery, CancellationToken.None); item.Status = SyncStatus.Done; item.Error = null; diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 7d619c6..2dde423 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using CommunityToolkit.Mvvm.ComponentModel; using gclo.Engine; diff --git a/gclo/App.xaml.cs b/gclo/App.xaml.cs index 856e1e9..ae2cda4 100644 --- a/gclo/App.xaml.cs +++ b/gclo/App.xaml.cs @@ -1,23 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using Microsoft.UI.Xaml.Shapes; -using Windows.ApplicationModel; -using Windows.ApplicationModel.Activation; -using Windows.Foundation; -using Windows.Foundation.Collections; - -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. namespace gclo { @@ -29,8 +10,8 @@ namespace gclo private Window? _window; /// - /// Initializes the singleton application object. This is the first line of authored code - /// executed, and as such is the logical equivalent of main() or WinMain(). + /// Constructed by Program.Main inside Application.Start after VelopackApp handled + /// install/update hooks — see Program.cs. /// public App() { diff --git a/gclo/Services/UpdateService.cs b/gclo/Services/UpdateService.cs index de1db8d..9ebdc00 100644 --- a/gclo/Services/UpdateService.cs +++ b/gclo/Services/UpdateService.cs @@ -8,11 +8,9 @@ namespace gclo.Services; /// /// Result of an update check. is the newer version found, -/// or null when already up to date; is true when that update is -/// already staged locally (e.g. by a previous session) and only needs a restart to apply; -/// is non-null when the check itself failed. +/// or null when already up to date; is non-null when the check itself failed. /// -public sealed record UpdateCheckResult(string? AvailableVersion, bool IsDownloaded, string? Error); +public sealed record UpdateCheckResult(string? AvailableVersion, string? Error); /// /// Self-update via Velopack, with releases hosted on the app's public GitHub repository. @@ -72,30 +70,26 @@ public sealed class UpdateService manager = GetManager(); if (!manager.IsInstalled) { - return new UpdateCheckResult(null, false, "Updates are only available in installed builds."); + return new UpdateCheckResult(null, "Updates are only available in installed builds."); } } catch (Exception ex) { - return new UpdateCheckResult(null, false, ex.Message); + return new UpdateCheckResult(null, ex.Message); } try { UpdateInfo? update = await manager.CheckForUpdatesAsync().ConfigureAwait(false); _pendingUpdate = update; - if (update is null) - { - return new UpdateCheckResult(null, false, null); - } - - bool downloaded = manager.UpdatePendingRestart?.Version == update.TargetFullRelease.Version; - return new UpdateCheckResult(update.TargetFullRelease.Version.ToString(), downloaded, null); + return update is null + ? new UpdateCheckResult(null, null) + : new UpdateCheckResult(update.TargetFullRelease.Version.ToString(), null); } catch (Exception ex) { _pendingUpdate = null; - return new UpdateCheckResult(null, false, ex.Message); + return new UpdateCheckResult(null, ex.Message); } } @@ -104,9 +98,7 @@ public sealed class UpdateService /// and restarts the app. On success this never returns (the process exits to restart); /// otherwise it returns an error message. Never throws. /// - /// Optional download progress callback, called with 0-100. - /// Optional token to abandon the download. - public async Task DownloadAndApplyAsync(Action? progress = null, CancellationToken cancellationToken = default) + public async Task DownloadAndApplyAsync() { UpdateManager? manager = _manager; UpdateInfo? update = _pendingUpdate; @@ -117,14 +109,10 @@ public sealed class UpdateService try { - await manager.DownloadUpdatesAsync(update, progress, cancellationToken).ConfigureAwait(false); + await manager.DownloadUpdatesAsync(update, null, CancellationToken.None).ConfigureAwait(false); manager.ApplyUpdatesAndRestart(update.TargetFullRelease); return null; } - catch (OperationCanceledException) - { - return "The update was canceled."; - } catch (Exception ex) { return ex.Message; diff --git a/gclo/SettingsDialog.xaml.cs b/gclo/SettingsDialog.xaml.cs index 34de647..095bcb0 100644 --- a/gclo/SettingsDialog.xaml.cs +++ b/gclo/SettingsDialog.xaml.cs @@ -1,5 +1,5 @@ using System; -using gclo.Services; +using gclo.ViewModels; using Microsoft.UI.Xaml.Controls; namespace gclo -- 2.54.0