Skip to content

Commit b5819cc

Browse files
authored
fix(diff): present submodules also if no local path (gitextensions#12467)
* Use git-diff --raw output to get if a path is a submodule, instead of parsing .gitmodules (info was not available with --name-status). This allows submodules where the local path does not exist to be presented as added/removed. * Remove support for git-status -porcelain=1, no longer in use, to simplify the git-diff parsing code * Move GetDiffChangedFilesFromString tests from GitModuleTests to the related tests in GetAllChangedFilesOutputParser * A few Span<> optimizations to reduce allocations. * Remove handling of error strings in the parsed text, as stderr is no longer included in the output. * Remove a few unused test files.
1 parent 1bb9dad commit b5819cc

File tree

20 files changed

+117
-456
lines changed

20 files changed

+117
-456
lines changed
Lines changed: 53 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using GitExtensions.Extensibility;
1+
using System.Diagnostics;
2+
using GitExtensions.Extensibility;
23
using GitExtensions.Extensibility.Git;
34

45
namespace GitCommands.Git
@@ -28,14 +29,13 @@ public IReadOnlyList<GitItemStatus> Parse(string getAllChangedFilesCommandOutput
2829
}
2930

3031
/// <summary>
31-
/// Parse git-status --porcelain=1 and git-diff --name-status
32-
/// Outputs are similar, except that git-status has status for both worktree and index.
32+
/// Parse git-diff --raw
33+
/// format is similar to "git-status --porcelain=1 -z" and "git diff --name-status", no longer supported
3334
/// </summary>
3435
/// <param name="getAllChangedFilesCommandOutput">An output of <see cref="Commands.GetAllChangedFiles"/> command.</param>
35-
/// <param name="fromDiff">Parse git-diff.</param>
3636
/// <param name="staged">The staged status <see cref="GitItemStatus"/>, only relevant for git-diff (parsed for git-status).</param>
3737
/// <returns>list with the git items.</returns>
38-
internal List<GitItemStatus> GetAllChangedFilesFromString_v1(string getAllChangedFilesCommandOutput, bool fromDiff, StagedStatus staged)
38+
internal List<GitItemStatus> GetDiffChangedFilesFromString(string getAllChangedFilesCommandOutput, StagedStatus staged)
3939
{
4040
List<GitItemStatus> diffFiles = [];
4141

@@ -44,113 +44,69 @@ internal List<GitItemStatus> GetAllChangedFilesFromString_v1(string getAllChange
4444
return diffFiles;
4545
}
4646

47-
string trimmedStatus = RemoveWarnings(getAllChangedFilesCommandOutput);
48-
49-
// Doesn't work with removed submodules
50-
IReadOnlyList<string> submodules = GetModule().GetSubmodulesLocalPaths();
51-
5247
// Split all files on '\0' (WE NEED ALL COMMANDS TO BE RUN WITH -z! THIS IS ALSO IMPORTANT FOR ENCODING ISSUES!)
53-
string[] files = trimmedStatus.Split(Delimiters.Null, StringSplitOptions.RemoveEmptyEntries);
54-
for (int n = 0; n < files.Length; n++)
48+
string[] files = getAllChangedFilesCommandOutput.Split(Delimiters.Null, StringSplitOptions.RemoveEmptyEntries);
49+
for (int n = 0; n < files.Length; ++n)
5550
{
5651
if (string.IsNullOrEmpty(files[n]))
5752
{
5853
continue;
5954
}
6055

61-
int splitIndex;
62-
if (fromDiff)
63-
{
64-
splitIndex = -1;
65-
}
66-
else
56+
if (n >= files.Length - 1)
6757
{
68-
// Note that this fails for files with spaces (git-status --porcelain=1 is deprecated)
69-
splitIndex = files[n].IndexOfAny(Delimiters.TabAndSpace, 1);
58+
// no file name for last entry, ignore
59+
Trace.WriteLine($"No filename for last entry, ignoring: {files[n]}");
60+
continue;
7061
}
7162

72-
string status;
73-
string fileName;
63+
ReadOnlySpan<char> status = files[n].AsSpan();
64+
++n;
65+
ReadOnlySpan<char> fileName = files[n].AsSpan();
7466

75-
if (splitIndex < 0)
67+
if (status[0] != ':' || status.Length < 15)
7668
{
77-
if (n >= files.Length - 1)
78-
{
79-
// Illegal, ignore
80-
continue;
81-
}
82-
83-
status = files[n];
84-
fileName = files[n + 1];
85-
n++;
86-
}
87-
else
88-
{
89-
status = files[n][..splitIndex];
90-
fileName = files[n][splitIndex..];
69+
Trace.WriteLine($"Illegal status line: {fileName}");
70+
continue;
9171
}
9272

93-
char x = status[0];
94-
char y = status.Length > 1 ? status[1] : GitItemStatusConverter.UnmodifiedStatus_v1;
73+
int statusIndex = status.LastIndexOfAnyExceptInRange('0', '9');
74+
char x = status[statusIndex];
9575

96-
if (fromDiff && staged == StagedStatus.WorkTree && x == GitItemStatusConverter.UnmergedStatus)
76+
if (staged == StagedStatus.WorkTree && x == GitItemStatusConverter.UnmergedStatus)
9777
{
9878
// git-diff has two lines to inform that a file is modified and has a merge conflict
9979
continue;
10080
}
10181

102-
// Skip unmerged where both are modified: Only worktree interesting.
103-
if ((x != GitItemStatusConverter.UntrackedStatus && x != GitItemStatusConverter.IgnoredStatus && x != GitItemStatusConverter.UnmodifiedStatus_v1)
104-
|| x != GitItemStatusConverter.UnmergedStatus
105-
|| y != GitItemStatusConverter.UnmergedStatus)
82+
GitItemStatus gitItemStatusX = GitItemStatusConverter.FromStatusCharacter(staged, fileName.ToString(), x);
83+
if (x is GitItemStatusConverter.RenamedStatus or GitItemStatusConverter.CopiedStatus)
10684
{
107-
GitItemStatus gitItemStatusX;
108-
StagedStatus stagedX = fromDiff ? staged : StagedStatus.Index;
109-
if (x == GitItemStatusConverter.RenamedStatus || x == GitItemStatusConverter.CopiedStatus)
85+
gitItemStatusX.RenameCopyPercentage = status[(statusIndex + 1)..].ToString();
86+
gitItemStatusX.OldName = gitItemStatusX.Name;
87+
88+
if (n + 1 >= files.Length)
11089
{
111-
// Find renamed files...
112-
string nextFile = n + 1 < files.Length ? files[n + 1] : "";
113-
gitItemStatusX = GitItemStatusFromCopyRename(stagedX, fromDiff, nextFile, fileName, x, status);
114-
n++;
90+
Trace.WriteLine($"No next file for renamed entry: {status}::{gitItemStatusX.Name}");
11591
}
11692
else
11793
{
118-
gitItemStatusX = GitItemStatusConverter.FromStatusCharacter(stagedX, fileName, x);
94+
// Renamed file in an extra entry
95+
// No check if the entry seem to be a file (if it starts with '.' it is likely next status)
96+
++n;
97+
gitItemStatusX.Name = files[n].ToString();
11998
}
120-
121-
if (submodules.Contains(gitItemStatusX.Name))
122-
{
123-
gitItemStatusX.IsSubmodule = true;
124-
}
125-
126-
diffFiles.Add(gitItemStatusX);
127-
}
128-
129-
if (fromDiff || y == GitItemStatusConverter.UnmodifiedStatus_v1)
130-
{
131-
continue;
13299
}
133100

134-
GitItemStatus gitItemStatusY;
135-
StagedStatus stagedY = StagedStatus.WorkTree;
136-
if (y == GitItemStatusConverter.RenamedStatus || y == GitItemStatusConverter.CopiedStatus)
137-
{
138-
// Find renamed files...
139-
string nextFile = n + 1 < files.Length ? files[n + 1] : "";
140-
gitItemStatusY = GitItemStatusFromCopyRename(stagedY, false, nextFile, fileName, y, status);
141-
n++;
142-
}
143-
else
101+
const string subMode = "160000";
102+
if (status.Slice(1, 6).SequenceEqual(subMode) || status.Slice(8, 6).SequenceEqual(subMode))
144103
{
145-
gitItemStatusY = GitItemStatusConverter.FromStatusCharacter(stagedY, fileName, y);
104+
gitItemStatusX.IsSubmodule = true;
146105
}
147106

148-
if (submodules.Contains(gitItemStatusY.Name))
149-
{
150-
gitItemStatusY.IsSubmodule = true;
151-
}
107+
// ignore the sha in status (could be used to set TreeGuid)
152108

153-
diffFiles.Add(gitItemStatusY);
109+
diffFiles.Add(gitItemStatusX);
154110
}
155111

156112
return diffFiles;
@@ -170,16 +126,15 @@ private static IReadOnlyList<GitItemStatus> GetAllChangedFilesFromString_v2(stri
170126
return diffFiles;
171127
}
172128

173-
string trimmedStatus = RemoveWarnings(getAllChangedFilesCommandOutput);
174-
175129
// Split all files on '\0'
176-
string[] files = trimmedStatus.Split(Delimiters.Null, StringSplitOptions.RemoveEmptyEntries);
177-
for (int n = 0; n < files.Length; n++)
130+
string[] files = getAllChangedFilesCommandOutput.Split(Delimiters.Null, StringSplitOptions.RemoveEmptyEntries);
131+
for (int n = 0; n < files.Length; ++n)
178132
{
179-
string line = files[n];
180-
if (line.Length <= 2 || line[1] != ' ')
133+
ReadOnlySpan<char> line = files[n].AsSpan();
134+
if (line.Length <= 2 || line[1] != ' ' || line[0] == '#')
181135
{
182-
// Illegal info, like error output
136+
// Illegal info, like error output,
137+
// except '#' that is an ignored header
183138
continue;
184139
}
185140

@@ -188,7 +143,7 @@ private static IReadOnlyList<GitItemStatus> GetAllChangedFilesFromString_v2(stri
188143
if (entryType == GitItemStatusConverter.UntrackedStatus || entryType == GitItemStatusConverter.IgnoredStatus)
189144
{
190145
// Untracked and ignored with just the path following, supply dummy data for most info.
191-
string otherFileName = line[2..];
146+
string otherFileName = line[2..].ToString();
192147
const string NotSumoduleEntry = "N...";
193148
UpdateItemStatus(entryType, false, NotSumoduleEntry, otherFileName, null, null);
194149
continue;
@@ -217,27 +172,28 @@ private static IReadOnlyList<GitItemStatus> GetAllChangedFilesFromString_v2(stri
217172
string fileName;
218173
string? oldFileName = null;
219174
string? renamePercent = null;
220-
string subm = line.Substring(5, 4);
175+
string subm = line[5..9].ToString();
221176

222177
if (entryType == OrdinaryEntry)
223178
{
224-
DebugHelpers.Assert(line.Length > 113, "Cannot parse line:" + line);
225-
fileName = line[113..];
179+
DebugHelpers.Assert(line.Length > 113, $"Cannot parse line: {line}");
180+
fileName = line[113..].ToString();
226181
}
227182
else if (entryType == RenamedEntry)
228183
{
229-
DebugHelpers.Assert(n + 1 < files.Length, "Cannot parse renamed:" + line);
184+
DebugHelpers.Assert(n + 1 < files.Length, "Cannot parse renamed: {line}");
230185

231186
// Find renamed files...
232-
string[] renames = line[114..].Split(Delimiters.Space, 2);
233-
renamePercent = renames[0];
234-
fileName = renames[1];
187+
int pos = line[114..].IndexOfAnyExceptInRange('0', '9');
188+
DebugHelpers.Assert(pos >= 0, "Cannot find space separating rename: {line}");
189+
renamePercent = line[114..(114 + pos)].ToString();
190+
fileName = line[(114 + pos + 1)..].ToString();
235191
oldFileName = files[++n];
236192
}
237193
else if (entryType == UnmergedEntry)
238194
{
239-
DebugHelpers.Assert(line.Length > 161, "Cannot parse unmerged:" + line);
240-
fileName = line[161..];
195+
DebugHelpers.Assert(line.Length > 161, $"Cannot parse line: {line}");
196+
fileName = line[161..].ToString();
241197
}
242198
else
243199
{
@@ -292,79 +248,5 @@ void UpdateItemStatus(char x, bool isIndex, string subm, string fileName, string
292248
diffFiles.Add(gitItemStatus);
293249
}
294250
}
295-
296-
private static GitItemStatus GitItemStatusFromCopyRename(StagedStatus staged, bool fromDiff, string nextFile, string fileName, char x, string status)
297-
{
298-
GitItemStatus gitItemStatus;
299-
300-
// Find renamed files...
301-
if (fromDiff)
302-
{
303-
gitItemStatus = new GitItemStatus(name: nextFile);
304-
gitItemStatus.OldName = fileName;
305-
}
306-
else
307-
{
308-
gitItemStatus = new GitItemStatus(name: fileName);
309-
gitItemStatus.OldName = nextFile;
310-
}
311-
312-
gitItemStatus.IsNew = false;
313-
gitItemStatus.IsChanged = false;
314-
gitItemStatus.IsDeleted = false;
315-
if (x == GitItemStatusConverter.RenamedStatus)
316-
{
317-
gitItemStatus.IsRenamed = true;
318-
}
319-
else
320-
{
321-
gitItemStatus.IsCopied = true;
322-
}
323-
324-
gitItemStatus.IsTracked = true;
325-
if (status.Length > 2)
326-
{
327-
gitItemStatus.RenameCopyPercentage = status[1..];
328-
}
329-
330-
gitItemStatus.Staged = staged;
331-
332-
return gitItemStatus;
333-
}
334-
335-
private IGitModule GetModule()
336-
{
337-
IGitModule module = _getModule();
338-
339-
if (module is null)
340-
{
341-
throw new ArgumentException($"Require a valid instance of {nameof(IGitModule)}");
342-
}
343-
344-
return module;
345-
}
346-
347-
private static string RemoveWarnings(string statusString)
348-
{
349-
// The status string from git-diff can show warnings. See tests
350-
string trimmedStatus = statusString;
351-
int lastNewLinePos = trimmedStatus.LastIndexOfAny(Delimiters.LineFeedAndCarriageReturn);
352-
while (lastNewLinePos >= 0)
353-
{
354-
if (lastNewLinePos == 0)
355-
{
356-
trimmedStatus = trimmedStatus.Remove(0, 1);
357-
break;
358-
}
359-
360-
// Error always end with \n and start at previous index
361-
int ind = trimmedStatus.LastIndexOfAny(Delimiters.LineFeedAndCarriageReturnAndNull, lastNewLinePos - 1);
362-
363-
trimmedStatus = trimmedStatus.Remove(ind + 1, lastNewLinePos - ind);
364-
lastNewLinePos = trimmedStatus.LastIndexOfAny(Delimiters.LineFeedAndCarriageReturn);
365-
}
366-
367-
return trimmedStatus;
368-
}
369251
}
370252
}

src/app/GitCommands/Git/GitModule.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2430,7 +2430,7 @@ public async Task<ExecutionResult> GetGrepFileAsync(
24302430
cancellationToken: cancellationToken);
24312431
}
24322432

2433-
public ExecutionResult GetDiffFiles(string? firstRevision, string? secondRevision, bool noCache = false, bool nullSeparated = false, CancellationToken cancellationToken = default)
2433+
public ExecutionResult GetDiffFiles(string? firstRevision, string? secondRevision, bool noCache = false, bool rawParsable = false, CancellationToken cancellationToken = default)
24342434
{
24352435
noCache = noCache || firstRevision.IsArtificial() || secondRevision.IsArtificial();
24362436

@@ -2447,8 +2447,8 @@ public ExecutionResult GetDiffFiles(string? firstRevision, string? secondRevisio
24472447
"--no-ext-diff",
24482448
"--find-renames",
24492449
"--find-copies",
2450-
"--name-status",
2451-
{ nullSeparated, "-z" },
2450+
{ rawParsable, "--raw", "--name-status" },
2451+
{ rawParsable, "-z" },
24522452
_revisionDiffProvider.Get(firstRevision, secondRevision)
24532453
},
24542454
cache: noCache ? null : GitCommandCache,
@@ -2505,7 +2505,7 @@ public IReadOnlyList<GitItemStatus> GetDiffFilesWithUntracked(string? firstRevis
25052505
return status.Where(x => x.Staged == stagedStatus || x.IsStatusOnly).ToList();
25062506
}
25072507

2508-
ExecutionResult exec = GetDiffFiles(firstRevision, secondRevision, noCache: noCache, nullSeparated: true, cancellationToken);
2508+
ExecutionResult exec = GetDiffFiles(firstRevision, secondRevision, noCache: noCache, rawParsable: true, cancellationToken);
25092509
List<GitItemStatus> result = GetDiffChangedFilesFromString(exec.StandardOutput, stagedStatus);
25102510
if (!exec.ExitedSuccessfully)
25112511
{
@@ -2705,7 +2705,7 @@ public IReadOnlyList<GitItemStatus> GetIndexFiles()
27052705
"--find-renames",
27062706
"--find-copies",
27072707
"-z",
2708-
"--name-status",
2708+
"--raw",
27092709
"--cached"
27102710
};
27112711
ExecutionResult exec = _gitExecutable.Execute(args, throwOnErrorExit: false);
@@ -2730,14 +2730,14 @@ public IReadOnlyList<GitItemStatus> GetIndexFiles()
27302730
}
27312731

27322732
/// <summary>
2733-
/// Parse the output from git-diff --name-status.
2733+
/// Parse the output from git-diff --raw.
27342734
/// </summary>
27352735
/// <param name="statusString">output from the git command.</param>
27362736
/// <param name="staged">required to determine if <see cref="StagedStatus"/> allows stage/unstage.</param>
27372737
/// <returns>list with the parsed GitItemStatus.</returns>
27382738
/// <seealso href="https://git-scm.com/docs/git-diff"/>
27392739
private List<GitItemStatus> GetDiffChangedFilesFromString(string statusString, StagedStatus staged)
2740-
=> _getAllChangedFilesOutputParser.GetAllChangedFilesFromString_v1(statusString, true, staged);
2740+
=> _getAllChangedFilesOutputParser.GetDiffChangedFilesFromString(statusString, staged);
27412741

27422742
public IReadOnlyList<GitItemStatus> GetIndexFilesWithSubmodulesStatus()
27432743
{
@@ -2774,10 +2774,16 @@ public bool IsDirtyDir()
27742774

27752775
public async Task<Patch?> GetCurrentChangesAsync(string? fileName, string? oldFileName, bool staged, string extraDiffArguments, Encoding? encoding = null, bool noLocks = false)
27762776
{
2777-
string output = await _gitExecutable.GetOutputAsync(Commands.GetCurrentChanges(fileName, oldFileName, staged, extraDiffArguments, noLocks),
2778-
outputEncoding: LosslessEncoding).ConfigureAwait(false);
2777+
ExecutionResult result = await _gitExecutable.ExecuteAsync(Commands.GetCurrentChanges(fileName, oldFileName, staged, extraDiffArguments, noLocks),
2778+
outputEncoding: LosslessEncoding, throwOnErrorExit: false).ConfigureAwait(false);
2779+
if (!result.ExitedSuccessfully)
2780+
{
2781+
// occurs if a submodule in a submodule does not exist
2782+
Trace.WriteLine($"Failure to get current changes: {result.AllOutput}");
2783+
return null;
2784+
}
27792785

2780-
IReadOnlyList<Patch> patches = PatchProcessor.CreatePatchesFromString(output, new Lazy<Encoding>(() => encoding ?? FilesEncoding)).ToList();
2786+
IReadOnlyList<Patch> patches = PatchProcessor.CreatePatchesFromString(result.StandardOutput, new Lazy<Encoding>(() => encoding ?? FilesEncoding)).ToList();
27812787

27822788
return GetPatch(patches, fileName, oldFileName);
27832789
}

0 commit comments

Comments
 (0)