Fixes the owner-reported class of clone failures on repos that are fine on Linux but break on Windows (#2): - Clones are two-phase (fetch, then checkout) so core.longpaths=true is set before any file touches disk — deep paths past MAX_PATH now clone fine - WindowsPathValidator walks the tree before every checkout (clone, pull merge, unborn repair): invalid characters, trailing space/dot, reserved device names, >255-char segments, case-only collisions - Failures surface as InvalidRepositoryPathsException with per-path reasons and suggested sanitized names instead of raw libgit2 errors - FetchAndPull also sets core.longpaths and validates incoming trees 12 new tests (47 total), fixtures forged via ObjectDatabase plumbing. Recovery UX (rename/skip mapping) tracked in #2 for the UX overhaul. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97 lines
3.5 KiB
C#
97 lines
3.5 KiB
C#
using LibGit2Sharp;
|
|
|
|
namespace gclo.Engine;
|
|
|
|
/// <summary>
|
|
/// Validates every path in a git tree against Windows file-system rules before a
|
|
/// checkout is attempted, so failures surface as structured, actionable results
|
|
/// instead of raw libgit2 errors. Path *length* is not validated here: clones and
|
|
/// pulls run with core.longpaths=true, which lifts the 260-character limit; only
|
|
/// the per-segment limit (255) still applies.
|
|
/// </summary>
|
|
public static class WindowsPathValidator
|
|
{
|
|
private const int MaxSegmentLength = 255;
|
|
|
|
private static readonly char[] InvalidChars = Path.GetInvalidFileNameChars();
|
|
|
|
private static readonly HashSet<string> ReservedNames = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"CON", "PRN", "AUX", "NUL",
|
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
};
|
|
|
|
/// <summary>Returns every Windows-invalid path in <paramref name="tree"/>; empty when all are fine.</summary>
|
|
public static IReadOnlyList<InvalidPathInfo> Validate(Tree tree)
|
|
{
|
|
var invalid = new List<InvalidPathInfo>();
|
|
// Lowered path -> first-seen original casing, to catch case-only collisions
|
|
// (git allows README.md and readme.md; NTFS cannot hold both).
|
|
var seen = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var stack = new Stack<(Tree Tree, string Prefix)>();
|
|
stack.Push((tree, ""));
|
|
|
|
while (stack.Count > 0)
|
|
{
|
|
var (current, prefix) = stack.Pop();
|
|
foreach (var entry in current)
|
|
{
|
|
string repoPath = prefix.Length == 0 ? entry.Name : $"{prefix}/{entry.Name}";
|
|
|
|
if (ValidateSegment(entry.Name) is { } problem)
|
|
{
|
|
invalid.Add(new InvalidPathInfo(repoPath, problem.Reason, problem.Suggestion));
|
|
}
|
|
|
|
if (seen.TryGetValue(repoPath, out string? original))
|
|
{
|
|
invalid.Add(new InvalidPathInfo(repoPath, $"differs only by case from '{original}'", null));
|
|
}
|
|
else
|
|
{
|
|
seen[repoPath] = repoPath;
|
|
}
|
|
|
|
if (entry.TargetType == TreeEntryTargetType.Tree)
|
|
{
|
|
stack.Push(((Tree)entry.Target, repoPath));
|
|
}
|
|
}
|
|
}
|
|
|
|
return invalid;
|
|
}
|
|
|
|
private static (string Reason, string? Suggestion)? ValidateSegment(string segment)
|
|
{
|
|
if (segment.Length == 0)
|
|
{
|
|
return ("empty path segment", "_");
|
|
}
|
|
if (segment.Length > MaxSegmentLength)
|
|
{
|
|
return ($"segment longer than {MaxSegmentLength} characters", segment[..MaxSegmentLength]);
|
|
}
|
|
if (segment.EndsWith(' ') || segment.EndsWith('.'))
|
|
{
|
|
var trimmed = segment.TrimEnd(' ', '.');
|
|
return ("trailing space or dot", trimmed.Length == 0 ? "_" : trimmed);
|
|
}
|
|
if (segment.IndexOfAny(InvalidChars) >= 0)
|
|
{
|
|
var sanitized = new string(segment.Select(c => InvalidChars.Contains(c) ? '_' : c).ToArray());
|
|
return ("contains a character that is invalid on Windows", sanitized);
|
|
}
|
|
|
|
string baseName = segment.Split('.')[0];
|
|
if (ReservedNames.Contains(baseName))
|
|
{
|
|
return ($"'{baseName}' is a reserved Windows device name", segment + "_");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|