Files
gclo/gclo.Engine/WindowsPathValidator.cs
T
James MaesandClaude Fable 5 47b102bc6c Validate Windows-invalid paths before checkout; enable core.longpaths
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>
2026-07-03 09:06:04 -05:00

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;
}
}