fix(recovery): plans are checked by where paths end up; suggestions are valid #150
No files matched your search
+8
-3
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
Reference in new issue
Block a user