diff --git a/docs/CLI.md b/docs/CLI.md index 29d929a..1d2169b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -24,7 +24,7 @@ and runs on Linux/macOS hosts with the .NET 10 SDK. ## Commands ``` -gclo sync --org --target [--parallel N] +gclo sync --org --target [--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 diff --git a/gclo.Cli/SyncCommand.cs b/gclo.Cli/SyncCommand.cs index c088a6f..7baa787 100644 --- a/gclo.Cli/SyncCommand.cs +++ b/gclo.Cli/SyncCommand.cs @@ -18,6 +18,14 @@ internal static class SyncCommand --target Local root folder; each repository becomes a subfolder (required; created if missing). --parallel 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 Read the token from environment variable VAR (default: GITHUB_TOKEN when no token option is given). --token-file Read the token from the first line of a file. @@ -34,6 +42,11 @@ internal static class SyncCommand statuses Queued, Cloning, Pulling, Done, Canceled on stdout and ' Failed ' 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 } } + /// + /// Applies an automatic (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 , + /// which by now forwards to the console again. + /// + private static async Task 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; + } + } + + /// + /// 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. + /// + private static PathRecovery BuildRecovery(IReadOnlyList paths) + { + // Skip wins: a path that also has a suggestion-less problem is not materialized. + var skipped = new HashSet( + paths.Where(p => string.IsNullOrEmpty(p.SuggestedName)).Select(p => p.RepoPath), + StringComparer.Ordinal); + + var renames = new Dictionary(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}'"))); + + /// One repository held back from failure reporting for a sanitization attempt. + private sealed record PendingRecovery(string Repo, string Error, IReadOnlyList Paths); + /// /// 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. /// - private sealed class FailureLoggingProgress : IProgress + private sealed class SyncProgressHandler : IProgress { + 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 _pending = new(); + private bool _collectForSanitize; - public FailureLoggingProgress(ProgressPrinter printer, IActivityLog log) + /// Write offending-path lines to stderr (off for --json, which carries the reason in the document). + /// Hold invalid-path failures back for --sanitize-paths instead of reporting them. + public SyncProgressHandler(ProgressPrinter printer, IActivityLog log, bool printDetails, bool collectForSanitize) { _printer = printer; _log = log; + _printDetails = printDetails; + _collectForSanitize = collectForSanitize; } + /// + /// Returns the failures held back for sanitization and stops collecting: + /// from here on Failed reports (the recovery attempts' outcomes) print normally. + /// + public IReadOnlyList 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); } } } diff --git a/gclo.Engine.Tests/Fakes.cs b/gclo.Engine.Tests/Fakes.cs index 9058750..626e757 100644 --- a/gclo.Engine.Tests/Fakes.cs +++ b/gclo.Engine.Tests/Fakes.cs @@ -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); /// /// An whose behavior is configured with delegates. @@ -38,6 +39,7 @@ public sealed class FakeGitClient : IGitClient { private readonly ConcurrentQueue _cloneCalls = new(); private readonly ConcurrentQueue _pullCalls = new(); + private readonly ConcurrentQueue _applyRecoveryCalls = new(); private readonly ConcurrentQueue _validityChecks = new(); /// Decides whether a local path counts as an existing valid repository. Default: never. @@ -51,8 +53,13 @@ public sealed class FakeGitClient : IGitClient public Func FetchAndPullHandler { get; set; } = (_, _, _) => Task.CompletedTask; + /// Body of (path, token, recovery, ct). Default: completes immediately. + public Func ApplyRecoveryHandler { get; set; } + = (_, _, _, _) => Task.CompletedTask; + public IReadOnlyList CloneCalls => _cloneCalls.ToArray(); public IReadOnlyList PullCalls => _pullCalls.ToArray(); + public IReadOnlyList ApplyRecoveryCalls => _applyRecoveryCalls.ToArray(); public IReadOnlyList ValidityChecks => _validityChecks.ToArray(); /// Repo folder names (last path segment) passed to . @@ -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); + } } /// An whose behavior is configured with a delegate. diff --git a/gclo.Engine.Tests/LibGit2GitClientTests.cs b/gclo.Engine.Tests/LibGit2GitClientTests.cs index 310401c..ed0781e 100644 --- a/gclo.Engine.Tests/LibGit2GitClientTests.cs +++ b/gclo.Engine.Tests/LibGit2GitClientTests.cs @@ -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("gclo.checkoutpending")?.Value); } [Fact] diff --git a/gclo.Engine.Tests/MainViewModelTests.cs b/gclo.Engine.Tests/MainViewModelTests.cs index b6a2958..6f2142c 100644 --- a/gclo.Engine.Tests/MainViewModelTests.cs +++ b/gclo.Engine.Tests/MainViewModelTests.cs @@ -260,6 +260,199 @@ public sealed class MainViewModelTests Directory.Delete(vm.TargetFolder, recursive: true); } + // ---------------------------------------------------------------- path recovery + + private static readonly IReadOnlyList 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"), + ]; + + /// Clones of 'alpha' fail with ; everything else succeeds. + 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 { ["aux"] = "aux_" }, + new HashSet { "docs/bad:name.txt" }); + var asked = new List(); + vm.RecoveryInteraction = item => + { + asked.Add(item); + return Task.FromResult(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(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 + { + 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( + new PathRecovery(new Dictionary(), new HashSet())); + + 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( + new PathRecovery(new Dictionary(), new HashSet())); + + 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] diff --git a/gclo.Engine.Tests/PathRecoveryTests.cs b/gclo.Engine.Tests/PathRecoveryTests.cs new file mode 100644 index 0000000..dd40a48 --- /dev/null +++ b/gclo.Engine.Tests/PathRecoveryTests.cs @@ -0,0 +1,404 @@ +using System.Text; +using gclo.Engine; +using LibGit2Sharp; + +namespace gclo.Engine.Tests; + +/// +/// Tests for path recovery: a clone whose tree contains Windows-invalid paths keeps +/// the fetched repository (marked checkout-pending) instead of deleting it, and +/// 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. +/// +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( + () => _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( + () => _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( + () => _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( + () => _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( + () => _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); + + /// Clones a repo known to hold invalid paths, asserting the typed failure; returns the kept target. + private async Task CloneExpectingInvalidPathsAsync(string source, string name) + { + string target = NewPath(name); + await Assert.ThrowsAsync( + () => _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(skips ?? [], StringComparer.Ordinal)); + + /// Builds a repo whose single commit holds the given forged entries with per-entry content. + private string CreateForgedRepo(params (string Name, string Content)[] entries) + { + string path = NewPath("forged-" + Guid.NewGuid().ToString("N")[..8]); + Repository.Init(path); + using var repo = new Repository(path); + + var definition = new TreeDefinition(); + foreach (var (name, content) in entries) + { + var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content))); + definition.Add(name, blob, Mode.NonExecutableFile); + } + var tree = repo.ObjectDatabase.CreateTree(definition); + var signature = MakeSignature(); + var commit = repo.ObjectDatabase.CreateCommit( + signature, signature, "forged commit", tree, [], prettifyMessage: false); + repo.Refs.Add(repo.Refs.Head.TargetIdentifier, commit.Id); + return path; + } + + /// Adds a commit on top of HEAD writing (or overwriting) forged entries; returns its SHA. + private static string AppendForgedCommit(string workdir, params (string Name, string Content)[] entries) + { + using var repo = new Repository(workdir); + var head = repo.Head.Tip; + + var definition = TreeDefinition.From(head.Tree); + foreach (var (name, content) in entries) + { + var blob = repo.ObjectDatabase.CreateBlob(new MemoryStream(Encoding.UTF8.GetBytes(content))); + definition.Add(name, blob, Mode.NonExecutableFile); + } + var tree = repo.ObjectDatabase.CreateTree(definition); + var signature = MakeSignature(); + var commit = repo.ObjectDatabase.CreateCommit( + signature, signature, "forged follow-up", tree, [head], prettifyMessage: false); + repo.Refs.UpdateTarget(repo.Refs.Head.ResolveToDirectReference(), commit.Id); + return commit.Sha; + } + + /// Adds a commit that removes one forged entry and adds another — "upstream fixed the path". + private static void ReplaceForgedEntry(string workdir, string remove, (string Name, string Content) add) + { + 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(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. + } + } +} diff --git a/gclo.Engine.Tests/WindowsPathValidationTests.cs b/gclo.Engine.Tests/WindowsPathValidationTests.cs index 3e290d0..8678980 100644 --- a/gclo.Engine.Tests/WindowsPathValidationTests.cs +++ b/gclo.Engine.Tests/WindowsPathValidationTests.cs @@ -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("gclo.checkoutpending")?.Value); } [Fact] diff --git a/gclo.Engine/IGitClient.cs b/gclo.Engine/IGitClient.cs index 28f47ca..6e9808d 100644 --- a/gclo.Engine/IGitClient.cs +++ b/gclo.Engine/IGitClient.cs @@ -12,4 +12,14 @@ public interface IGitClient /// Fetches from origin and fast-forwards the current branch to its upstream. Task FetchAndPullAsync(string path, string token, CancellationToken cancellationToken); + + /// + /// Materializes the working tree of a fetched-but-never-checked-out repository + /// (one whose clone failed with ) + /// by writing HEAD's blobs to disk with 's renames + /// and skips applied. Throws when + /// the effective (post-mapping) path set is still invalid or collides; on + /// success the recovery is persisted so later pulls re-apply it to new commits. + /// + Task ApplyRecoveryAsync(string path, string token, PathRecovery recovery, CancellationToken cancellationToken); } diff --git a/gclo.Engine/LibGit2GitClient.cs b/gclo.Engine/LibGit2GitClient.cs index c60c43e..f88d025 100644 --- a/gclo.Engine/LibGit2GitClient.cs +++ b/gclo.Engine/LibGit2GitClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using LibGit2Sharp; using LibGit2Sharp.Handlers; @@ -6,6 +7,19 @@ namespace gclo.Engine; /// Git operations implemented with LibGit2Sharp. public sealed class LibGit2GitClient : IGitClient { + /// + /// 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 + /// ). + /// + private const string CheckoutPendingKey = "gclo.checkoutpending"; + + /// File name (under .git) of the persisted . + 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? 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); + } + + /// + /// 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. + /// + 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); + } + + /// + /// Walks iteratively, resolving every blob to the path it + /// should occupy on disk: skipped files and whole skipped directories are omitted, + /// and a rename of any path (file or directory) relocates it — and, for a + /// directory, its entire subtree — to the mapped destination. + /// + 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; + } + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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(CheckoutPendingKey)?.Value == true; + + private static string GetRecoveryFilePath(Repository repo) + => Path.Combine(repo.Info.Path, RecoveryFileName); + + /// Serializable shape of for .git\gclo-recovery.json. + private sealed record RecoveryDocument(Dictionary? SegmentRenames, List? SkippedPaths); + + private static void SaveRecovery(Repository repo, PathRecovery recovery) + { + var document = new RecoveryDocument( + new Dictionary(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(File.ReadAllText(filePath), RecoveryJsonOptions) + ?? throw new InvalidOperationException($"Recovery file '{filePath}' is empty or malformed."); + return new PathRecovery( + new Dictionary(document.SegmentRenames ?? new(StringComparer.Ordinal), StringComparer.Ordinal), + new HashSet(document.SkippedPaths ?? [], StringComparer.Ordinal)); + } + private static CredentialsHandler MakeCredentialsProvider(string token) => (_, _, _) => new UsernamePasswordCredentials { diff --git a/gclo.Engine/OrgSyncEngine.cs b/gclo.Engine/OrgSyncEngine.cs index 367df64..9be0077 100644 --- a/gclo.Engine/OrgSyncEngine.cs +++ b/gclo.Engine/OrgSyncEngine.cs @@ -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); } diff --git a/gclo.Engine/PathRecovery.cs b/gclo.Engine/PathRecovery.cs new file mode 100644 index 0000000..9e1244d --- /dev/null +++ b/gclo.Engine/PathRecovery.cs @@ -0,0 +1,18 @@ +namespace gclo.Engine; + +/// +/// A user-approved plan for materializing a repository whose tree contains +/// Windows-invalid paths: rename some paths, omit others. Applied by +/// instead of a normal checkout. +/// +/// +/// Maps an original repository path (forward-slash separated) to its full +/// replacement repository path (already joined, forward slashes). A directory +/// mapping relocates its whole subtree. +/// +/// +/// Repository paths (files or whole directories) to omit from the working tree. +/// +public sealed record PathRecovery( + IReadOnlyDictionary SegmentRenames, + IReadOnlySet SkippedPaths); diff --git a/gclo.Engine/RepoProgress.cs b/gclo.Engine/RepoProgress.cs index 2dfc1a6..6116868 100644 --- a/gclo.Engine/RepoProgress.cs +++ b/gclo.Engine/RepoProgress.cs @@ -5,4 +5,14 @@ namespace gclo.Engine; /// Current lifecycle state. /// Human-readable failure reason when is . /// Object-transfer completion in [0,1] while cloning, when known. -public sealed record RepoProgress(string RepoName, SyncStatus Status, string? Error = null, double? Percent = null); +/// +/// The offending paths when the repository failed with +/// ; null for every other failure. +/// Lets consumers offer path recovery (rename/skip) without re-running the sync. +/// +public sealed record RepoProgress( + string RepoName, + SyncStatus Status, + string? Error = null, + double? Percent = null, + IReadOnlyList? InvalidPaths = null); diff --git a/gclo.Engine/WindowsPathValidator.cs b/gclo.Engine/WindowsPathValidator.cs index 746e910..274d9e7 100644 --- a/gclo.Engine/WindowsPathValidator.cs +++ b/gclo.Engine/WindowsPathValidator.cs @@ -64,6 +64,70 @@ public static class WindowsPathValidator return invalid; } + /// + /// Validates a flat set of file paths (forward-slash separated) against the same + /// rules as . Used for the EFFECTIVE path set after a + /// mapping is applied, where — unlike in a git tree — + /// two source paths can also land on the same destination; those collisions + /// (duplicate destinations, file/directory clashes) are reported as invalid too. + /// + public static IReadOnlyList ValidatePaths(IEnumerable filePaths) + { + var invalid = new List(); + // Lowered path -> first-seen original casing, for case-only collisions. + var seenCi = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Exact-cased prefixes already segment-checked (shared directories repeat per file). + var seenExact = new HashSet(StringComparer.Ordinal); + var files = new HashSet(StringComparer.Ordinal); + var directories = new HashSet(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) diff --git a/gclo.ViewModels/MainViewModel.cs b/gclo.ViewModels/MainViewModel.cs index a988f5a..70ea639 100644 --- a/gclo.ViewModels/MainViewModel.cs +++ b/gclo.ViewModels/MainViewModel.cs @@ -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 + + /// + /// 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. + /// + public Func>? RecoveryInteraction { get; set; } + + private bool CanResolvePaths(RepoItemViewModel item) + => item is not null && item.HasPathIssue && !IsRunning; + + /// + /// Asks the view (via ) how to rename or skip the + /// row's invalid paths, then applies that recovery and checks the repository out. + /// + [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 /// @@ -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) diff --git a/gclo.ViewModels/RepoItemViewModel.cs b/gclo.ViewModels/RepoItemViewModel.cs index 658d012..7d619c6 100644 --- a/gclo.ViewModels/RepoItemViewModel.cs +++ b/gclo.ViewModels/RepoItemViewModel.cs @@ -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; } + /// + /// 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. + /// + [ObservableProperty] + public partial IReadOnlyList? InvalidPaths { get; set; } + + /// True when the row failed because of Windows-invalid paths and can be resolved. + public bool HasPathIssue => InvalidPaths is { Count: > 0 }; + /// Default branch name, or empty for an empty repository. 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? value) + => OnPropertyChanged(nameof(HasPathIssue)); } diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index 5d3013a..6c37a5a 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -238,9 +238,13 @@ CommandParameter="Archived" /> - + @@ -256,6 +260,7 @@ + @@ -316,6 +321,16 @@ Foreground="Crimson" Text="{x:Bind Error, Mode=OneWay}" Visibility="{x:Bind local:StatusFormat.VisibleIf(HasError), Mode=OneWay}" /> + diff --git a/gclo/MainWindow.xaml.cs b/gclo/MainWindow.xaml.cs index 4bc9e7a..683fad6 100644 --- a/gclo/MainWindow.xaml.cs +++ b/gclo/MainWindow.xaml.cs @@ -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 } } + /// + /// Shows 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). + /// + private async Task 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 diff --git a/gclo/PathRecoveryDialog.xaml b/gclo/PathRecoveryDialog.xaml new file mode 100644 index 0000000..a89ee0d --- /dev/null +++ b/gclo/PathRecoveryDialog.xaml @@ -0,0 +1,72 @@ + + + + + + 800 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gclo/PathRecoveryDialog.xaml.cs b/gclo/PathRecoveryDialog.xaml.cs new file mode 100644 index 0000000..6e24920 --- /dev/null +++ b/gclo/PathRecoveryDialog.xaml.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; +using gclo.Engine; +using Microsoft.UI.Xaml.Controls; + +namespace gclo +{ + /// + /// 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. carries + /// the chosen after "Apply and check out" and stays null + /// when the dialog is dismissed ("Skip this repo"). + /// + /// As with every ContentDialog, the caller must set XamlRoot before + /// ShowAsync. + /// + public sealed partial class PathRecoveryDialog : ContentDialog + { + private readonly List _rows; + + /// The user's recovery choice, or null when the dialog was dismissed. + public PathRecovery? Result { get; private set; } + + public PathRecoveryDialog(string repoName, IReadOnlyList 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(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(StringComparer.Ordinal); + var skipped = new HashSet(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); + } + } + + /// + /// One offending path in : the original repo path and + /// reason (read-only), plus the user's replacement name for the offending segment and + /// the per-row Skip choice. always ends with + /// the offending segment, so the rename applies to the last segment only. + /// + public sealed partial class PathRecoveryRow : ObservableObject + { + /// Repo path up to the offending segment: "" or "dir/sub/" (trailing slash). + private readonly string _parentPrefix; + + /// The offending (last) segment of . + private readonly string _originalSegment; + + /// Initial TextBox value: the validator's suggestion, or the original segment. + 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; + } + + /// Full repo path (forward slashes) of the offending file or folder. + public string RepoPath { get; } + + /// Why the path cannot be created on Windows. + public string Reason { get; } + + /// Replacement name for the offending segment, edited by the user. + [ObservableProperty] + public partial string NewName { get; set; } + + /// When set, the whole file or folder is omitted from the checkout. + [ObservableProperty] + public partial bool Skip { get; set; } + + /// The rename box is only editable while the row is not skipped. + public bool RenameEnabled => !Skip; + + partial void OnSkipChanged(bool value) => OnPropertyChanged(nameof(RenameEnabled)); + + /// + /// 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. + /// + internal string? BuildReplacementPath() + { + string segment = string.IsNullOrWhiteSpace(NewName) ? _prefill : NewName; + return segment == _originalSegment ? null : _parentPrefix + segment; + } + } +}