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/.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
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/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/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 6f2142c..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]
@@ -198,7 +184,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();
};
@@ -321,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);
@@ -361,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);
@@ -384,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);
@@ -418,7 +404,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);
@@ -566,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 2d29382..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]
@@ -105,8 +75,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.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 70ea639..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;
@@ -17,7 +11,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;
@@ -56,7 +50,7 @@ public sealed partial class MainViewModel : ObservableObject
Organization = "";
Token = "";
TargetFolder = "";
- MaxConcurrency = 8;
+ MaxConcurrency = AppSettings.DefaultConcurrency;
StatusText = "";
AllSelected = true;
}
@@ -165,33 +159,49 @@ 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()
{
_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)
@@ -222,7 +232,9 @@ public sealed partial class MainViewModel : ObservableObject
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
@@ -402,11 +414,11 @@ public sealed partial class MainViewModel : ObservableObject
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)
{
@@ -477,7 +489,7 @@ public sealed partial class MainViewModel : ObservableObject
{
_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 b4a1509..ae2cda4 100644
--- a/gclo/App.xaml.cs
+++ b/gclo/App.xaml.cs
@@ -1,23 +1,4 @@
-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 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;
-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.
+using Microsoft.UI.Xaml;
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/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();
});
}
}
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