Forge
csharp15d33a96
1namespace GitMcp.Core;
2
3/// <summary>
4/// Lightweight classification of changed files into semantic and noise buckets.
5/// </summary>
6public 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
View only · write via MCP/CIDE