fix(recovery): plans are checked by where paths end up; suggestions are valid #150

Merged
james.maes merged 2 commits from fix/149-recovery-plans into main 2026-11-10 20:53:40 +00:00
10 changed files with 899 additions and 55 deletions

No files matched your search

+8 -3
View File
@@ -131,9 +131,14 @@ performs a plain checkout and `--sanitize-paths` has nothing to do.
With `--sanitize-paths`, gclo checks such a repository out anyway:
- Each offending path is renamed on disk to a safe suggested name
(`aux` → `aux_`, `spec?.md` → `spec_.md`).
- Paths with no safe automatic rename (case-only collisions) are **skipped**:
they are not materialized on disk.
(`aux` → `aux_`, `com3.txt` → `com3_.txt`, `spec?.md` → `spec_.md`).
- Paths with no safe automatic rename are **skipped**: they are not
materialized on disk. That covers case-only collisions, and paths whose
suggested names would collide with each other (`a:b` and `a?b` would both
become `a_b`; two folders in that position are both skipped, not merged).
A suggested name that collides with a path the repository already has
(`a:b` beside an existing `a_b`) is not detected up front: that repository
fails with the colliding path named, and nothing is checked out.
- A note listing the renames goes to stderr (text modes) and to the activity
log, and the repository counts as **cloned** — the exit code stays `0`. The
human summary calls the count out:
+31
View File
@@ -658,6 +658,37 @@ public sealed class SyncCommandTests : IDisposable
[Fact]
public async Task SanitizePaths_SaysWhereEachPathLands_AndCountsOnlyWhatIsApplied()
{
using var console = new ConsoleCapture();
var lister = new FakeRepoLister { Result = [Repo("a")] };
var git = new FakeGit
{
OnClone = (_, _, _, _, _) => Task.FromException(new InvalidRepositoryPathsException(
[
new InvalidPathInfo("logs/12:00", "invalid", "12_00"),
new InvalidPathInfo("logs/12:00/run:1.log", "invalid", "run_1.log"),
new InvalidPathInfo("Dir", "differs only by case from 'dir'", null), // skipped, with what is in it
new InvalidPathInfo("Dir/x:1", "invalid", "x_1"),
])),
OnApplyRecovery = (_, _, _) => Task.CompletedTask,
};
int code = await Run(lister, git, "--org", "acme", "--target", Target, "--token-env", EnvVar, "--sanitize-paths");
Assert.Equal(0, code);
// The entry inside the renamed folder lands under the folder's new name, and
// the rename under the skipped folder is never applied: not listed, not counted.
Assert.Contains("a Sanitized 2 renamed, 1 skipped: ", console.Error);
Assert.Contains("'logs/12:00' -> 'logs/12_00'", console.Error);
Assert.Contains("'logs/12:00/run:1.log' -> 'logs/12_00/run_1.log'", console.Error);
Assert.Contains("skipped 'Dir'", console.Error);
Assert.DoesNotContain("x_1", console.Error);
}
[Fact]
public async Task SanitizePaths_RecoveryFails_LeavesRepoFailed()
{
+15 -40
View File
@@ -540,20 +540,31 @@ internal static class SyncCommand
try
{
cancellationToken.ThrowIfCancellationRequested();
PathRecovery recovery = BuildRecovery(pending.Paths);
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(pending.Paths);
await git.ApplyRecoveryAsync(path, recovery, cancellationToken).ConfigureAwait(false);
string detail = DescribeRecovery(recovery);
// Where each renamed path really lands (a rename inside a renamed folder
// lands under the folder's new name); a rename under a skipped folder is
// never applied, and is neither listed nor counted.
List<(string Original, string Destination)> applied = recovery.SegmentRenames.Keys
.Select(original => (Original: original, Destination: recovery.EffectivePathOf(original)))
.Where(r => r.Destination is not null)
.Select(r => (r.Original, r.Destination!))
.OrderBy(r => r.Original, StringComparer.Ordinal)
.ToList();
string detail = string.Join("; ", applied
.Select(r => $"'{r.Original}' -> '{r.Destination}'")
.Concat(recovery.SkippedPaths.Order(StringComparer.Ordinal).Select(s => $"skipped '{s}'")));
if(announce)
{
Terminal.Error(
$"{pending.Repo} Sanitized {recovery.SegmentRenames.Count} renamed, "
$"{pending.Repo} Sanitized {applied.Count} renamed, "
+ $"{recovery.SkippedPaths.Count} skipped: {detail}");
}
log.Info($"{pending.Repo}: cloned with sanitized paths: {detail}");
printer.RecordSanitized(new SyncSanitized(
pending.Repo,
recovery.SegmentRenames.Count,
applied.Count,
recovery.SkippedPaths.Count,
recovery.SkippedPaths.Order(StringComparer.Ordinal).ToList()));
progress.Report(new RepoProgress(pending.Repo, SyncStatus.Done));
@@ -577,42 +588,6 @@ internal static class SyncCommand
/// <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);
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace gclo.Engine.Tests;
/// <summary>
/// <see cref="PathRecovery.EffectivePathOf"/> is the one definition of where a path
/// ends up once renames and skips are applied (#149).
/// </summary>
public sealed class PathRecoveryEffectivePathTests
{
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));
[Theory]
[InlineData("readme.txt")]
[InlineData("src/app/main.cs")]
public void APathNothingApplyTo_StaysWhereItIs(string path)
{
Assert.Equal(path, Recovery(renames: [("other:file.txt", "other_file.txt")], skips: ["gone"]).EffectivePathOf(path));
}
[Fact]
public void ARenamedFile_TakesTheLastSegmentOfItsReplacement_UnderItsOwnFolder()
{
PathRecovery recovery = Recovery(renames: [("docs/bad:name.txt", "docs/bad_name.txt")]);
Assert.Equal("docs/bad_name.txt", recovery.EffectivePathOf("docs/bad:name.txt"));
}
[Fact]
public void OnlyTheLastSegmentOfAReplacementCounts_WhateverFolderItIsWrittenWith()
{
// A replacement cannot move an entry to another folder.
PathRecovery recovery = Recovery(renames: [("docs/bad:name.txt", "somewhere/else/bad_name.txt")]);
Assert.Equal("docs/bad_name.txt", recovery.EffectivePathOf("docs/bad:name.txt"));
}
[Fact]
public void ARenamedFolder_MovesEverythingUnderIt()
{
PathRecovery recovery = Recovery(renames: [("logs/12:00", "logs/12_00")]);
Assert.Equal("logs/12_00", recovery.EffectivePathOf("logs/12:00"));
Assert.Equal("logs/12_00/run.log", recovery.EffectivePathOf("logs/12:00/run.log"));
Assert.Equal("logs/12_00/deep/er/run.log", recovery.EffectivePathOf("logs/12:00/deep/er/run.log"));
Assert.Equal("logs/other/run.log", recovery.EffectivePathOf("logs/other/run.log"));
}
[Fact]
public void RenamesOfAFolderAndOfAnEntryInsideIt_Compose()
{
PathRecovery recovery = Recovery(renames: [("logs/12:00", "logs/12_00"), ("logs/12:00/run:1.log", "logs/12:00/run_1.log")]);
Assert.Equal("logs/12_00/run_1.log", recovery.EffectivePathOf("logs/12:00/run:1.log"));
}
[Fact]
public void ASkippedPath_AndEverythingUnderASkippedFolder_EndsUpNowhere()
{
PathRecovery recovery = Recovery(renames: [("gone/bad:name.txt", "gone/bad_name.txt")], skips: ["gone", "single:file.txt"]);
Assert.Null(recovery.EffectivePathOf("single:file.txt"));
Assert.Null(recovery.EffectivePathOf("gone"));
Assert.Null(recovery.EffectivePathOf("gone/kept.txt"));
Assert.Null(recovery.EffectivePathOf("gone/bad:name.txt"));
Assert.Equal("gone-too/kept.txt", recovery.EffectivePathOf("gone-too/kept.txt"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void NoPath_IsACallerError(string? path)
{
Assert.ThrowsAny<ArgumentException>(() => Recovery().EffectivePathOf(path!));
}
}
@@ -97,4 +97,312 @@ public sealed class PathRecoveryPlannerTests
{
Assert.Throws<ArgumentNullException>(() => Build(null!));
}
// ---------------------------------------------------------------- destinations, not replacements as typed (#149)
[Fact]
public void Build_RenamedEntryInsideARenamedFolder_IsSound_AlthoughItsReplacementStillShowsTheOldFolderName()
{
// The dialog writes each replacement with the row's ORIGINAL parent in front;
// the engine applies only the last segment, under the folder's new name.
Plan plan = Build(
[
new Choice("logs/12:00", "logs/12_00", Skip: false),
new Choice("logs/12:00/run:1.log", "logs/12:00/run_1.log", Skip: false),
]);
Assert.Empty(plan.Problems);
Assert.Equal("logs/12_00/run_1.log", plan.Recovery.EffectivePathOf("logs/12:00/run:1.log"));
}
[Fact]
public void Build_RenamedEntryInsideARenamedFolder_IsStillCheckedByItsOwnNewName()
{
Plan plan = Build(
[
new Choice("logs/12:00", "logs/12_00", Skip: false),
new Choice("logs/12:00/run:1.log", "logs/12:00/run?1.log", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'logs/12_00/run?1.log' cannot be used", problem);
}
[Fact]
public void Build_TwoRowsThatEndUpAtTheSamePlace_AreAProblem_EvenWhenTheirReplacementsDiffer()
{
// Different folders as typed, the same folder once the folder's rename applies.
Plan plan = Build(
[
new Choice("a:b", "a_b", Skip: false),
new Choice("a:b/x:1.txt", "a:b/x_1.txt", Skip: false),
new Choice("a:b/x?1.txt", "a:b/x_1.txt", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'a_b/x_1.txt' cannot be used: more than one path maps to this destination", problem);
}
[Fact]
public void Build_RenamedEntryUnderASkippedFolder_IsNotChecked_BecauseItIsNotWritten()
{
Plan plan = Build(
[
new Choice("skipped:dir", null, Skip: true),
new Choice("skipped:dir/still:bad.txt", "skipped:dir/still?bad.txt", Skip: false),
]);
Assert.Empty(plan.Problems);
Assert.Null(plan.Recovery.EffectivePathOf("skipped:dir/still:bad.txt"));
}
[Fact]
public void Build_AFileRenamedOntoANameThatIsAlsoAFolderOfAnotherRename_IsAProblem()
{
// Two unrelated originals: a file that becomes "a_b", and an entry inside the
// existing folder "a_b". Their destinations nest, which is a clash, not a
// folder with its content.
Plan plan = Build(
[
new Choice("a:b", "a_b", Skip: false),
new Choice("a_b/c:d", "a_b/c_d", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("'a_b' cannot be used: maps to both a file and a directory (from 'a:b')", problem);
}
[Fact]
public void Build_AFolderAndAFileRenamedOntoOneName_IsAProblem()
{
Plan plan = Build(
[
new Choice("a:b", "a_b", Skip: false),
new Choice("a:b/x:1", "a:b/x_1", Skip: false),
new Choice("a?b", "a_b", Skip: false),
]);
Assert.Contains(plan.Problems, p => p.StartsWith("'a_b' cannot be used", StringComparison.Ordinal));
}
[Fact]
public void Build_TwoFoldersRenamedOntoOneName_AreAProblem_NamedWithTheRowsTheyComeFrom()
{
Plan plan = Build(
[
new Choice("a:b", "a_b", Skip: false),
new Choice("a:b/x:1", "a:b/x_1", Skip: false),
new Choice("a?b", "a_b", Skip: false),
new Choice("a?b/y:1", "a?b/y_1", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Equal("'a_b' cannot be used: more than one path maps to this destination (from 'a:b', 'a?b').", problem);
}
[Fact]
public void Build_ThreeRowsAtOnePlace_AreReportedOnce()
{
Plan plan = Build(
[
new Choice("x:1", "x_1", Skip: false),
new Choice("x?1", "x_1", Skip: false),
new Choice("x*1", "x_1", Skip: false),
]);
string problem = Assert.Single(plan.Problems);
Assert.Contains("(from 'x*1', 'x:1', 'x?1')", problem);
}
[Theory]
[InlineData("docs/bad:name.txt", "elsewhere/bad_name.txt")]
[InlineData("docs/bad:name.txt", "docs/sub/bad_name.txt")]
[InlineData("docs/bad:name.txt", "bad_name.txt")]
[InlineData("bad:name.txt", "docs/bad_name.txt")]
public void Build_AReplacementInAnotherFolder_IsAProblem_BecauseOnlyTheNameIsApplied(string path, string replacement)
{
Plan plan = Build([new Choice(path, replacement, Skip: false)]);
string problem = Assert.Single(plan.Problems);
Assert.Contains($"'{replacement}' would move '{path}' to another folder", problem);
}
// ---------------------------------------------------------------- the automatic plan (#149)
[Fact]
public void FromSuggestions_RenamesWhatHasASuggestion_AndSkipsWhatHasNone()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("bad:name.txt", "invalid", "bad_name.txt"),
new InvalidPathInfo("dir/CON", "reserved", "CON_"),
new InvalidPathInfo("README.md", "differs only by case from 'readme.md'", null),
]);
Assert.Equal("bad_name.txt", recovery.SegmentRenames["bad:name.txt"]);
Assert.Equal("dir/CON_", recovery.SegmentRenames["dir/CON"]);
Assert.Equal(["README.md"], recovery.SkippedPaths, StringComparer.Ordinal);
}
[Fact]
public void FromSuggestions_APathThatAlsoHasAProblemWithoutASuggestion_IsSkipped()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("x:y.txt", "invalid", "x_y.txt"),
new InvalidPathInfo("x:y.txt", "differs only by case from 'X:Y.txt'", null),
]);
Assert.Empty(recovery.SegmentRenames);
Assert.Equal(["x:y.txt"], recovery.SkippedPaths, StringComparer.Ordinal);
}
[Fact]
public void FromSuggestions_TwoPathsGivenTheSameName_AreSkipped_RatherThanFailingTheApply()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("a:b", "invalid", "a_b"),
new InvalidPathInfo("a?b", "invalid", "a_b"),
new InvalidPathInfo("fine:name.txt", "invalid", "fine_name.txt"),
]);
Assert.Equal("fine_name.txt", Assert.Single(recovery.SegmentRenames).Value);
Assert.Equal(new[] { "a:b", "a?b" }, recovery.SkippedPaths.Order(StringComparer.Ordinal), StringComparer.Ordinal);
// What comes out always passes the pre-flight the dialog runs.
Assert.Empty(Build(recovery.SegmentRenames.Select(r => new Choice(r.Key, r.Value, Skip: false)).ToList()).Problems);
}
[Fact]
public void FromSuggestions_AnInvalidFolderAndAnInvalidEntryInsideIt_AreBothRenamed_AndCompose()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("logs/12:00", "invalid", "12_00"),
new InvalidPathInfo("logs/12:00/run:1.log", "invalid", "run_1.log"),
]);
Assert.Empty(recovery.SkippedPaths);
Assert.Equal("logs/12_00/run_1.log", recovery.EffectivePathOf("logs/12:00/run:1.log"));
}
[Fact]
public void FromSuggestions_TwoEntriesCollidingInsideARenamedFolder_AreSkipped_AndTheFolderKeepsItsRename()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("a:b", "invalid", "a_b"),
new InvalidPathInfo("a:b/x:1", "invalid", "x_1"),
new InvalidPathInfo("a:b/x?1", "invalid", "x_1"),
]);
Assert.Equal("a_b", Assert.Single(recovery.SegmentRenames).Value);
Assert.Equal(new[] { "a:b/x:1", "a:b/x?1" }, recovery.SkippedPaths.Order(StringComparer.Ordinal), StringComparer.Ordinal);
}
[Fact]
public void FromSuggestions_TwoFoldersGivenTheSameName_AreBothSkipped_WithEverythingInThem()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("a:b", "invalid", "a_b"),
new InvalidPathInfo("a:b/x:1", "invalid", "x_1"),
new InvalidPathInfo("a?b", "invalid", "a_b"),
new InvalidPathInfo("a?b/y:1", "invalid", "y_1"),
new InvalidPathInfo("fine:name.txt", "invalid", "fine_name.txt"),
]);
Assert.Equal("fine_name.txt", Assert.Single(recovery.SegmentRenames).Value);
Assert.Contains("a:b", recovery.SkippedPaths);
Assert.Contains("a?b", recovery.SkippedPaths);
Assert.Null(recovery.EffectivePathOf("a:b/x:1"));
Assert.Null(recovery.EffectivePathOf("a?b/y:1"));
}
[Fact]
public void FromSuggestions_AnEntryWhoseFolderIsInvalidAndNotListed_IsSkipped_AndThePlanIsFinished()
{
// The problem is on the folder above the destination, not on the destination
// itself; the entry is what gets skipped, and the loop ends.
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[new InvalidPathInfo("logs/12:00/run:1.log", "invalid", "run_1.log")]);
Assert.Empty(recovery.SegmentRenames);
Assert.Equal(["logs/12:00/run:1.log"], recovery.SkippedPaths, StringComparer.Ordinal);
}
[Fact]
public void FromSuggestions_TwoNamesThatWouldDifferOnlyByCase_KeepTheFirstAndSkipTheSecond()
{
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(
[
new InvalidPathInfo("a:B", "invalid", "a_B"),
new InvalidPathInfo("a:b", "invalid", "a_b"),
]);
Assert.Equal("a_B", Assert.Single(recovery.SegmentRenames).Value);
Assert.Equal(["a:b"], recovery.SkippedPaths, StringComparer.Ordinal);
}
[Fact]
public void FromSuggestions_AndBuild_StayFast_ForARepositoryWithTensOfThousandsOfInvalidPaths()
{
// Timestamps in file names are the usual way to get this many. The work per path
// must not grow with the number of paths (a pairwise comparison took a minute).
var paths = Enumerable.Range(0, 50_000)
.Select(i => new InvalidPathInfo($"data/2026-01-01T00:00:{i}.json", "invalid", $"2026-01-01T00_00_{i}.json"))
.ToList();
var clock = System.Diagnostics.Stopwatch.StartNew();
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(paths);
Plan plan = Build(recovery.SegmentRenames.Select(r => new Choice(r.Key, r.Value, Skip: false)).ToList());
Assert.Equal(50_000, recovery.SegmentRenames.Count);
Assert.Empty(plan.Problems);
Assert.True(clock.Elapsed < TimeSpan.FromSeconds(20), $"took {clock.Elapsed}");
}
[Fact]
public void FromSuggestions_Null_Throws()
{
Assert.Throws<ArgumentNullException>(() => PathRecoveryPlanner.FromSuggestions(null!));
}
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using gclo.ViewModels;
using static gclo.Engine.Tests.GitTestHelpers;
namespace gclo.Engine.Tests;
/// <summary>
/// The plan <see cref="PathRecoveryPlanner.FromSuggestions"/> builds from the paths a
/// clone reports is one the real engine accepts and applies (#149). Real repositories
/// on the local file system, like <see cref="PathRecoveryTests"/>.
/// </summary>
public sealed class SuggestedRecoveryAgainstEngineTests : IDisposable
{
private const string Token = "test-token";
private readonly string _root =
Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N"));
private readonly LibGit2GitClient _client = new();
public SuggestedRecoveryAgainstEngineTests()
{
Directory.CreateDirectory(_root);
}
public void Dispose() => TryDeleteDirectory(_root);
[Fact]
public async Task AnInvalidFolderWithAnInvalidEntryInside_BothRenamed_UnderTheFoldersNewName()
{
string target = await SanitizeAsync(("logs/12:00/run:1.log", "run"), ("logs/12:00/ok.txt", "ok"), ("keep.txt", "keep"));
Assert.Equal("run", File.ReadAllText(Path.Combine(target, "logs", "12_00", "run_1.log")));
Assert.Equal("ok", File.ReadAllText(Path.Combine(target, "logs", "12_00", "ok.txt")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task TwoFilesGivenTheSameName_AreBothLeftOut_AndTheRestIsWritten()
{
string target = await SanitizeAsync(("a:b", "one"), ("a?b", "two"), ("keep.txt", "keep"));
Assert.False(File.Exists(Path.Combine(target, "a_b")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task TwoFoldersGivenTheSameName_AreBothLeftOut_NeverMergedIntoOne()
{
string target = await SanitizeAsync(("a:b/f1.txt", "one"), ("a?b/f2.txt", "two"), ("keep.txt", "keep"));
Assert.False(Directory.Exists(Path.Combine(target, "a_b")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task TwoFoldersGivenTheSameName_AreLeftOut_AlsoWhenTheyHoldInvalidEntriesOfTheirOwn()
{
// Whether two subtrees are merged or left out must not depend on whether one of
// them happens to contain another invalid name.
string target = await SanitizeAsync(("a:b/x:1.txt", "one"), ("a?b/y:1.txt", "two"), ("keep.txt", "keep"));
Assert.False(Directory.Exists(Path.Combine(target, "a_b")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
[Fact]
public async Task AFileWhoseNewNameIsAlsoAFolderThatGetsARenamedEntry_IsLeftOut_AndTheFolderIsWritten()
{
string target = await SanitizeAsync(("a:b", "file"), ("a_b/c:d.txt", "inside"), ("a_b/ok.txt", "ok"));
Assert.Equal("ok", File.ReadAllText(Path.Combine(target, "a_b", "ok.txt")));
Assert.True(Directory.Exists(Path.Combine(target, "a_b")));
}
[Fact]
public async Task AFolderAndAFileGivenTheSameName_AreBothLeftOut()
{
string target = await SanitizeAsync(("a:b/x:1.txt", "inside"), ("a:b/ok.txt", "ok"), ("a?b", "file"), ("keep.txt", "keep"));
Assert.False(File.Exists(Path.Combine(target, "a_b")));
Assert.False(Directory.Exists(Path.Combine(target, "a_b")));
Assert.Equal("keep", File.ReadAllText(Path.Combine(target, "keep.txt")));
}
/// <summary>
/// Clones a forged repository (which reports its invalid paths), builds the
/// automatic plan from that report, and applies it; returns the working directory.
/// </summary>
private async Task<string> SanitizeAsync(params (string Name, string Content)[] entries)
{
string source = CreateForgedRepo(_root, entries);
string target = Path.Combine(_root, "clone-" + Guid.NewGuid().ToString("N")[..8]);
InvalidRepositoryPathsException reported = await Assert.ThrowsAsync<InvalidRepositoryPathsException>(
() => _client.CloneAsync(source, target, Token, null, CancellationToken.None));
PathRecovery recovery = PathRecoveryPlanner.FromSuggestions(reported.Paths);
await _client.ApplyRecoveryAsync(target, recovery, CancellationToken.None);
return target;
}
}
@@ -46,7 +46,12 @@ public sealed class WindowsPathValidationTests : IDisposable
[InlineData("trailing-dot.", "trailing space or dot", "trailing-dot")]
[InlineData("bad:colon.txt", "invalid on Windows", "bad_colon.txt")]
[InlineData("CON", "reserved Windows device name", "CON_")]
[InlineData("COM3.txt", "reserved Windows device name", "COM3.txt_")]
[InlineData("COM3.txt", "reserved Windows device name", "COM3_.txt")]
[InlineData("aux.tar.gz", "reserved Windows device name", "aux_.tar.gz")]
[InlineData("x?.", "trailing space or dot", "x_")]
[InlineData("report?. .", "trailing space or dot", "report_")]
[InlineData("CON .", "trailing space or dot", "CON_")]
[InlineData("...", "trailing space or dot", "_")]
public void Validate_FlagsInvalidSegment_WithReasonAndSuggestion(
string entryName, string expectedReasonFragment, string expectedSuggestion)
{
@@ -101,6 +106,59 @@ public sealed class WindowsPathValidationTests : IDisposable
[Theory]
[InlineData("x?.")]
[InlineData(" . ")]
[InlineData("CON")]
[InlineData("con.txt")]
[InlineData("COM1?.log ")]
[InlineData("a:b*c|d")]
[InlineData("nul.")]
[InlineData("LPT9. .")]
[InlineData("CON.t?t.")] // three rules, one after the other
public void ValidatePaths_EverySuggestedName_IsItselfValid(string segment)
{
// One rule's fix can trip another; a suggestion that would be rejected is worse
// than none (#149).
InvalidPathInfo info = Assert.Single(WindowsPathValidator.ValidatePaths([segment]));
Assert.NotNull(info.SuggestedName);
Assert.Empty(WindowsPathValidator.ValidatePaths([info.SuggestedName!]));
}
[Fact]
public void ValidatePaths_AnOverlongReservedName_StillGetsASuggestionThatIsValid()
{
// The reserved-name fix adds a character and the length fix takes one away:
// the two must settle rather than undo each other.
string segment = "CON." + new string('x', 251);
Assert.Equal(255, segment.Length);
InvalidPathInfo info = Assert.Single(WindowsPathValidator.ValidatePaths([segment]));
Assert.Empty(WindowsPathValidator.ValidatePaths([info.SuggestedName!]));
Assert.StartsWith("CON_.", info.SuggestedName, StringComparison.Ordinal);
}
[Fact]
public void ValidatePaths_AnOverlongName_IsCutBetweenCharacters_NeverThroughOne()
{
// An emoji is two code units; the limit falls between them.
string emoji = char.ConvertFromUtf32(0x1F600);
string segment = new string('x', 254) + emoji + "tail";
InvalidPathInfo info = Assert.Single(WindowsPathValidator.ValidatePaths([segment]));
Assert.Equal(new string('x', 254), info.SuggestedName);
Assert.Empty(WindowsPathValidator.ValidatePaths([info.SuggestedName!]));
}
[Fact]
public void Validate_FlagsCaseOnlyCollision()
{
+34 -1
View File
@@ -23,4 +23,37 @@ namespace gclo.Engine;
/// </param>
public sealed record PathRecovery(
IReadOnlyDictionary<string, string> SegmentRenames,
IReadOnlySet<string> SkippedPaths);
IReadOnlySet<string> SkippedPaths)
{
/// <summary>
/// Where <paramref name="repoPath"/> ends up in the working tree once this
/// recovery is applied: each part of the path takes the last segment of its own
/// rename, if it has one, under the place its parent folder ended up. Null when the
/// path, or a folder above it, is skipped. This is the one definition of how
/// renames compose; anything that needs to know a destination before the tree is
/// written (a pre-flight check, an automatic plan) asks here.
/// </summary>
public string? EffectivePathOf(string repoPath)
{
ArgumentException.ThrowIfNullOrEmpty(repoPath);
string[] segments = repoPath.Split('/');
string original = "";
string effective = "";
for(int i = 0; i < segments.Length; i++)
{
original = i == 0 ? segments[i] : $"{original}/{segments[i]}";
if(SkippedPaths.Contains(original))
{
return null;
}
string name = SegmentRenames.TryGetValue(original, out string? mapped)
? mapped[(mapped.LastIndexOf('/') + 1)..]
: segments[i];
effective = i == 0 ? name : $"{effective}/{name}";
}
return effective;
}
}
+31 -4
View File
@@ -225,7 +225,7 @@ public static class WindowsPathValidator
{
if(ValidateSegment(segments[i]) is { } problem)
{
invalid.Add(new InvalidPathInfo(prefix, problem.Reason, problem.Suggestion));
invalid.Add(new InvalidPathInfo(prefix, problem.Reason, Settle(problem.Suggestion)));
}
if(seenCi.TryGetValue(prefix, out string? original))
{
@@ -244,7 +244,28 @@ public static class WindowsPathValidator
private static (string Reason, string? Suggestion)? ValidateSegment(string segment)
/// <summary>
/// A suggested name that is itself valid. One rule's fix can break another rule
/// ("x?." loses its dot and still holds a '?'), so the suggestion is put through
/// the rules again until it passes; proposing a name that would be rejected helps
/// nobody (#149). Every rule's fix settles within a few rounds; the bound is there
/// so that a future rule which does not cannot loop.
/// </summary>
private static string Settle(string suggestion)
{
const int MaxRounds = 8;
for(int round = 0; round < MaxRounds && ValidateSegment(suggestion) is { } problem; round++)
{
suggestion = problem.Suggestion;
}
return suggestion;
}
private static (string Reason, string Suggestion)? ValidateSegment(string segment)
{
if(segment.Length == 0)
{
@@ -252,7 +273,11 @@ public static class WindowsPathValidator
}
if(segment.Length > MaxSegmentLength)
{
return ($"segment longer than {MaxSegmentLength} characters", segment[..MaxSegmentLength]);
// Cut between characters: a pair of code units for one character is kept
// whole or dropped whole, never split (the half would be written to disk and
// then stored as something else).
int keep = char.IsHighSurrogate(segment[MaxSegmentLength - 1]) ? MaxSegmentLength - 1 : MaxSegmentLength;
return ($"segment longer than {MaxSegmentLength} characters", segment[..keep]);
}
if(WorkingTree.IsGitDirectoryName(segment))
{
@@ -278,7 +303,9 @@ public static class WindowsPathValidator
string baseName = segment.Split('.')[0];
if(s_reservedNames.Contains(baseName))
{
return ($"'{baseName}' is a reserved Windows device name", segment + "_");
// The device name is the part before the first dot, whatever follows it
// ("COM3.txt" is as reserved as "COM3"), so that part is what changes.
return ($"'{baseName}' is a reserved Windows device name", baseName + "_" + segment[baseName.Length..]);
}
return null;
+190 -6
View File
@@ -16,6 +16,11 @@ namespace gclo.ViewModels;
/// engine, and re-opening with a fresh list (#30). The engine still re-validates the
/// full effective tree on apply; this is the pre-flight for what the user can see.
/// </summary>
/// <remarks>
/// The pre-flight sees the listed paths only, not the rest of the tree. A new name
/// that collides with a path that was valid all along (and so is not listed) is
/// caught by the engine, not here.
/// </remarks>
public static class PathRecoveryPlanner
{
/// <summary>One row's choice.</summary>
@@ -35,9 +40,12 @@ public static class PathRecoveryPlanner
/// <summary>
/// Builds the plan. A row that is neither skipped nor renamed is always a problem
/// (every listed path is invalid by definition), and replacement names go through
/// the same Windows rules as a checkout would, including two rows renamed onto one
/// destination.
/// (every listed path is invalid by definition); a replacement may change a path's
/// name but not its folder; and the places the renamed paths end up go through the
/// same Windows rules as a checkout would, including two rows renamed onto one
/// destination. What is validated is the destination, not the replacement as typed:
/// a renamed entry inside a renamed folder is written with its folder's ORIGINAL
/// name in front, and lands under the folder's new one (#149).
/// </summary>
public static Plan Build(IReadOnlyList<Choice> choices)
{
@@ -59,6 +67,12 @@ public static class PathRecoveryPlanner
else if(choice.ReplacementPath is { } replacement)
{
renames[choice.RepoPath] = replacement;
if(!string.Equals(FolderOf(replacement), FolderOf(choice.RepoPath), StringComparison.Ordinal))
{
// Only the last segment is applied; saying nothing would let the user
// believe the entry moves to the folder they typed.
problems.Add($"'{replacement}' would move '{choice.RepoPath}' to another folder: only its name can be changed.");
}
}
else
{
@@ -66,11 +80,181 @@ public static class PathRecoveryPlanner
}
}
foreach(InvalidPathInfo stillInvalid in WindowsPathValidator.ValidatePaths(renames.Values))
var recovery = new PathRecovery(renames, skipped);
List<Placement> placements = Place(recovery);
foreach((string destination, string reason) in ProblemsOf(placements))
{
problems.Add($"'{stillInvalid.RepoPath}' cannot be used: {stillInvalid.Reason}.");
problems.Add($"'{destination}' cannot be used: {reason}{Origins(placements, destination)}.");
}
return new Plan(new PathRecovery(renames, skipped), problems);
return new Plan(recovery, problems);
}
/// <summary>
/// The recovery an unattended run applies (<c>--sanitize-paths</c>): every path with
/// a suggested name is renamed to it, every path without one is skipped, and then
/// whatever the renames cannot place after all (two paths given the same name, a
/// name that is also a folder's) is skipped as well, until the listed paths no
/// longer clash with each other. Two folders given the same name are both skipped,
/// never merged into one. The result never fails the pre-flight that
/// <see cref="Build"/> runs (#149).
/// </summary>
public static PathRecovery FromSuggestions(IReadOnlyList<InvalidPathInfo> paths)
{
ArgumentNullException.ThrowIfNull(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.
renames[info.RepoPath] = FolderOf(info.RepoPath) + suggestion;
}
while(true)
{
var recovery = new PathRecovery(renames, skipped);
List<Placement> placements = Place(recovery);
var unusable = new HashSet<string>(ProblemsOf(placements).Select(p => p.Destination), StringComparer.Ordinal);
if(unusable.Count == 0)
{
return recovery;
}
// Every reported destination is where a placement ends up, or a folder above
// one, so each round turns at least one rename into a skip.
foreach(Placement placement in placements)
{
if(IsAtOrUnder(placement.Effective, unusable))
{
renames.Remove(placement.Original);
skipped.Add(placement.Original);
}
}
}
}
/// <summary>A renamed path and where it ends up.</summary>
/// <param name="Original">The repository path that is renamed.</param>
/// <param name="Effective">Where it lands (see <see cref="PathRecovery.EffectivePathOf"/>).</param>
/// <param name="HoldsRenamedEntries">True when another renamed path lies inside this one, which makes it a folder for certain.</param>
private sealed record Placement(string Original, string Effective, bool HoldsRenamedEntries);
/// <summary>
/// Every renamed path that is written at all (not under a skipped folder), with its
/// destination, and whether it is known to be a folder: one that holds another
/// renamed path. Decided by the ORIGINAL paths, which are the tree's; two unrelated
/// originals whose destinations happen to nest are a clash, not a folder and its
/// content.
/// </summary>
private static List<Placement> Place(PathRecovery recovery)
{
var placed = new List<(string Original, string Effective)>();
var foldersOfPlaced = new HashSet<string>(StringComparer.Ordinal);
foreach(string original in recovery.SegmentRenames.Keys)
{
if(recovery.EffectivePathOf(original) is not { } effective)
{
continue;
}
placed.Add((original, effective));
for(int slash = original.LastIndexOf('/'); slash > 0; slash = original.LastIndexOf('/', slash - 1))
{
foldersOfPlaced.Add(original[..slash]);
}
}
return placed.Select(p => new Placement(p.Original, p.Effective, foldersOfPlaced.Contains(p.Original))).ToList();
}
/// <summary>
/// The destinations that cannot be used, each with the reason. Destinations of
/// entries go through the Windows rules together, which also finds two of them at
/// one place and an entry where a folder is needed. A folder that holds renamed
/// entries is part of their destinations already, and is checked for the one thing
/// that would not show there: another renamed path, folder or not, given its name.
/// </summary>
private static List<(string Destination, string Reason)> ProblemsOf(List<Placement> placements)
{
var problems = new List<(string Destination, string Reason)>();
var reported = new HashSet<string>(StringComparer.Ordinal);
foreach(InvalidPathInfo invalid in WindowsPathValidator.ValidatePaths(placements.Where(p => !p.HoldsRenamedEntries).Select(p => p.Effective)))
{
if(reported.Add(invalid.RepoPath))
{
problems.Add((invalid.RepoPath, invalid.Reason));
}
}
foreach(IGrouping<string, Placement> sameName in placements.GroupBy(p => p.Effective, StringComparer.OrdinalIgnoreCase))
{
if(sameName.Count() > 1 && sameName.Any(p => p.HoldsRenamedEntries))
{
foreach(string destination in sameName.Select(p => p.Effective).Distinct(StringComparer.Ordinal))
{
if(reported.Add(destination))
{
problems.Add((destination, "more than one path maps to this destination"));
}
}
}
}
return problems;
}
/// <summary>The rows behind a destination, for a message: the user sees originals, not where they land.</summary>
private static string Origins(List<Placement> placements, string destination)
{
var originals = placements
.Where(p => string.Equals(p.Effective, destination, StringComparison.Ordinal))
.Select(p => p.Original)
.Where(original => !string.Equals(original, destination, StringComparison.Ordinal))
.Order(StringComparer.Ordinal)
.ToList();
return originals.Count == 0 ? "" : $" (from {string.Join(", ", originals.Select(o => $"'{o}'"))})";
}
/// <summary>True when <paramref name="path"/> is one of <paramref name="paths"/> or lies under one of them.</summary>
private static bool IsAtOrUnder(string path, HashSet<string> paths)
{
for(int end = path.Length; end > 0; end = path.LastIndexOf('/', end - 1))
{
if(paths.Contains(path[..end]))
{
return true;
}
}
return false;
}
/// <summary>The folder part of a repository path, with its trailing slash; empty at the root.</summary>
private static string FolderOf(string repoPath)
=> repoPath[..(repoPath.LastIndexOf('/') + 1)];
}