Path recovery: rename/skip Windows-invalid paths (#2) #17

Merged
KofTwentyTwo merged 1 commits from dev into main 2026-07-03 15:43:53 +00:00
19 changed files with 1520 additions and 22 deletions

No files matched your search

+41 -1
View File
@@ -24,7 +24,7 @@ and runs on Linux/macOS hosts with the .NET 10 SDK.
## Commands
```
gclo sync --org <name> --target <folder> [--parallel N]
gclo sync --org <name> --target <folder> [--parallel N] [--sanitize-paths]
[--token-env VAR | --token-file PATH | --token-stdin]
[--json] [--quiet]
gclo orgs [--token-env VAR | --token-file PATH | --token-stdin] [--json]
@@ -67,6 +67,40 @@ broken-repo Failed remote authentication failed
repositories are marked `Canceled`, and the summary still prints. A second
Ctrl+C aborts the process immediately.
#### Windows-invalid paths and `--sanitize-paths`
git happily stores paths that no Windows file system can hold: reserved device
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):
```
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 docs/spec?.md (contains a character that is invalid on Windows)
```
With `--sanitize-paths`, gclo checks such a repository out anyway:
- Each offending path is renamed on disk to a safe suggested name
(`aux` → `aux_`, `spec?.md` → `spec_.md`).
- Paths with no safe automatic rename (case-only collisions) are **skipped**:
they are not materialized on disk.
- A note listing the renames goes to stderr and to the activity log, and the
repository counts as **cloned** — the exit code stays `0` and `--json` counts
it under `cloned`. The human summary calls the count out:
`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.
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
local changes. Treat a sanitized repository as a read-only mirror.
#### `--json`
Suppresses all progress lines and prints a single line of JSON to stdout when the
@@ -115,6 +149,9 @@ stderr and exits with code 2.
| 1 | The run completed, but some repositories failed or the run was canceled (Ctrl+C). |
| 2 | Fatal: bad arguments, missing/empty/rejected token, or organization not found. |
A repository recovered by `--sanitize-paths` counts as a success: if every other
repository also succeeds, the exit code is `0`.
## Examples
### PowerShell
@@ -142,6 +179,9 @@ if ($result.failed -gt 0) { $result.failures | ForEach-Object { "$($_.repo): $($
# Nightly mirror job: quiet, check the exit code
gclo sync --org contoso --target D:\mirror\contoso --parallel 16 --quiet
if ($LASTEXITCODE -ne 0) { Write-Error "sync ended with code $LASTEXITCODE" }
# Legacy repos with Windows-invalid paths: rename/skip them instead of failing
gclo sync --org contoso --target C:\src\contoso --sanitize-paths
```
### bash
+191 -10
View File
@@ -18,6 +18,14 @@ internal static class SyncCommand
--target <folder> Local root folder; each repository becomes a subfolder
(required; created if missing).
--parallel <N> Maximum simultaneous git operations (default 8).
--sanitize-paths When a repository contains paths that are legal in
git but invalid on Windows (reserved device names,
characters like ':', trailing dots or spaces),
rename them to safe suggested names instead of
failing the repository. Paths with no safe rename
(case-only collisions) are skipped. The renames are
listed on stderr and in the activity log, and the
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.
@@ -34,6 +42,11 @@ internal static class SyncCommand
statuses Queued, Cloning, Pulling, Done, Canceled on stdout and
'<name> Failed <error>' on stderr. Clone percent updates are not printed.
A repository whose paths cannot be created on Windows fails with the
offending paths listed on stderr (at most 10, plus a count of the
rest). With --sanitize-paths it is checked out with those paths
renamed or skipped instead and counts as cloned.
Ctrl+C cancels gracefully: in-flight git operations stop, remaining
repositories are marked Canceled, and the summary still prints.
@@ -55,6 +68,7 @@ internal static class SyncCommand
int parallel = 8;
bool json = false;
bool quiet = false;
bool sanitizePaths = false;
var tokenOptions = new TokenOptions();
var reader = new OptionReader(args);
@@ -92,6 +106,10 @@ internal static class SyncCommand
reader.RejectValue();
quiet = true;
break;
case "--sanitize-paths":
reader.RejectValue();
sanitizePaths = true;
break;
default:
throw new CliUsageException($"Unknown option '{reader.Current}' for 'gclo sync'.");
}
@@ -109,21 +127,24 @@ internal static class SyncCommand
// One activity log per invocation. Only non-secret parameters are ever
// written to it; the token is not.
var log = new FileActivityLog();
log.Info($"sync started: org='{org}', target='{target}', parallel={parallel}, json={json}, quiet={quiet}");
log.Info($"sync started: org='{org}', target='{target}', parallel={parallel}, "
+ $"sanitizePaths={sanitizePaths}, json={json}, quiet={quiet}");
try
{
string token = tokenOptions.Resolve();
var printer = new ProgressPrinter(printProgress: !quiet && !json, printFailures: !json);
var engine = new OrgSyncEngine(new GitHubRepositoryLister(), new LibGit2GitClient());
var git = new LibGit2GitClient();
var engine = new OrgSyncEngine(new GitHubRepositoryLister(), git);
var request = new SyncRequest(org.Trim(), token, target.Trim(), parallel);
var progress = new SyncProgressHandler(printer, log, printDetails: !json, collectForSanitize: sanitizePaths);
SyncSummary summary;
try
{
summary = await engine
.SyncAsync(request, new FailureLoggingProgress(printer, log), cancellationToken)
.SyncAsync(request, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -142,9 +163,27 @@ internal static class SyncCommand
throw new CliErrorException($"Cannot use target folder '{target}': {ex.Message}", ex);
}
// --sanitize-paths: repositories whose only problem was Windows-invalid
// paths were held back by the progress handler; retry each with an
// automatic recovery. A recovered repository counts as cloned.
int sanitized = 0;
foreach (PendingRecovery pending in progress.DrainPendingRecoveries())
{
if (await TrySanitizeAsync(git, progress, log, target.Trim(), token, pending, cancellationToken)
.ConfigureAwait(false))
{
sanitized++;
}
}
if (sanitized > 0)
{
summary = summary with { Cloned = summary.Cloned + sanitized, Failed = summary.Failed - sanitized };
}
log.Info(
$"sync finished: total={summary.Total}, cloned={summary.Cloned}, updated={summary.Updated}, "
+ $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}");
+ $"failed={summary.Failed}, canceled={summary.Canceled}, wasCanceled={summary.WasCanceled}"
+ (sanitizePaths ? $", sanitizedPaths={sanitized}" : ""));
if (json)
{
@@ -156,8 +195,11 @@ internal static class SyncCommand
else
{
string verb = summary.WasCanceled ? "Canceled" : "Finished";
string clonedText = sanitized > 0
? $"{summary.Cloned} cloned ({sanitized} with sanitized paths)"
: $"{summary.Cloned} cloned";
Console.Out.WriteLine(
$"{verb}: {summary.Cloned} cloned, {summary.Updated} updated, "
$"{verb}: {clonedText}, {summary.Updated} updated, "
+ $"{summary.Failed} failed, {summary.Canceled} canceled of {summary.Total}.");
}
@@ -172,29 +214,168 @@ internal static class SyncCommand
}
}
/// <summary>
/// Applies an automatic <see cref="PathRecovery"/> (rename to the suggested name,
/// skip what has none) to one repository that failed with Windows-invalid paths.
/// Reports the real outcome — Done or Failed — through <paramref name="progress"/>,
/// which by now forwards to the console again.
/// </summary>
private static async Task<bool> TrySanitizeAsync(
IGitClient git,
SyncProgressHandler progress,
IActivityLog log,
string targetRoot,
string token,
PendingRecovery pending,
CancellationToken cancellationToken)
{
string path = Path.Combine(targetRoot, pending.Repo);
try
{
cancellationToken.ThrowIfCancellationRequested();
PathRecovery recovery = BuildRecovery(pending.Paths);
await git.ApplyRecoveryAsync(path, token, recovery, cancellationToken).ConfigureAwait(false);
string detail = DescribeRecovery(recovery);
Console.Error.WriteLine(
$"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, "
+ $"{recovery.SkippedPaths.Count} skipped: {detail}");
log.Info($"{pending.Repo}: cloned with sanitized paths: {detail}");
progress.Report(new RepoProgress(pending.Repo, SyncStatus.Done));
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Canceled before this repo could be sanitized: surface the original failure.
progress.Report(new RepoProgress(
pending.Repo, SyncStatus.Failed, pending.Error, InvalidPaths: pending.Paths));
return false;
}
catch (Exception ex)
{
progress.Report(new RepoProgress(
pending.Repo, SyncStatus.Failed, $"path sanitization failed: {ex.Message}",
InvalidPaths: (ex as InvalidRepositoryPathsException)?.Paths));
return false;
}
}
/// <summary>
/// Builds the automatic recovery: each offending path is renamed to its suggested
/// name; paths without one (e.g. case-only collisions, which a rename to the same
/// suggestion could not resolve) are skipped entirely.
/// </summary>
private static PathRecovery BuildRecovery(IReadOnlyList<InvalidPathInfo> paths)
{
// Skip wins: a path that also has a suggestion-less problem is not materialized.
var skipped = new HashSet<string>(
paths.Where(p => string.IsNullOrEmpty(p.SuggestedName)).Select(p => p.RepoPath),
StringComparer.Ordinal);
var renames = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (InvalidPathInfo info in paths)
{
if (info.SuggestedName is not { Length: > 0 } suggestion || skipped.Contains(info.RepoPath))
{
continue;
}
// The suggestion replaces the last segment — the validator reports each
// offending segment as a repo path ending in that segment.
int slash = info.RepoPath.LastIndexOf('/');
renames[info.RepoPath] = slash < 0 ? suggestion : info.RepoPath[..(slash + 1)] + suggestion;
}
return new PathRecovery(renames, skipped);
}
private static string DescribeRecovery(PathRecovery recovery)
=> string.Join("; ", recovery.SegmentRenames
.Select(r => $"'{r.Key}' -> '{r.Value}'")
.Concat(recovery.SkippedPaths.Select(s => $"skipped '{s}'")));
/// <summary>One repository held back from failure reporting for a sanitization attempt.</summary>
private sealed record PendingRecovery(string Repo, string Error, IReadOnlyList<InvalidPathInfo> Paths);
/// <summary>
/// Forwards every engine report to the console printer unchanged and mirrors
/// failures into the activity log, so console output stays exactly what the
/// printer alone would have produced.
/// printer alone would have produced — with two invalid-path additions: failure
/// details (the offending paths, up to 10) follow the failure line on stderr,
/// and with --sanitize-paths such failures are collected for a recovery attempt
/// instead of being reported at all.
/// </summary>
private sealed class FailureLoggingProgress : IProgress<RepoProgress>
private sealed class SyncProgressHandler : IProgress<RepoProgress>
{
private const int MaxListedPaths = 10;
private readonly object _gate = new();
private readonly ProgressPrinter _printer;
private readonly IActivityLog _log;
private readonly bool _printDetails;
private readonly List<PendingRecovery> _pending = new();
private bool _collectForSanitize;
public FailureLoggingProgress(ProgressPrinter printer, IActivityLog log)
/// <param name="printDetails">Write offending-path lines to stderr (off for --json, which carries the reason in the document).</param>
/// <param name="collectForSanitize">Hold invalid-path failures back for --sanitize-paths instead of reporting them.</param>
public SyncProgressHandler(ProgressPrinter printer, IActivityLog log, bool printDetails, bool collectForSanitize)
{
_printer = printer;
_log = log;
_printDetails = printDetails;
_collectForSanitize = collectForSanitize;
}
/// <summary>
/// Returns the failures held back for sanitization and stops collecting:
/// from here on Failed reports (the recovery attempts' outcomes) print normally.
/// </summary>
public IReadOnlyList<PendingRecovery> DrainPendingRecoveries()
{
lock (_gate)
{
_collectForSanitize = false;
PendingRecovery[] drained = _pending.ToArray();
_pending.Clear();
return drained;
}
}
// The engine reports from worker threads; the lock keeps a failure line and
// its detail lines together.
public void Report(RepoProgress value)
{
if (value.Status == SyncStatus.Failed)
lock (_gate)
{
if (value.Status != SyncStatus.Failed)
{
_printer.Report(value);
return;
}
if (_collectForSanitize && value.InvalidPaths is { Count: > 0 })
{
_pending.Add(new PendingRecovery(
value.RepoName, value.Error ?? "invalid paths", value.InvalidPaths));
return;
}
_log.Error($"{value.RepoName} failed: {value.Error ?? "unknown error"}");
_printer.Report(value);
if (_printDetails && value.InvalidPaths is { Count: > 0 })
{
// The WHY, for scripts: the failure message itself only carries
// the first three offending paths.
foreach (InvalidPathInfo info in value.InvalidPaths.Take(MaxListedPaths))
{
Console.Error.WriteLine($"{value.RepoName} {info.RepoPath} ({info.Reason})");
}
if (value.InvalidPaths.Count > MaxListedPaths)
{
Console.Error.WriteLine(
$"{value.RepoName} ... and {value.InvalidPaths.Count - MaxListedPaths} more invalid paths");
}
}
}
_printer.Report(value);
}
}
}
+13
View File
@@ -29,6 +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);
/// <summary>
/// An <see cref="IGitClient"/> whose behavior is configured with delegates.
@@ -38,6 +39,7 @@ public sealed class FakeGitClient : IGitClient
{
private readonly ConcurrentQueue<CloneCall> _cloneCalls = new();
private readonly ConcurrentQueue<PullCall> _pullCalls = new();
private readonly ConcurrentQueue<ApplyRecoveryCall> _applyRecoveryCalls = new();
private readonly ConcurrentQueue<string> _validityChecks = new();
/// <summary>Decides whether a local path counts as an existing valid repository. Default: never.</summary>
@@ -51,8 +53,13 @@ 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;
public IReadOnlyList<CloneCall> CloneCalls => _cloneCalls.ToArray();
public IReadOnlyList<PullCall> PullCalls => _pullCalls.ToArray();
public IReadOnlyList<ApplyRecoveryCall> ApplyRecoveryCalls => _applyRecoveryCalls.ToArray();
public IReadOnlyList<string> ValidityChecks => _validityChecks.ToArray();
/// <summary>Repo folder names (last path segment) passed to <see cref="CloneAsync"/>.</summary>
@@ -80,6 +87,12 @@ public sealed class FakeGitClient : IGitClient
_pullCalls.Enqueue(new PullCall(path, token));
await FetchAndPullHandler(path, token, cancellationToken).ConfigureAwait(false);
}
public async Task ApplyRecoveryAsync(string path, string token, PathRecovery recovery, CancellationToken cancellationToken)
{
_applyRecoveryCalls.Enqueue(new ApplyRecoveryCall(path, token, recovery));
await ApplyRecoveryHandler(path, token, recovery, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>An <see cref="IOrganizationLister"/> whose behavior is configured with a delegate.</summary>
+8 -4
View File
@@ -58,11 +58,13 @@ public sealed class LibGit2GitClientTests : IDisposable
}
[Fact]
public async Task Clone_WindowsInvalidPath_ThrowsTypedExceptionAndCleansUp()
public async Task Clone_WindowsInvalidPath_ThrowsTypedExceptionAndKeepsRepoWithPendingMarker()
{
// A tree entry whose name is invalid on Windows must surface as the structured
// InvalidRepositoryPathsException (not a raw libgit2 error), after the fetch has
// already written read-only pack files — exercising the attribute-clearing delete.
// InvalidRepositoryPathsException (not a raw libgit2 error). Unlike other clone
// failures, the fetched repository is KEPT — everything is already downloaded,
// so path recovery can run without hitting the network — and marked as
// checkout-pending so a later pull cannot mistake it for an up-to-date clone.
string source = CreateRepoWithWindowsInvalidFileName();
string target = NewPath("partial-clone");
@@ -73,7 +75,9 @@ public sealed class LibGit2GitClientTests : IDisposable
Assert.Equal("bad:name.txt", info.RepoPath);
Assert.Contains("invalid on Windows", info.Reason);
Assert.Equal("bad_name.txt", info.SuggestedName);
Assert.False(Directory.Exists(target));
Assert.True(_client.IsValidRepository(target));
using var repo = new Repository(target);
Assert.True(repo.Config.Get<bool>("gclo.checkoutpending")?.Value);
}
[Fact]
+193
View File
@@ -260,6 +260,199 @@ public sealed class MainViewModelTests
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- path recovery
private static readonly IReadOnlyList<InvalidPathInfo> SampleInvalidPaths =
[
new InvalidPathInfo("aux", "'aux' is a reserved Windows device name", "aux_"),
new InvalidPathInfo("docs/bad:name.txt", "contains a character that is invalid on Windows", "bad_name.txt"),
];
/// <summary>Clones of 'alpha' fail with <see cref="SampleInvalidPaths"/>; everything else succeeds.</summary>
private void MakeAlphaFailWithInvalidPaths()
=> _git.CloneHandler = (url, path, token, onProgress, ct) =>
Path.GetFileName(path) == "alpha"
? Task.FromException(new InvalidRepositoryPathsException(SampleInvalidPaths))
: Task.CompletedTask;
[Fact]
public async Task Sync_InvalidPathFailure_FlowsPayloadToRow_AndEnablesResolve()
{
MakeAlphaFailWithInvalidPaths();
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
await vm.SyncCommand.ExecuteAsync(null);
var byName = vm.Repos.ToDictionary(r => r.Name);
Assert.Equal(SyncStatus.Failed, byName["alpha"].Status);
Assert.True(byName["alpha"].HasPathIssue);
Assert.Equal(SampleInvalidPaths, byName["alpha"].InvalidPaths);
Assert.Equal(SyncStatus.Done, byName["bravo"].Status);
Assert.False(byName["bravo"].HasPathIssue);
Assert.Null(byName["bravo"].InvalidPaths);
Assert.True(vm.ResolvePathsCommand.CanExecute(byName["alpha"]));
Assert.False(vm.ResolvePathsCommand.CanExecute(byName["bravo"])); // no path issue
Assert.False(vm.ResolvePathsCommand.CanExecute(null));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task ResolvePaths_AppliesRecovery_AndMarksRowDone()
{
MakeAlphaFailWithInvalidPaths();
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel row = vm.Repos[0];
var recovery = new PathRecovery(
new Dictionary<string, string> { ["aux"] = "aux_" },
new HashSet<string> { "docs/bad:name.txt" });
var asked = new List<RepoItemViewModel>();
vm.RecoveryInteraction = item =>
{
asked.Add(item);
return Task.FromResult<PathRecovery?>(recovery);
};
await vm.ResolvePathsCommand.ExecuteAsync(row);
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);
Assert.Null(row.Error);
Assert.Null(row.InvalidPaths);
Assert.False(row.HasPathIssue);
Assert.False(vm.ResolvePathsCommand.CanExecute(row)); // nothing left to resolve
Assert.False(vm.RetryFailedCommand.CanExecute(null)); // nothing failed anymore
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task ResolvePaths_InteractionReturnsNull_LeavesRowFailed()
{
MakeAlphaFailWithInvalidPaths();
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel row = vm.Repos[0];
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(null);
await vm.ResolvePathsCommand.ExecuteAsync(row);
Assert.Empty(_git.ApplyRecoveryCalls); // canceled: nothing applied
Assert.Equal(SyncStatus.Failed, row.Status);
Assert.True(row.HasPathIssue); // payload kept for another attempt
Assert.True(vm.ResolvePathsCommand.CanExecute(row));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task ResolvePaths_ApplyStillInvalid_KeepsRowFailed_WithFreshPayload()
{
MakeAlphaFailWithInvalidPaths();
var stillInvalid = new List<InvalidPathInfo>
{
new("aux_", "differs only by case from 'AUX_'", null),
};
_git.ApplyRecoveryHandler = (_, _, _, _) =>
Task.FromException(new InvalidRepositoryPathsException(stillInvalid));
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel row = vm.Repos[0];
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
await vm.ResolvePathsCommand.ExecuteAsync(row);
Assert.Single(_git.ApplyRecoveryCalls);
Assert.Equal(SyncStatus.Failed, row.Status);
Assert.Contains("aux_", row.Error);
Assert.Equal(stillInvalid, row.InvalidPaths); // the fresh list, ready for another round
Assert.True(vm.ResolvePathsCommand.CanExecute(row));
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task ResolvePaths_ApplyFailsForAnotherReason_ClearsPayload()
{
MakeAlphaFailWithInvalidPaths();
_git.ApplyRecoveryHandler = (_, _, _, _) =>
Task.FromException(new IOException("disk full"));
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel row = vm.Repos[0];
vm.RecoveryInteraction = _ => Task.FromResult<PathRecovery?>(
new PathRecovery(new Dictionary<string, string>(), new HashSet<string>()));
await vm.ResolvePathsCommand.ExecuteAsync(row);
Assert.Equal(SyncStatus.Failed, row.Status);
Assert.Equal("disk full", row.Error);
Assert.False(row.HasPathIssue); // renaming again would not help
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task ResolvePaths_DisabledWhileARunIsInFlight()
{
MakeAlphaFailWithInvalidPaths();
var vm = await CreateLoadedViewModelAsync(Repo("alpha"), Repo("bravo"));
await vm.SyncCommand.ExecuteAsync(null);
var byName = vm.Repos.ToDictionary(r => r.Name);
RepoItemViewModel alpha = byName["alpha"];
Assert.True(vm.ResolvePathsCommand.CanExecute(alpha));
// Second run touches only bravo, so alpha keeps its payload throughout.
alpha.IsSelected = false;
var gate = new TaskCompletionSource();
int started = 0;
_git.CloneHandler = async (url, path, token, onProgress, ct) =>
{
Interlocked.Increment(ref started);
await gate.Task.WaitAsync(TimeSpan.FromSeconds(30));
};
Task run = vm.SyncCommand.ExecuteAsync(null);
await WaitUntilAsync(() => started > 0, "second run to start");
Assert.True(alpha.HasPathIssue);
Assert.False(vm.ResolvePathsCommand.CanExecute(alpha)); // locked while running
gate.SetResult();
await run;
Assert.True(vm.ResolvePathsCommand.CanExecute(alpha)); // unlocked again
Directory.Delete(vm.TargetFolder, recursive: true);
}
[Fact]
public async Task Sync_ReRun_ClearsAStalePathPayload()
{
MakeAlphaFailWithInvalidPaths();
var vm = await CreateLoadedViewModelAsync(Repo("alpha"));
await vm.SyncCommand.ExecuteAsync(null);
RepoItemViewModel row = vm.Repos[0];
Assert.True(row.HasPathIssue);
_git.CloneHandler = (_, _, _, _, _) => Task.CompletedTask; // upstream fixed the paths
await vm.SyncCommand.ExecuteAsync(null);
Assert.Equal(SyncStatus.Done, row.Status);
Assert.Null(row.InvalidPaths);
Assert.False(row.HasPathIssue);
Assert.False(vm.ResolvePathsCommand.CanExecute(row));
Directory.Delete(vm.TargetFolder, recursive: true);
}
// ---------------------------------------------------------------- selection
[Fact]
+404
View File
@@ -0,0 +1,404 @@
using System.Text;
using gclo.Engine;
using LibGit2Sharp;
namespace gclo.Engine.Tests;
/// <summary>
/// Tests for path recovery: a clone whose tree contains Windows-invalid paths keeps
/// the fetched repository (marked checkout-pending) instead of deleting it, and
/// <see cref="IGitClient.ApplyRecoveryAsync"/> materializes the working tree manually
/// with user-chosen renames and skips applied. The recovery persists under .git and
/// is re-applied by later pulls. Offending tree entries are forged through the object
/// database — they can never be created on a Windows working tree directly.
/// </summary>
public sealed class PathRecoveryTests : IDisposable
{
private const string Token = "test-token";
private const string PendingKey = "gclo.checkoutpending";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly LibGit2GitClient _client = new();
public PathRecoveryTests()
{
Directory.CreateDirectory(_root);
}
public void Dispose() => TryDeleteDirectory(_root);
// ---------------------------------------------------------------- clone marker
[Fact]
public async Task Clone_InvalidPaths_KeepsFetchedRepo_SetsPendingMarker_AndThrowsTyped()
{
string source = CreateForgedRepo(("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = NewPath("marker-clone");
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "bad:file.txt");
Assert.True(_client.IsValidRepository(target));
Assert.True(IsCheckoutPending(target));
// Nothing may have been checked out, and no recovery exists yet.
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
Assert.False(File.Exists(RecoveryFilePath(target)));
}
// ---------------------------------------------------------------- apply recovery
[Fact]
public async Task ApplyRecovery_RenamedFile_MaterializesContentUnderNewName()
{
string source = CreateForgedRepo(("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);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("bad content", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
}
[Fact]
public async Task ApplyRecovery_RenamedDirectory_RelocatesItsWholeSubtree()
{
string source = CreateForgedRepo(
("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);
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_SkippedPaths_OmitFilesAndWholeDirectories()
{
string source = CreateForgedRepo(
("bad:one.txt", "unwanted"),
("good.txt", "kept"),
("junk/a.txt", "junk a"),
("junk/sub/b.txt", "junk b"),
("docs/keep.txt", "docs kept"));
string target = await CloneExpectingInvalidPathsAsync(source, "skip-apply");
// Skip the invalid file outright and a whole (valid) directory subtree.
var recovery = Recovery(skips: ["bad:one.txt", "junk"]);
await _client.ApplyRecoveryAsync(target, Token, recovery, CancellationToken.None);
Assert.Equal("kept", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("docs kept", File.ReadAllText(Path.Combine(target, "docs", "keep.txt")));
Assert.False(Directory.Exists(Path.Combine(target, "junk")));
Assert.False(IsCheckoutPending(target));
}
[Fact]
public async Task ApplyRecovery_MappingStillInvalid_ThrowsListingEffectivePaths_AndWritesNothing()
{
string source = CreateForgedRepo(("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));
// The exception lists the EFFECTIVE (post-mapping) path, not the original.
Assert.Contains(ex.Paths, p => p.RepoPath == "still:bad.txt");
// Validation runs before any write: even the valid file must not appear,
// the marker stays pending, and no recovery is persisted.
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
Assert.True(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
}
[Fact]
public async Task ApplyRecovery_TwoOriginalsMappedToOneDestination_Throws()
{
string source = CreateForgedRepo(("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));
Assert.Contains(ex.Paths, p => p.RepoPath == "merged.txt");
Assert.False(File.Exists(Path.Combine(target, "merged.txt")));
}
[Fact]
public async Task ApplyRecovery_PersistsRecoveryJsonUnderDotGit_AndClearsMarker()
{
string source = CreateForgedRepo(("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);
Assert.False(IsCheckoutPending(target));
string json = File.ReadAllText(RecoveryFilePath(target));
Assert.Contains("bad:file.txt", json);
Assert.Contains("bad_file.txt", json);
}
// ---------------------------------------------------------------- pull semantics
[Fact]
public async Task FetchAndPull_MarkerRepoWithoutRecovery_RethrowsTypedException()
{
string source = CreateForgedRepo(("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "marker-pull");
// The repo must not silently report success with an empty working tree.
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "bad:file.txt");
Assert.True(IsCheckoutPending(target));
}
[Fact]
public async Task FetchAndPull_MarkerRepoUpstreamFixedPaths_ChecksOutAndClearsMarker()
{
string source = CreateForgedRepo(("good.txt", "good content"), ("bad:file.txt", "bad content"));
string target = await CloneExpectingInvalidPathsAsync(source, "fixed-upstream");
ReplaceForgedEntry(source, remove: "bad:file.txt", add: ("fixed.txt", "fixed content"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("good content", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal("fixed content", File.ReadAllText(Path.Combine(target, "fixed.txt")));
Assert.False(IsCheckoutPending(target));
Assert.Equal(HeadSha(source), HeadSha(target));
}
[Fact]
public async Task FetchAndPull_WithStoredRecovery_RematerializesNewCommitsThroughMapping()
{
string source = CreateForgedRepo(("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);
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
// Upstream rewrites the still-invalid file; the pull must land the new
// content at the mapped name.
string newSha = AppendForgedCommit(source, ("bad:file.txt", "v2"));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.Equal("v2", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
Assert.Equal("g1", File.ReadAllText(Path.Combine(target, "good.txt")));
Assert.Equal(newSha, HeadSha(target));
}
[Fact]
public async Task FetchAndPull_WithStoredRecovery_NewInvalidPathNotCovered_Throws()
{
string source = CreateForgedRepo(("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);
AppendForgedCommit(source, ("worse:new.txt", "not covered"));
var ex = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.FetchAndPullAsync(target, Token, CancellationToken.None));
Assert.Contains(ex.Paths, p => p.RepoPath == "worse:new.txt");
// The previously materialized working tree is untouched.
Assert.Equal("v1", File.ReadAllText(Path.Combine(target, "bad_file.txt")));
}
[Fact]
public async Task CloneAndPull_ValidRepo_NeverWritesMarkerOrRecovery()
{
string source = CreateForgedRepo(("readme.txt", "hello"));
string target = NewPath("normal");
await _client.CloneAsync(source, target, Token, null, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
await _client.FetchAndPullAsync(target, Token, CancellationToken.None);
Assert.False(IsCheckoutPending(target));
Assert.False(File.Exists(RecoveryFilePath(target)));
Assert.Equal("hello", File.ReadAllText(Path.Combine(target, "readme.txt")));
}
// ---------------------------------------------------------------- engine payload
[Fact]
public async Task OrgSync_InvalidPathFailure_AttachesInvalidPathsToFailedReport()
{
var lister = new FakeRepositoryLister
{
Repositories = [new RepoDescriptor("broken", "https://example.test/acme/broken.git", "main", IsArchived: false)],
};
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(
[new InvalidPathInfo("bad:file.txt", "contains a character that is invalid on Windows", "bad_file.txt")])),
};
var progress = new RecordingProgress();
var summary = await new OrgSyncEngine(lister, git)
.SyncAsync(new SyncRequest("acme", Token, NewPath("org-root")), progress);
Assert.Equal(1, summary.Failed);
var report = progress.LastFor("broken");
Assert.NotNull(report);
Assert.Equal(SyncStatus.Failed, report!.Status);
Assert.NotNull(report.InvalidPaths);
var info = Assert.Single(report.InvalidPaths!);
Assert.Equal("bad:file.txt", info.RepoPath);
Assert.Equal("bad_file.txt", info.SuggestedName);
}
[Fact]
public async Task OrgSync_OtherFailures_AttachNoInvalidPathsPayload()
{
var lister = new FakeRepositoryLister
{
Repositories = [new RepoDescriptor("flaky", "https://example.test/acme/flaky.git", "main", IsArchived: false)],
};
var git = new FakeGitClient
{
CloneHandler = (_, _, _, _, _) => Task.FromException(new InvalidOperationException("boom")),
};
var progress = new RecordingProgress();
await new OrgSyncEngine(lister, git)
.SyncAsync(new SyncRequest("acme", Token, NewPath("org-root-2")), progress);
var report = progress.LastFor("flaky");
Assert.NotNull(report);
Assert.Equal(SyncStatus.Failed, report!.Status);
Assert.Null(report.InvalidPaths);
}
// ---------------------------------------------------------------- fixture helpers
private string NewPath(string name) => Path.Combine(_root, name);
/// <summary>Clones a repo known to hold invalid paths, asserting the typed failure; returns the kept target.</summary>
private async Task<string> CloneExpectingInvalidPathsAsync(string source, string name)
{
string target = NewPath(name);
await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
return target;
}
private static PathRecovery Recovery(
(string From, string To)[]? renames = null,
string[]? skips = null)
=> new(
(renames ?? []).ToDictionary(r => r.From, r => r.To, StringComparer.Ordinal),
new HashSet<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)
{
using var repo = new Repository(workdir);
var head = repo.Head.Tip;
var definition = TreeDefinition.From(head.Tree).Remove(remove);
var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(add.Content)));
definition.Add(add.Name, blob, Mode.NonExecutableFile);
var tree = repo.ObjectDatabase.CreateTree(definition);
var signature = MakeSignature();
var commit = repo.ObjectDatabase.CreateCommit(
signature, signature, "forged fix", tree, [head], prettifyMessage: false);
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id);
}
private static Signature MakeSignature()
=> new("tester", "tester@example.test", DateTimeOffset.Now);
private static bool IsCheckoutPending(string repoPath)
{
using var repo = new Repository(repoPath);
return repo.Config.Get<bool>(PendingKey)?.Value == true;
}
private static string RecoveryFilePath(string repoPath)
{
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.
}
}
}
@@ -84,7 +84,7 @@ public sealed class WindowsPathValidationTests : IDisposable
// ---------------------------------------------------------------- clone behavior
[Fact]
public async Task Clone_MixedValidAndInvalidPaths_ReportsOnlyInvalidOnes_AndCleansUp()
public async Task Clone_MixedValidAndInvalidPaths_ReportsOnlyInvalidOnes_AndKeepsFetchedRepo()
{
string source = CreateForgedRepo(("good.txt", true), ("bad:name.txt", true), ("NUL.log", true));
string target = NewPath("mixed-clone");
@@ -94,7 +94,12 @@ public sealed class WindowsPathValidationTests : IDisposable
Assert.Equal(2, ex.Paths.Count);
Assert.DoesNotContain(ex.Paths, p => p.RepoPath == "good.txt");
Assert.False(Directory.Exists(target));
// The fetched repo is kept for path recovery (marked checkout-pending),
// but nothing may have been checked out.
Assert.True(_client.IsValidRepository(target));
Assert.False(File.Exists(Path.Combine(target, "good.txt")));
using var repo = new Repository(target);
Assert.True(repo.Config.Get<bool>("gclo.checkoutpending")?.Value);
}
[Fact]
+10
View File
@@ -12,4 +12,14 @@ public interface IGitClient
/// <summary>Fetches from origin and fast-forwards the current branch to its upstream.</summary>
Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken);
/// <summary>
/// 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.
/// </summary>
Task ApplyRecoveryAsync(string path, string token, PathRecovery recovery, CancellationToken cancellationToken);
}
+240 -2
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using LibGit2Sharp;
using LibGit2Sharp.Handlers;
@@ -6,6 +7,19 @@ namespace gclo.Engine;
/// <summary>Git operations implemented with LibGit2Sharp.</summary>
public sealed class LibGit2GitClient : IGitClient
{
/// <summary>
/// Local git config flag set when a clone fetched successfully but its tree failed
/// Windows path validation, so nothing was ever checked out. Cleared once a working
/// tree is materialized (normal checkout after upstream fixed the paths, or
/// <see cref="ApplyRecoveryAsync"/>).
/// </summary>
private const string CheckoutPendingKey = "gclo.checkoutpending";
/// <summary>File name (under .git) of the persisted <see cref="PathRecovery"/>.</summary>
private const string RecoveryFileName = "gclo-recovery.json";
private static readonly JsonSerializerOptions RecoveryJsonOptions = new() { WriteIndented = true };
public bool IsValidRepository(string path)
=> Directory.Exists(path) && Repository.IsValid(path);
@@ -21,6 +35,13 @@ public sealed class LibGit2GitClient : IGitClient
() => 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)
=> Task.Factory.StartNew(
() => ApplyRecovery(path, recovery, cancellationToken),
cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
private static void Clone(string url, string path, string token, Action<double>? onProgress, CancellationToken ct)
{
bool existedBefore = Directory.Exists(path);
@@ -68,6 +89,11 @@ public sealed class LibGit2GitClient : IGitClient
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);
}
@@ -80,8 +106,10 @@ public sealed class LibGit2GitClient : IGitClient
catch (Exception ex)
{
// Don't leave a half-cloned directory behind: the next run would treat a
// partial checkout as "exists locally" and try to pull it.
if (!existedBefore)
// partial checkout as "exists locally" and try to pull it. Invalid-path
// failures are the deliberate exception — the fetched repo is kept (with
// the pending marker set above) so recovery needs no re-download.
if (!existedBefore && ex is not InvalidRepositoryPathsException)
{
TryDeleteDirectory(path);
}
@@ -122,6 +150,28 @@ public sealed class LibGit2GitClient : IGitClient
ct.ThrowIfCancellationRequested();
// 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.
string recoveryFile = GetRecoveryFilePath(repo);
if (File.Exists(recoveryFile))
{
// A recovery-managed repo stays recovery-managed: re-materialize the new
// tip through the stored mapping. Freshly-invalid paths the mapping does
// not cover surface as a typed failure listing the effective paths.
AdvanceHeadToTrackedTip(repo);
ApplyRecoveryCore(repo, LoadRecovery(recoveryFile), ct);
return;
}
if (IsCheckoutPending(repo))
{
CompletePendingCheckout(repo, ct);
return;
}
if (repo.Info.IsHeadDetached)
{
throw new InvalidOperationException("HEAD is detached; fetched, but nothing was merged.");
@@ -226,6 +276,194 @@ public sealed class LibGit2GitClient : IGitClient
}
}
// ---------------------------------------------------------------- path recovery
private static void ApplyRecovery(string path, PathRecovery recovery, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(recovery);
using var repo = new Repository(path);
ApplyRecoveryCore(repo, recovery, ct);
}
/// <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.
/// </summary>
private static void ApplyRecoveryCore(Repository repo, PathRecovery recovery, CancellationToken ct)
{
repo.Config.Set("core.longpaths", true, ConfigurationLevel.Local);
var tip = repo.Head.Tip
?? throw new InvalidOperationException("Repository has no commits; nothing to materialize.");
var entries = CollectEffectiveEntries(tip.Tree, recovery);
// Validate BEFORE writing anything: a bad mapping (still-invalid segment, or
// two originals landing on one destination) must fail cleanly with the
// effective paths, leaving the working tree untouched.
var stillInvalid = WindowsPathValidator.ValidatePaths(entries.Select(e => e.EffectivePath));
if (stillInvalid.Count > 0)
{
throw new InvalidRepositoryPathsException(stillInvalid);
}
string workdir = repo.Info.WorkingDirectory;
foreach (var (originalPath, effectivePath, blob) in entries)
{
ct.ThrowIfCancellationRequested();
string fullPath = Path.Combine(workdir, effectivePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
if (File.Exists(fullPath))
{
// Force semantics: overwrite whatever is there, read-only or not.
File.SetAttributes(fullPath, FileAttributes.Normal);
}
// Filter through .gitattributes (CRLF etc.) under the ORIGINAL repo path,
// exactly as a checkout of that entry would.
using var content = blob.GetContentStream(new FilteringOptions(originalPath));
using var file = new FileStream(fullPath, FileMode.Create, FileAccess.Write);
content.CopyTo(file);
}
SaveRecovery(repo, recovery);
repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local);
}
/// <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.
/// </summary>
private static List<(string OriginalPath, string EffectivePath, Blob Blob)> CollectEffectiveEntries(
Tree tree, PathRecovery recovery)
{
var entries = new List<(string, string, Blob)>();
var stack = new Stack<(Tree Tree, string OriginalPrefix, string EffectivePrefix)>();
stack.Push((tree, "", ""));
while (stack.Count > 0)
{
var (current, originalPrefix, effectivePrefix) = stack.Pop();
foreach (var entry in current)
{
string originalPath = originalPrefix.Length == 0 ? entry.Name : $"{originalPrefix}/{entry.Name}";
if (recovery.SkippedPaths.Contains(originalPath))
{
continue; // omit the file — or the whole subtree when this is a directory
}
// Renames key on the full original path; 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}";
switch (entry.TargetType)
{
case TreeEntryTargetType.Tree:
stack.Push(((Tree)entry.Target, originalPath, effectivePath));
break;
case TreeEntryTargetType.Blob:
// Executable-bit and symlink entries are written as regular
// files: NTFS has no executable bit, and creating symlinks on
// Windows requires elevation (a symlink blob's content is its
// target path, which is still useful as a plain file).
entries.Add((originalPath, effectivePath, (Blob)entry.Target));
break;
default:
break; // GitLink (submodule): nothing to materialize
}
}
}
return entries;
}
/// <summary>
/// Finishes a clone that was interrupted by invalid paths but has no stored
/// recovery: if upstream has fixed the paths, check out normally and clear the
/// marker; otherwise rethrow the same typed failure — the repo must not silently
/// report success with an empty working tree.
/// </summary>
private static void CompletePendingCheckout(Repository repo, CancellationToken ct)
{
AdvanceHeadToTrackedTip(repo);
var tip = repo.Head.Tip;
if (tip is not null)
{
var invalidPaths = WindowsPathValidator.Validate(tip.Tree);
if (invalidPaths.Count > 0)
{
throw new InvalidRepositoryPathsException(invalidPaths);
}
ct.ThrowIfCancellationRequested();
try
{
Commands.Checkout(repo, repo.Head, new CheckoutOptions
{
CheckoutModifiers = CheckoutModifiers.Force, // nothing was ever checked out
CheckoutNotifyFlags = CheckoutNotifyFlags.Updated,
OnCheckoutNotify = (_, _) => !ct.IsCancellationRequested,
});
}
catch (UserCancelledException) when (ct.IsCancellationRequested)
{
throw new OperationCanceledException(ct);
}
}
repo.Config.Set(CheckoutPendingKey, false, ConfigurationLevel.Local);
}
/// <summary>
/// Moves the current branch's ref to its upstream tip without touching the working
/// tree. Only used for never-checked-out (marker) and recovery-managed repos, whose
/// local branch can hold no local work — so a plain ref move is safe where the
/// normal pull path would insist on a fast-forward merge.
/// </summary>
private static void AdvanceHeadToTrackedTip(Repository repo)
{
var tracked = repo.Head.TrackedBranch;
if (tracked?.Tip is null || repo.Head.Tip?.Sha == tracked.Tip.Sha)
{
return;
}
repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), tracked.Tip.Id);
}
private static bool IsCheckoutPending(Repository repo)
=> repo.Config.Get<bool>(CheckoutPendingKey)?.Value == true;
private static string GetRecoveryFilePath(Repository repo)
=> Path.Combine(repo.Info.Path, RecoveryFileName);
/// <summary>Serializable shape of <see cref="PathRecovery"/> for .git\gclo-recovery.json.</summary>
private sealed record RecoveryDocument(Dictionary<string, string>? SegmentRenames, List<string>? SkippedPaths);
private static void SaveRecovery(Repository repo, PathRecovery recovery)
{
var document = new RecoveryDocument(
new Dictionary<string, string>(recovery.SegmentRenames, StringComparer.Ordinal),
recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList());
File.WriteAllText(GetRecoveryFilePath(repo), JsonSerializer.Serialize(document, RecoveryJsonOptions));
}
private static PathRecovery LoadRecovery(string filePath)
{
var document = JsonSerializer.Deserialize<RecoveryDocument>(File.ReadAllText(filePath), RecoveryJsonOptions)
?? throw new InvalidOperationException($"Recovery file '{filePath}' is empty or malformed.");
return new PathRecovery(
new Dictionary<string, string>(document.SegmentRenames ?? new(StringComparer.Ordinal), StringComparer.Ordinal),
new HashSet<string>(document.SkippedPaths ?? [], StringComparer.Ordinal));
}
private static CredentialsHandler MakeCredentialsProvider(string token)
=> (_, _, _) => new UsernamePasswordCredentials
{
+4 -1
View File
@@ -125,7 +125,10 @@ public sealed class OrgSyncEngine
{
Interlocked.Increment(ref failed);
pending.TryRemove(repo.Name, out _);
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Failed, ex.Message));
// Windows-invalid path failures carry the offending paths so
// consumers can offer recovery (rename/skip) without re-syncing.
var invalidPaths = (ex as InvalidRepositoryPathsException)?.Paths;
progress?.Report(new RepoProgress(repo.Name, SyncStatus.Failed, ex.Message, InvalidPaths: invalidPaths));
}
}).ConfigureAwait(false);
}
+18
View File
@@ -0,0 +1,18 @@
namespace gclo.Engine;
/// <summary>
/// A user-approved plan for materializing a repository whose tree contains
/// Windows-invalid paths: rename some paths, omit others. Applied by
/// <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.
/// </param>
/// <param name="SkippedPaths">
/// Repository paths (files or whole directories) to omit from the working tree.
/// </param>
public sealed record PathRecovery(
IReadOnlyDictionary<string, string> SegmentRenames,
IReadOnlySet<string> SkippedPaths);
+11 -1
View File
@@ -5,4 +5,14 @@ namespace gclo.Engine;
/// <param name="Status">Current lifecycle state.</param>
/// <param name="Error">Human-readable failure reason when <see cref="Status"/> is <see cref="SyncStatus.Failed"/>.</param>
/// <param name="Percent">Object-transfer completion in [0,1] while cloning, when known.</param>
public sealed record RepoProgress(string RepoName, SyncStatus Status, string? Error = null, double? Percent = null);
/// <param name="InvalidPaths">
/// The offending paths when the repository failed with
/// <see cref="InvalidRepositoryPathsException"/>; null for every other failure.
/// Lets consumers offer path recovery (rename/skip) without re-running the sync.
/// </param>
public sealed record RepoProgress(
string RepoName,
SyncStatus Status,
string? Error = null,
double? Percent = null,
IReadOnlyList<InvalidPathInfo>? InvalidPaths = null);
+64
View File
@@ -64,6 +64,70 @@ public static class WindowsPathValidator
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
/// (duplicate destinations, file/directory clashes) are reported as invalid too.
/// </summary>
public static IReadOnlyList<InvalidPathInfo> ValidatePaths(IEnumerable<string> filePaths)
{
var invalid = new List<InvalidPathInfo>();
// Lowered path -> first-seen original casing, for case-only collisions.
var seenCi = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Exact-cased prefixes already segment-checked (shared directories repeat per file).
var seenExact = new HashSet<string>(StringComparer.Ordinal);
var files = new HashSet<string>(StringComparer.Ordinal);
var directories = new HashSet<string>(StringComparer.Ordinal);
foreach (string path in filePaths)
{
string[] segments = path.Split('/');
string prefix = "";
for (int i = 0; i < segments.Length; i++)
{
prefix = i == 0 ? segments[i] : $"{prefix}/{segments[i]}";
bool isLeaf = i == segments.Length - 1;
if (isLeaf)
{
if (!files.Add(path))
{
invalid.Add(new InvalidPathInfo(path, "more than one path maps to this destination", null));
break; // an exact duplicate needs no further checks
}
if (directories.Contains(path))
{
invalid.Add(new InvalidPathInfo(path, "maps to both a file and a directory", null));
}
}
else if (directories.Add(prefix) && files.Contains(prefix))
{
invalid.Add(new InvalidPathInfo(prefix, "maps to both a file and a directory", null));
}
if (seenExact.Add(prefix))
{
if (ValidateSegment(segments[i]) is { } problem)
{
invalid.Add(new InvalidPathInfo(prefix, problem.Reason, problem.Suggestion));
}
if (seenCi.TryGetValue(prefix, out string? original))
{
invalid.Add(new InvalidPathInfo(prefix, $"differs only by case from '{original}'", null));
}
else
{
seenCi[prefix] = prefix;
}
}
}
}
return invalid;
}
private static (string Reason, string? Suggestion)? ValidateSegment(string segment)
{
if (segment.Length == 0)
+65
View File
@@ -85,6 +85,7 @@ public sealed partial class MainViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(SyncCommand))]
[NotifyCanExecuteChangedFor(nameof(LoadReposCommand))]
[NotifyCanExecuteChangedFor(nameof(RetryFailedCommand))]
[NotifyCanExecuteChangedFor(nameof(ResolvePathsCommand))]
public partial bool IsRunning { get; set; }
[ObservableProperty]
@@ -264,6 +265,12 @@ public sealed partial class MainViewModel : ObservableObject
private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RepoItemViewModel.HasPathIssue))
{
// Parameterized command: the row's 'Resolve...' button re-queries CanExecute.
ResolvePathsCommand.NotifyCanExecuteChanged();
return;
}
if (e.PropertyName != nameof(RepoItemViewModel.IsSelected))
{
return;
@@ -376,6 +383,7 @@ public sealed partial class MainViewModel : ObservableObject
item.Status = SyncStatus.Queued;
item.Error = null;
item.Percent = null;
item.InvalidPaths = null;
}
RecomputeCompletedCount();
@@ -434,6 +442,62 @@ public sealed partial class MainViewModel : ObservableObject
await SyncCommand.ExecuteAsync(null);
}
// ---------------------------------------------------------------- path recovery
/// <summary>
/// Raised when the user asks to resolve a row's Windows-invalid paths. The view
/// subscribes and shows recovery UI (the VM stays UI-free), returning the user's
/// decision, or null when they cancel. With no subscriber the command does nothing.
/// </summary>
public Func<RepoItemViewModel, Task<PathRecovery?>>? RecoveryInteraction { get; set; }
private bool CanResolvePaths(RepoItemViewModel item)
=> item is not null && item.HasPathIssue && !IsRunning;
/// <summary>
/// Asks the view (via <see cref="RecoveryInteraction"/>) how to rename or skip the
/// row's invalid paths, then applies that recovery and checks the repository out.
/// </summary>
[RelayCommand(CanExecute = nameof(CanResolvePaths))]
private async Task ResolvePathsAsync(RepoItemViewModel item)
{
if (item is null || RecoveryInteraction is not { } interaction)
{
return;
}
PathRecovery? recovery = await interaction(item);
if (recovery is null)
{
return; // user canceled; the row keeps its Failed state and payload
}
string path = Path.Combine(EffectiveTargetRoot, item.Name);
try
{
_log.Info($"{item.Name}: applying path recovery "
+ $"({recovery.SegmentRenames.Count} renamed, {recovery.SkippedPaths.Count} skipped).");
await _git.ApplyRecoveryAsync(path, Token.Trim(), recovery, CancellationToken.None);
item.Status = SyncStatus.Done;
item.Error = null;
item.InvalidPaths = null;
_log.Info($"{item.Name}: path recovery applied; repository checked out.");
}
catch (Exception ex)
{
item.Status = SyncStatus.Failed;
item.Error = ex.Message;
// A still-invalid or colliding mapping keeps the row resolvable with the
// fresh path list; any other failure clears the payload (renaming again
// would not help).
item.InvalidPaths = (ex as InvalidRepositoryPathsException)?.Paths;
_log.Error($"{item.Name}: path recovery failed: {ex.Message}", ex);
}
RecomputeCompletedCount();
RetryFailedCommand.NotifyCanExecuteChanged();
}
// ---------------------------------------------------------------- sorting
/// <summary>
@@ -498,6 +562,7 @@ public sealed partial class MainViewModel : ObservableObject
item.Status = report.Status;
item.Error = report.Error;
item.Percent = report.Percent;
item.InvalidPaths = report.InvalidPaths;
}
if (report.Status == SyncStatus.Failed)
+14
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using gclo.Engine;
@@ -34,6 +35,16 @@ public sealed partial class RepoItemViewModel : ObservableObject
[ObservableProperty]
public partial double? Percent { get; set; }
/// <summary>
/// The Windows-invalid paths that made the last sync fail, when that was the cause
/// (set from the engine's failure progress payload); null for any other failure.
/// </summary>
[ObservableProperty]
public partial IReadOnlyList<InvalidPathInfo>? InvalidPaths { get; set; }
/// <summary>True when the row failed because of Windows-invalid paths and can be resolved.</summary>
public bool HasPathIssue => InvalidPaths is { Count: > 0 };
/// <summary>Default branch name, or empty for an empty repository.</summary>
public string BranchText => Descriptor.DefaultBranch ?? "";
@@ -79,4 +90,7 @@ public sealed partial class RepoItemViewModel : ObservableObject
}
partial void OnErrorChanged(string? value) => OnPropertyChanged(nameof(HasError));
partial void OnInvalidPathsChanged(IReadOnlyList<InvalidPathInfo>? value)
=> OnPropertyChanged(nameof(HasPathIssue));
}
+16 -1
View File
@@ -238,9 +238,13 @@
CommandParameter="Archived" />
</Grid>
<!-- Per-repo table body -->
<!-- Per-repo table body. The ListView carries the view model as DataContext so
the row template's Resolve link can reach MainViewModel.ResolvePathsCommand
via ElementName (x:Bind inside the template is scoped to the row item). -->
<ListView
x:Name="RepoListView"
Grid.Row="6"
DataContext="{x:Bind ViewModel, Mode=OneTime}"
ItemsSource="{x:Bind ViewModel.Repos, Mode=OneTime}"
SelectionMode="None">
<ListView.ItemContainerStyle>
@@ -256,6 +260,7 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40" />
@@ -316,6 +321,16 @@
Foreground="Crimson"
Text="{x:Bind Error, Mode=OneWay}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" />
<HyperlinkButton
Grid.Row="2"
Grid.Column="0"
Grid.ColumnSpan="5"
Padding="0"
HorizontalAlignment="Left"
Content="Resolve…"
Command="{Binding DataContext.ResolvePathsCommand, ElementName=RepoListView}"
CommandParameter="{x:Bind}"
Visibility="{x:Bind local:StatusFormat.VisibleIf(HasPathIssue), Mode=OneWay}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
+22
View File
@@ -33,6 +33,11 @@ namespace gclo
Title = "gclo — Git Clone Large Organizations";
// The view model stays UI-free: when ResolvePathsCommand needs the user's
// path-recovery choices, it calls back through here and the window answers
// with PathRecoveryDialog.
ViewModel.RecoveryInteraction = ShowPathRecoveryDialogAsync;
_settings = AppSettings.Load();
ApplySettings();
@@ -178,6 +183,23 @@ namespace gclo
}
}
/// <summary>
/// Shows <see cref="PathRecoveryDialog"/> for a repository whose checkout was
/// blocked by Windows-invalid paths. Returns the user's recovery choice, or null
/// when the dialog was dismissed (or the row carries no path details).
/// </summary>
private async Task<PathRecovery?> ShowPathRecoveryDialogAsync(RepoItemViewModel item)
{
if (item.InvalidPaths is not { Count: > 0 } paths)
{
return null;
}
var dialog = new PathRecoveryDialog(item.Name, paths) { XamlRoot = Content.XamlRoot };
await dialog.ShowAsync();
return dialog.Result;
}
private async Task ShowUpdateMessageAsync(string title, string message)
{
var dialog = new ContentDialog
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<ContentDialog
x:Class="gclo.PathRecoveryDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:gclo"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="Repository has Windows-invalid paths"
PrimaryButtonText="Apply and check out"
CloseButtonText="Skip this repo"
DefaultButton="Primary"
PrimaryButtonClick="OnPrimaryButtonClick">
<ContentDialog.Resources>
<!-- Repository paths are wide; loosen the stock 548px dialog cap. -->
<x:Double x:Key="ContentDialogMaxWidth">800</x:Double>
</ContentDialog.Resources>
<StackPanel MinWidth="480" Spacing="12">
<TextBlock x:Name="IntroText" TextWrapping="Wrap" />
<ScrollViewer MaxHeight="400" VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="RowsControl">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="local:PathRecoveryRow">
<Grid Margin="0,0,0,12" ColumnSpacing="12" RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="2"
FontFamily="Consolas"
FontWeight="SemiBold"
IsTextSelectionEnabled="True"
TextWrapping="Wrap"
Text="{x:Bind RepoPath}" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Style="{ThemeResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"
Text="{x:Bind Reason}" />
<TextBox
Grid.Row="2"
Grid.Column="0"
IsEnabled="{x:Bind RenameEnabled, Mode=OneWay}"
Text="{x:Bind NewName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox
Grid.Row="2"
Grid.Column="1"
MinWidth="0"
VerticalAlignment="Center"
Content="Skip"
IsChecked="{x:Bind Skip, Mode=TwoWay}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</ContentDialog>
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using gclo.Engine;
using Microsoft.UI.Xaml.Controls;
namespace gclo
{
/// <summary>
/// Asks the user how to recover a repository whose tree contains Windows-invalid
/// paths: rename each offending name (prefilled with the validator's suggestion when
/// there is one) or skip the file or folder entirely. <see cref="Result"/> carries
/// the chosen <see cref="PathRecovery"/> after "Apply and check out" and stays null
/// when the dialog is dismissed ("Skip this repo").
///
/// As with every ContentDialog, the caller must set <c>XamlRoot</c> before
/// <c>ShowAsync</c>.
/// </summary>
public sealed partial class PathRecoveryDialog : ContentDialog
{
private readonly List<PathRecoveryRow> _rows;
/// <summary>The user's recovery choice, or null when the dialog was dismissed.</summary>
public PathRecovery? Result { get; private set; }
public PathRecoveryDialog(string repoName, IReadOnlyList<InvalidPathInfo> paths)
{
ArgumentNullException.ThrowIfNull(repoName);
ArgumentNullException.ThrowIfNull(paths);
InitializeComponent();
IntroText.Text =
$"'{repoName}' contains paths that are legal in git but cannot be created on Windows. "
+ "Edit the replacement name for each entry, or mark it Skip to leave it out of the checkout.";
_rows = new List<PathRecoveryRow>(paths.Count);
foreach (InvalidPathInfo path in paths)
{
_rows.Add(new PathRecoveryRow(path));
}
RowsControl.ItemsSource = _rows;
}
private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
// Git paths are case-sensitive, so both collections compare ordinally. The same
// repo path can appear in two rows (e.g. an invalid segment that also collides
// by case), which is why renames assigns via the indexer instead of Add.
var renames = new Dictionary<string, string>(StringComparer.Ordinal);
var skipped = new HashSet<string>(StringComparer.Ordinal);
foreach (PathRecoveryRow row in _rows)
{
if (row.Skip)
{
skipped.Add(row.RepoPath);
}
else if (row.BuildReplacementPath() is { } replacement)
{
renames[row.RepoPath] = replacement;
}
}
Result = new PathRecovery(renames, skipped);
}
}
/// <summary>
/// One offending path in <see cref="PathRecoveryDialog"/>: the original repo path and
/// reason (read-only), plus the user's replacement name for the offending segment and
/// the per-row Skip choice. <see cref="InvalidPathInfo.RepoPath"/> always ends with
/// the offending segment, so the rename applies to the last segment only.
/// </summary>
public sealed partial class PathRecoveryRow : ObservableObject
{
/// <summary>Repo path up to the offending segment: "" or "dir/sub/" (trailing slash).</summary>
private readonly string _parentPrefix;
/// <summary>The offending (last) segment of <see cref="RepoPath"/>.</summary>
private readonly string _originalSegment;
/// <summary>Initial TextBox value: the validator's suggestion, or the original segment.</summary>
private readonly string _prefill;
internal PathRecoveryRow(InvalidPathInfo info)
{
RepoPath = info.RepoPath;
Reason = info.Reason;
int slash = info.RepoPath.LastIndexOf('/');
_parentPrefix = slash < 0 ? "" : info.RepoPath[..(slash + 1)];
_originalSegment = info.RepoPath[(slash + 1)..];
_prefill = info.SuggestedName ?? _originalSegment;
NewName = _prefill;
}
/// <summary>Full repo path (forward slashes) of the offending file or folder.</summary>
public string RepoPath { get; }
/// <summary>Why the path cannot be created on Windows.</summary>
public string Reason { get; }
/// <summary>Replacement name for the offending segment, edited by the user.</summary>
[ObservableProperty]
public partial string NewName { get; set; }
/// <summary>When set, the whole file or folder is omitted from the checkout.</summary>
[ObservableProperty]
public partial bool Skip { get; set; }
/// <summary>The rename box is only editable while the row is not skipped.</summary>
public bool RenameEnabled => !Skip;
partial void OnSkipChanged(bool value) => OnPropertyChanged(nameof(RenameEnabled));
/// <summary>
/// Full replacement repo path for a non-skipped row, or null when the name is
/// unchanged and no rename is needed. An emptied TextBox falls back to the
/// prefill; the engine re-validates the effective path set on apply, so a
/// still-invalid manual edit surfaces as a fresh set of invalid paths.
/// </summary>
internal string? BuildReplacementPath()
{
string segment = string.IsNullOrWhiteSpace(NewName) ? _prefill : NewName;
return segment == _originalSegment ? null : _parentPrefix + segment;
}
}
}