Audit: analyzer enforcement + 28 verified findings fixed (#5) #18

Merged
KofTwentyTwo merged 3 commits from dev into main 2026-07-03 16:41:20 +00:00
42 changed files with 678 additions and 464 deletions

No files matched your search

+61
View File
@@ -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
+4
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
<Project>
<!-- Local quality gate: every project builds with warnings as errors and the
recommended analyzers + .editorconfig code style enforced, so problems fail
at the desk exactly like they fail in CI. -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
+12 -7
View File
@@ -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
<!-- SCREENSHOT: empty main window on first launch — token box, org dropdown, target folder,
Parallel spinner, Sync/Cancel buttons. Tracked in issue #10. -->
<!-- SCREENSHOT: empty main window on first launch — token box, org dropdown, target folder
with subfolder checkbox and path preview, Parallel spinner, Load repos / Sync selected
buttons. Tracked in issue #10. -->
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.
<!-- SCREENSHOT: org dropdown open, personal account at the top followed by orgs. Tracked in issue #10. -->
3. **Choose a target folder** — type a path or click **Browse...**. Each repository syncs into `<target>\<repo>`.
3. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `<org>` 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.`).
<!-- SCREENSHOT: sync in progress — several repos Cloning with percentages, progress bar advancing. Tracked in issue #10. -->
<!-- SCREENSHOT: sync in progress — several rows Cloning with per-row progress bars, overall bar advancing. Tracked in issue #10. -->
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).
<!-- SCREENSHOT: Settings dialog — default target folder, default parallelism, theme. Tracked in issue #10. -->
**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
+22 -5
View File
@@ -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 |
+40 -8
View File
@@ -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 <NUGET_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 <WINGET_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
+1 -1
View File
@@ -17,7 +17,7 @@ internal static class OrgsCommand
Options:
--token-env <VAR> Read the token from environment variable VAR
(default: GITHUB_TOKEN when no token option is given).
--token-file <path> Read the token from the first line of a file.
--token-file <path> 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.
+1 -1
View File
@@ -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 <VAR> read it from environment variable VAR
--token-file <path> read the first line of a file
--token-file <path> 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.
+3 -4
View File
@@ -28,7 +28,7 @@ internal static class SyncCommand
repository counts as cloned.
--token-env <VAR> Read the token from environment variable VAR
(default: GITHUB_TOKEN when no token option is given).
--token-file <path> Read the token from the first line of a file.
--token-file <path> 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(
+3 -3
View File
@@ -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)
{
+7 -7
View File
@@ -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);
/// <summary>
/// An <see cref="IGitClient"/> whose behavior is configured with delegates.
@@ -53,9 +53,9 @@ public sealed class FakeGitClient : IGitClient
public Func<string, string, CancellationToken, Task> FetchAndPullHandler { get; set; }
= (_, _, _) => Task.CompletedTask;
/// <summary>Body of <see cref="ApplyRecoveryAsync"/> (path, token, recovery, ct). Default: completes immediately.</summary>
public Func<string, string, PathRecovery, CancellationToken, Task> ApplyRecoveryHandler { get; set; }
= (_, _, _, _) => Task.CompletedTask;
/// <summary>Body of <see cref="ApplyRecoveryAsync"/> (path, recovery, ct). Default: completes immediately.</summary>
public Func<string, PathRecovery, CancellationToken, Task> ApplyRecoveryHandler { get; set; }
= (_, _, _) => Task.CompletedTask;
public IReadOnlyList<CloneCall> CloneCalls => _cloneCalls.ToArray();
public IReadOnlyList<PullCall> 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);
}
}
+119
View File
@@ -0,0 +1,119 @@
using System.Diagnostics;
using System.Text;
using gclo.Engine;
using LibGit2Sharp;
namespace gclo.Engine.Tests;
/// <summary>
/// Shared fixture helpers: descriptor builders for fake repositories, polling waits,
/// and real on-disk git fixtures. Forged tree entries are written through the object
/// database because Windows-invalid names can never be created on a working tree
/// directly.
/// </summary>
internal static class GitTestHelpers
{
/// <summary>Descriptor for a repository of the fake organization 'acme'.</summary>
public static RepoDescriptor Repo(string name, string? branch = "main", bool archived = false)
=> new(name, $"https://example.test/acme/{name}.git", branch, archived);
/// <summary>Descriptors (default branch, not archived) for the given names.</summary>
public static RepoDescriptor[] Repos(params string[] names)
=> names.Select(name => Repo(name)).ToArray();
/// <summary>Standard signature for fixture commits.</summary>
public static Signature MakeSignature()
=> new("tester", "tester@example.test", DateTimeOffset.Now);
/// <summary>Polls until <paramref name="condition"/> is true; fails the test after a generous timeout.</summary>
public static async Task WaitUntilAsync(Func<bool> condition, string description)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
$"Timed out after {stopwatch.Elapsed} waiting for: {description}");
await Task.Delay(10);
}
}
/// <summary>SHA of the commit at HEAD's tip.</summary>
public static string HeadSha(string workdir)
{
using var repo = new Repository(workdir);
return repo.Head.Tip.Sha;
}
/// <summary>
/// Builds a repository in a fresh directory under <paramref name="root"/> whose single
/// commit holds the given forged entries with per-entry content; returns its path.
/// </summary>
public static string CreateForgedRepo(string root, params (string Name, string Content)[] entries)
{
string path = Path.Combine(root, "forged-" + Guid.NewGuid().ToString("N")[..8]);
Repository.Init(path);
using var repo = new Repository(path);
var tree = repo.ObjectDatabase.CreateTree(AddForgedEntries(repo, new TreeDefinition(), entries));
var signature = MakeSignature();
var commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged commit", tree, [], prettifyMessage: false);
repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id);
return path;
}
/// <summary>Convenience overload: every forged entry gets the same placeholder content.</summary>
public static string CreateForgedRepo(string root, params string[] entryNames)
=> CreateForgedRepo(root, entryNames.Select(name => (name, "content")).ToArray());
/// <summary>Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA.</summary>
public static string AppendForgedCommit(string workdir, params (string Name, string Content)[] entries)
{
using var repo = new Repository(workdir);
var head = repo.Head.Tip;
var definition = AddForgedEntries(repo, TreeDefinition.From(head.Tree), entries);
var tree = repo.ObjectDatabase.CreateTree(definition);
var signature = MakeSignature();
var commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged follow-up", tree, [head], prettifyMessage: false);
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id);
return commit.Sha;
}
private static TreeDefinition AddForgedEntries(
Repository repo, TreeDefinition definition, (string Name, string Content)[] entries)
{
foreach (var (name, content) in entries)
{
var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content)));
definition.Add(name, blob, Mode.NonExecutableFile);
}
return definition;
}
/// <summary>
/// Same approach as the production client: pack files under .git are written
/// read-only on Windows, so clear attributes before deleting. Best effort — a
/// stray temp dir is harmless.
/// </summary>
public static void TryDeleteDirectory(string path)
{
try
{
if (!Directory.Exists(path))
{
return;
}
foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
File.SetAttributes(file, FileAttributes.Normal);
}
Directory.Delete(path, recursive: true);
}
catch
{
// Best effort; stray temp dirs are harmless.
}
}
}
+3 -53
View File
@@ -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<InvalidRepositoryPathsException>(
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Same approach as the class under test: pack files under .git are written read-only
/// on Windows, so clear attributes before deleting.
/// </summary>
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.
}
}
}
+36 -22
View File
@@ -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);
/// <summary>
/// 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<bool> condition, string description)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
$"Timed out after {stopwatch.Elapsed} waiting for: {description}");
await Task.Delay(10);
}
}
// ---------------------------------------------------------------- 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<IReadOnlyList<string>>(["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<IReadOnlyList<string>>(
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]
+5 -34
View File
@@ -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();
/// <summary>Polls until <paramref name="condition"/> is true; fails the test after a generous timeout.</summary>
private static async Task WaitUntilAsync(Func<bool> condition, string description)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(15),
$"Timed out after {stopwatch.Elapsed} waiting for: {description}");
await Task.Delay(10);
}
}
// ---------------------------------------------------------------- (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
{
+74 -88
View File
@@ -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<InvalidRepositoryPathsException>(
@@ -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<InvalidRepositoryPathsException>(
() => _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<InvalidRepositoryPathsException>(
() => _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<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "worse:new.txt");
// Applying a recovery that covers ONLY the new path merges with the stored one:
// the old rename keeps working and the persisted json carries both mappings.
await _client.ApplyRecoveryAsync(
target, Recovery(renames: [("worse:new.txt", "worse_new.txt")]), CancellationToken.None);
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("new content", File.ReadAllText(Path.Combine(target, "worse_new.txt")));
string json = File.ReadAllText(RecoveryFilePath(target));
Assert.Contains("bad_file.txt", json);
Assert.Contains("worse_new.txt", json);
}
// ---------------------------------------------------------------- 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<string>(skips ?? [], StringComparer.Ordinal));
/// <summary>Builds a repo whose single commit holds the given forged entries with per-entry content.</summary>
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;
}
/// <summary>Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA.</summary>
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;
}
/// <summary>Adds a commit that removes one forged entry and adds another — "upstream fixed the path".</summary>
private static void ReplaceForgedEntry(string workdir, string remove, (string Name, string Content) add)
{
@@ -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.
}
}
}
+2 -19
View File
@@ -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()
{
@@ -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<InvalidRepositoryPathsException>(
@@ -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<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
@@ -156,42 +157,6 @@ public sealed class WindowsPathValidationTests : IDisposable
return WindowsPathValidator.Validate(repo.ObjectDatabase.CreateTree(definition));
}
/// <summary>Builds a repo whose single commit holds the given forged entry names.</summary>
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;
}
/// <summary>Adds a commit on top of HEAD introducing a forged entry name.</summary>
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);
}
/// <summary>Creates a repo containing one file at a deep path exceeding legacy MAX_PATH.</summary>
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.
}
}
}
+8
View File
@@ -14,6 +14,7 @@ public sealed class FileActivityLog : IActivityLog
{
private readonly object _gate = new();
/// <summary>Creates a log that writes under <paramref name="directory"/>.</summary>
/// <param name="directory">
/// Folder for the log files; defaults to %LOCALAPPDATA%\gclo\logs when null or blank.
/// </param>
@@ -26,6 +27,7 @@ public sealed class FileActivityLog : IActivityLog
: directory;
}
/// <inheritdoc/>
public string LogDirectory { get; }
/// <summary>Today's log file; entries roll over to a new file at midnight.</summary>
@@ -34,8 +36,10 @@ public sealed class FileActivityLog : IActivityLog
LogDirectory,
"gclo-" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log");
/// <inheritdoc/>
public void Info(string message) => Write("INFO", message, exception: null);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception);
private void Write(string level, string message, Exception? exception)
@@ -67,14 +71,18 @@ public sealed class FileActivityLog : IActivityLog
/// <summary>An <see cref="IActivityLog"/> that discards everything.</summary>
public sealed class NullActivityLog : IActivityLog
{
/// <inheritdoc/>
public string LogDirectory => "";
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message)
{
}
/// <inheritdoc/>
public void Error(string message, Exception? exception = null)
{
}
+15 -3
View File
@@ -5,6 +5,7 @@ namespace gclo.Engine;
/// <summary>Lists the authenticated user's organizations through the GitHub REST API.</summary>
public sealed class GitHubOrganizationLister : IOrganizationLister
{
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> 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)
{
+1
View File
@@ -5,6 +5,7 @@ namespace gclo.Engine;
/// <summary>Lists org repositories through the GitHub REST API via Octokit.</summary>
public sealed class GitHubRepositoryLister : IRepositoryLister
{
/// <inheritdoc/>
public async Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
string organization, string token, CancellationToken cancellationToken = default)
{
+11 -4
View File
@@ -7,7 +7,11 @@ public interface IGitClient
bool IsValidRepository(string path);
/// <summary>Clones <paramref name="url"/> into <paramref name="path"/>.</summary>
/// <param name="url">HTTPS clone URL.</param>
/// <param name="path">Local directory the repository is cloned into.</param>
/// <param name="token">GitHub Personal Access Token used for git transport.</param>
/// <param name="onProgress">Receives object-transfer completion in [0,1], when known.</param>
/// <param name="cancellationToken">Cancels the clone.</param>
Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken);
/// <summary>Fetches from origin and fast-forwards the current branch to its upstream.</summary>
@@ -17,9 +21,12 @@ public interface IGitClient
/// Materializes the working tree of a fetched-but-never-checked-out repository
/// (one whose clone failed with <see cref="InvalidRepositoryPathsException"/>)
/// by writing HEAD's blobs to disk with <paramref name="recovery"/>'s renames
/// and skips applied. Throws <see cref="InvalidRepositoryPathsException"/> 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 <paramref name="recovery"/> winning on conflicts, so a repository that
/// gains new invalid paths can be fixed without restating earlier choices.
/// Throws <see cref="InvalidRepositoryPathsException"/> when the effective
/// (post-mapping) path set is still invalid or collides; on success the merged
/// recovery is persisted so later pulls re-apply it to new commits.
/// </summary>
Task ApplyRecoveryAsync(string path, string token, PathRecovery recovery, CancellationToken cancellationToken);
Task ApplyRecoveryAsync(string path, PathRecovery recovery, CancellationToken cancellationToken);
}
@@ -7,8 +7,10 @@ namespace gclo.Engine;
/// </summary>
public sealed class InvalidRepositoryPathsException : Exception
{
/// <summary>The offending paths, one entry per invalid path.</summary>
public IReadOnlyList<InvalidPathInfo> Paths { get; }
/// <summary>Creates the exception from the validator's findings.</summary>
public InvalidRepositoryPathsException(IReadOnlyList<InvalidPathInfo> paths)
: base(BuildMessage(paths))
{
+98 -40
View File
@@ -20,24 +20,26 @@ public sealed class LibGit2GitClient : IGitClient
private static readonly JsonSerializerOptions RecoveryJsonOptions = new() { WriteIndented = true };
/// <inheritdoc/>
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.
/// <inheritdoc/>
public Task CloneAsync(string url, string path, string token, Action<double>? onProgress, CancellationToken cancellationToken)
=> Task.Factory.StartNew(
() => Clone(url, path, token, onProgress, cancellationToken),
cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
/// <inheritdoc/>
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)
/// <inheritdoc/>
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
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Overlays <paramref name="incoming"/> onto <paramref name="stored"/>: renames
/// union with the incoming value winning on a shared original path; skips union.
/// </summary>
private static PathRecovery MergeRecoveries(PathRecovery stored, PathRecovery incoming)
{
var renames = new Dictionary<string, string>(stored.SegmentRenames, StringComparer.Ordinal);
foreach (var (originalPath, replacement) in incoming.SegmentRenames)
{
renames[originalPath] = replacement;
}
var skips = new HashSet<string>(stored.SkippedPaths, StringComparer.Ordinal);
skips.UnionWith(incoming.SkippedPaths);
return new PathRecovery(renames, skips);
}
/// <summary>
/// Walks <paramref name="tree"/> 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.
/// </summary>
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);
/// <summary>Serializable shape of <see cref="PathRecovery"/> for .git\gclo-recovery.json.</summary>
/// <param name="SegmentRenames">Original path to replacement path map; null when absent from the file.</param>
/// <param name="SkippedPaths">Paths omitted from the working tree; null when absent from the file.</param>
private sealed record RecoveryDocument(Dictionary<string, string>? SegmentRenames, List<string>? SkippedPaths);
private static void SaveRecovery(Repository repo, PathRecovery recovery)
+4
View File
@@ -12,6 +12,10 @@ public sealed class OrgSyncEngine
private readonly IRepositoryLister _lister;
private readonly IGitClient _git;
/// <summary>
/// Creates an engine that discovers repositories through <paramref name="lister"/>
/// and runs git operations through <paramref name="git"/>.
/// </summary>
public OrgSyncEngine(IRepositoryLister lister, IGitClient git)
{
_lister = lister ?? throw new ArgumentNullException(nameof(lister));
+10
View File
@@ -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.
+5 -3
View File
@@ -6,9 +6,11 @@ namespace gclo.Engine;
/// <see cref="IGitClient.ApplyRecoveryAsync"/> instead of a normal checkout.
/// </summary>
/// <param name="SegmentRenames">
/// 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.
/// </param>
/// <param name="SkippedPaths">
/// Repository paths (files or whole directories) to omit from the working tree.
+11
View File
@@ -3,10 +3,21 @@ namespace gclo.Engine;
/// <summary>Lifecycle of a single repository within an org sync.</summary>
public enum SyncStatus
{
/// <summary>Discovered and waiting for a worker.</summary>
Queued,
/// <summary>Being cloned for the first time.</summary>
Cloning,
/// <summary>Existing local repository being fetched and fast-forwarded.</summary>
Pulling,
/// <summary>Finished successfully.</summary>
Done,
/// <summary>Failed; the progress report carries the reason.</summary>
Failed,
/// <summary>Stopped by cancellation before or during processing.</summary>
Canceled,
}
+6
View File
@@ -1,4 +1,10 @@
namespace gclo.Engine;
/// <summary>Aggregate outcome of an organization sync.</summary>
/// <param name="Total">Number of repositories the sync set out to process.</param>
/// <param name="Cloned">Repositories cloned for the first time.</param>
/// <param name="Updated">Existing repositories fetched and fast-forwarded.</param>
/// <param name="Failed">Repositories that failed; a failure never aborts the others.</param>
/// <param name="Canceled">Repositories canceled before or during processing.</param>
/// <param name="WasCanceled">Whether the run was cut short by cancellation.</param>
public sealed record SyncSummary(int Total, int Cloned, int Updated, int Failed, int Canceled, bool WasCanceled);
+29 -26
View File
@@ -22,14 +22,21 @@ public static class WindowsPathValidator
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
};
/// <summary>Returns every Windows-invalid path in <paramref name="tree"/>; empty when all are fine.</summary>
/// <summary>
/// Returns every Windows-invalid path in <paramref name="tree"/>; empty when all
/// are fine. Flattens the tree to its leaf paths and runs them through
/// <see cref="ValidatePaths"/>, so both entry points share one implementation.
/// </summary>
public static IReadOnlyList<InvalidPathInfo> Validate(Tree tree)
{
var invalid = new List<InvalidPathInfo>();
// 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<string, string>(StringComparer.OrdinalIgnoreCase);
=> ValidatePaths(FlattenPaths(tree));
/// <summary>
/// Depth-first flattening of <paramref name="tree"/> into leaf paths (blobs and
/// submodule links). An empty subtree is emitted as its own path so its name is
/// still validated.
/// </summary>
private static IEnumerable<string> 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;
}
/// <summary>
/// Validates a flat set of file paths (forward-slash separated) against the same
/// rules as <see cref="Validate(Tree)"/>. Used for the EFFECTIVE path set after a
/// <see cref="PathRecovery"/> 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 <see cref="Validate(Tree)"/>
/// feeds by flattening a git tree. Also used directly for the EFFECTIVE path set
/// after a <see cref="PathRecovery"/> 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.
/// </summary>
public static IReadOnlyList<InvalidPathInfo> ValidatePaths(IEnumerable<string> filePaths)
+11 -1
View File
@@ -1,11 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>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.</Description>
<Authors>James Maes</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<RepositoryUrl>https://github.com/KofTwentyTwo/gclo</RepositoryUrl>
<PackageReadmeFile>PACKAGE.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="PACKAGE.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="Octokit" Version="14.0.0" />
+1 -3
View File
@@ -1,9 +1,7 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace gclo.Services;
namespace gclo.ViewModels;
/// <summary>
/// User preferences persisted as JSON at %LOCALAPPDATA%\gclo\settings.json.
+32 -20
View File
@@ -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 <see cref="SyncCommand"/> clones or updates the selected subset with live
/// per-repo progress. <see cref="RetryFailedCommand"/> re-runs just the failures.
/// </summary>
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();
/// <summary>Stops and releases the in-flight org lookup, if any.</summary>
public void Dispose()
{
_orgLoadCts?.Cancel();
_orgLoadCts?.Dispose();
_orgLoadCts = null;
}
/// <summary>Debounced: each token edit cancels the previous lookup.</summary>
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;
-2
View File
@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using gclo.Engine;
+3 -22
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public App()
{
+1 -1
View File
@@ -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
{
+3 -2
View File
@@ -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();
};
}
+1 -1
View File
@@ -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();
});
}
}
+10 -22
View File
@@ -8,11 +8,9 @@ namespace gclo.Services;
/// <summary>
/// Result of an update check. <see cref="AvailableVersion"/> is the newer version found,
/// or null when already up to date; <see cref="IsDownloaded"/> is true when that update is
/// already staged locally (e.g. by a previous session) and only needs a restart to apply;
/// <see cref="Error"/> is non-null when the check itself failed.
/// or null when already up to date; <see cref="Error"/> is non-null when the check itself failed.
/// </summary>
public sealed record UpdateCheckResult(string? AvailableVersion, bool IsDownloaded, string? Error);
public sealed record UpdateCheckResult(string? AvailableVersion, string? Error);
/// <summary>
/// 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.
/// </summary>
/// <param name="progress">Optional download progress callback, called with 0-100.</param>
/// <param name="cancellationToken">Optional token to abandon the download.</param>
public async Task<string?> DownloadAndApplyAsync(Action<int>? progress = null, CancellationToken cancellationToken = default)
public async Task<string?> 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;
+1 -1
View File
@@ -1,5 +1,5 @@
using System;
using gclo.Services;
using gclo.ViewModels;
using Microsoft.UI.Xaml.Controls;
namespace gclo