| 1 | namespace GitMcp.Core; |
| 2 | |
| 3 | /// <summary> |
| 4 | /// Lightweight classification of changed files into semantic and noise buckets. |
| 5 | /// </summary> |
| 6 | public static class GitPreflight |
| 7 | { |
| 8 | public sealed record Report( |
| 9 | IReadOnlyList<string> ChangedFiles, |
| 10 | IReadOnlyList<string> UntrackedFiles, |
| 11 | IReadOnlyList<string> SemanticFiles, |
| 12 | IReadOnlyList<string> WhitespaceOnlyFiles, |
| 13 | IReadOnlyList<string> EolOnlyFiles, |
| 14 | IReadOnlyList<string> BomOnlyFiles, |
| 15 | IReadOnlyList<string> SuggestedSafeFixCommands); |
| 16 | |
| 17 | public static Report BuildReport( |
| 18 | IEnumerable<string> changed, |
| 19 | IEnumerable<string> untracked, |
| 20 | IEnumerable<string> ignoreCrAtEol, |
| 21 | IEnumerable<string> ignoreWhitespace, |
| 22 | IReadOnlyDictionary<string, string>? patchesByFile = null) |
| 23 | { |
| 24 | var changedSet = ToOrderedSet(changed); |
| 25 | var untrackedSet = ToOrderedSet(untracked); |
| 26 | var ignoreCrSet = ToOrderedSet(ignoreCrAtEol); |
| 27 | var ignoreWsSet = ToOrderedSet(ignoreWhitespace); |
| 28 | |
| 29 | var semantic = new SortedSet<string>(StringComparer.Ordinal); |
| 30 | var whitespaceOnly = new SortedSet<string>(StringComparer.Ordinal); |
| 31 | var eolOnly = new SortedSet<string>(StringComparer.Ordinal); |
| 32 | var bomOnly = new SortedSet<string>(StringComparer.Ordinal); |
| 33 | |
| 34 | foreach (var path in changedSet) |
| 35 | { |
| 36 | if (!ignoreCrSet.Contains(path)) |
| 37 | { |
| 38 | eolOnly.Add(path); |
| 39 | continue; |
| 40 | } |
| 41 | |
| 42 | if (!ignoreWsSet.Contains(path)) |
| 43 | { |
| 44 | whitespaceOnly.Add(path); |
| 45 | continue; |
| 46 | } |
| 47 | |
| 48 | semantic.Add(path); |
| 49 | } |
| 50 | |
| 51 | if (patchesByFile is not null) |
| 52 | { |
| 53 | foreach (var path in semantic.ToArray()) |
| 54 | { |
| 55 | if (patchesByFile.TryGetValue(path, out var patch) && IsBomOnlyPatch(patch)) |
| 56 | { |
| 57 | bomOnly.Add(path); |
| 58 | semantic.Remove(path); |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | var safeFixCommands = new List<string>(); |
| 64 | if (eolOnly.Count > 0) |
| 65 | safeFixCommands.Add("git add --renormalize ."); |
| 66 | |
| 67 | return new Report( |
| 68 | ChangedFiles: changedSet.ToArray(), |
| 69 | UntrackedFiles: untrackedSet.ToArray(), |
| 70 | SemanticFiles: semantic.ToArray(), |
| 71 | WhitespaceOnlyFiles: whitespaceOnly.ToArray(), |
| 72 | EolOnlyFiles: eolOnly.ToArray(), |
| 73 | BomOnlyFiles: bomOnly.ToArray(), |
| 74 | SuggestedSafeFixCommands: safeFixCommands); |
| 75 | } |
| 76 | |
| 77 | public static IReadOnlyList<string> ParseNameOnlyOutput(string? output) |
| 78 | { |
| 79 | if (string.IsNullOrWhiteSpace(output)) |
| 80 | return []; |
| 81 | |
| 82 | var set = new SortedSet<string>(StringComparer.Ordinal); |
| 83 | var lines = output.Replace('\r', '\n').Split('\n', StringSplitOptions.RemoveEmptyEntries); |
| 84 | foreach (var line in lines) |
| 85 | { |
| 86 | var p = line.Trim(); |
| 87 | if (p.Length > 0) |
| 88 | set.Add(p); |
| 89 | } |
| 90 | |
| 91 | return set.ToArray(); |
| 92 | } |
| 93 | |
| 94 | private static SortedSet<string> ToOrderedSet(IEnumerable<string> values) |
| 95 | { |
| 96 | var set = new SortedSet<string>(StringComparer.Ordinal); |
| 97 | foreach (var value in values) |
| 98 | { |
| 99 | if (!string.IsNullOrWhiteSpace(value)) |
| 100 | set.Add(value.Trim()); |
| 101 | } |
| 102 | return set; |
| 103 | } |
| 104 | |
| 105 | internal static bool IsBomOnlyPatch(string? patch) |
| 106 | { |
| 107 | if (string.IsNullOrWhiteSpace(patch)) |
| 108 | return false; |
| 109 | |
| 110 | var removed = new List<string>(); |
| 111 | var added = new List<string>(); |
| 112 | var lines = patch.Replace("\r\n", "\n").Split('\n'); |
| 113 | |
| 114 | foreach (var line in lines) |
| 115 | { |
| 116 | if (line.StartsWith("---", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal)) |
| 117 | continue; |
| 118 | |
| 119 | if (line.StartsWith("-", StringComparison.Ordinal)) |
| 120 | removed.Add(line[1..]); |
| 121 | else if (line.StartsWith("+", StringComparison.Ordinal)) |
| 122 | added.Add(line[1..]); |
| 123 | } |
| 124 | |
| 125 | if (removed.Count == 0 || removed.Count != added.Count) |
| 126 | return false; |
| 127 | |
| 128 | for (var i = 0; i < removed.Count; i++) |
| 129 | { |
| 130 | var oldLine = removed[i]; |
| 131 | var newLine = added[i]; |
| 132 | if (oldLine.Length == 0 || oldLine[0] != '\uFEFF') |
| 133 | return false; |
| 134 | if (!string.Equals(oldLine[1..], newLine, StringComparison.Ordinal)) |
| 135 | return false; |
| 136 | } |
| 137 | |
| 138 | return true; |
| 139 | } |
| 140 | } |
| 141 | |