Audit: analyzer enforcement + 28 verified findings fixed (#5) #18
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using gclo.Engine;
|
||||
|
||||
|
||||
+3
-22
@@ -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,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
|
||||
{
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
using System;
|
||||
using gclo.Services;
|
||||
using gclo.ViewModels;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace gclo
|
||||
|
||||
Reference in new issue
Block a user