Path recovery: rename/skip Windows-invalid paths (#2) #17
No files matched your search
+41
-1
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user