Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions GitIntegration.Test/Fixtures/patch-b-slash-in-path.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
diff --git a/Plan b/notes.txt b/Plan b/notes.txt
index 814f4a4..879de50 100644
--- a/Plan b/notes.txt
+++ b/Plan b/notes.txt
@@ -1,2 +1,2 @@
one
-two
+TWO
8 changes: 8 additions & 0 deletions GitIntegration.Test/Fixtures/patch-quoted-path.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
diff --git "a/say \"hi\".txt" "b/say \"hi\".txt"
index 814f4a4..879de50 100644
--- "a/say \"hi\".txt"
+++ "b/say \"hi\".txt"
@@ -1,2 +1,2 @@
one
-two
+TWO
4 changes: 4 additions & 0 deletions GitIntegration.Test/Fixtures/patch-quoted-rename.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
diff --git "a/old \"x\".txt" "b/new \"y\".txt"
similarity index 100%
rename from "old \"x\".txt"
rename to "new \"y\".txt"
123 changes: 123 additions & 0 deletions GitIntegration.Test/Parsing/GitPatchParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
using System.IO;
using System.Linq;

using ktsu.Semantics.Paths;
using ktsu.Semantics.Strings;

[TestClass]
public class GitPatchParserTests
{
Expand Down Expand Up @@ -63,6 +66,126 @@
"A rename can carry content changes too, and treating rename as hunkless drops them.");
}

[TestMethod]
public void ReadsAPathThatContainsTheNewSidePrefix()
{
GitFilePatch file = GitPatchParser.Parse(Fixture("patch-b-slash-in-path.txt")).Files.Single();

Assert.AreEqual(
"Plan b/notes.txt".As<RelativeFilePath>().WeakString,
file.Path.WeakString,
"The last ' b/' in 'a/Plan b/notes.txt b/Plan b/notes.txt' is inside the path itself.");
Assert.AreEqual(GitChangeKind.Modified, file.Kind);
Assert.AreEqual(1, file.Hunks.Count);

Check warning on line 79 in GitIntegration.Test/Parsing/GitPatchParserTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGYBLAduN7hzMOc5x&open=AaDjGYBLAduN7hzMOc5x&pullRequest=129
}

[TestMethod]
public void DecodesAPathGitQuoted()
{
GitFilePatch file = GitPatchParser.Parse(Fixture("patch-quoted-path.txt")).Files.Single();

Assert.AreEqual("say \"hi\".txt", file.Path.WeakString);
Assert.AreEqual(1, file.Hunks.Count);

Check warning on line 88 in GitIntegration.Test/Parsing/GitPatchParserTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGYBLAduN7hzMOc5y&open=AaDjGYBLAduN7hzMOc5y&pullRequest=129
}

[TestMethod]
public void DecodesQuotedRenamePaths()
{
GitFilePatch file = GitPatchParser.Parse(Fixture("patch-quoted-rename.txt")).Files.Single();

Assert.AreEqual(GitChangeKind.Renamed, file.Kind);
Assert.AreEqual("old \"x\".txt", file.OriginalPath?.WeakString);
Assert.AreEqual("new \"y\".txt", file.Path.WeakString);
}

[TestMethod]
public void DecodesOctalEscapesAsUtf8Bytes()
{
const string output = "diff --git \"a/caf\\303\\251\\\"q\\\".txt\" \"b/caf\\303\\251\\\"q\\\".txt\"\n"
+ "index 814f4a4..879de50 100644\n";

GitFilePatch file = GitPatchParser.Parse(output).Files.Single();

Assert.AreEqual("caf\u00e9\"q\".txt", file.Path.WeakString);
}

[TestMethod]
public void DecodesEveryNamedEscape()
{
Assert.AreEqual(
"\a\b\t\n\v\f\r\"\\",
GitPatchParser.UnquotePath("\"\\a\\b\\t\\n\\v\\f\\r\\\"\\\\\"", "line"));
}

[TestMethod]
public void LeavesAnUnquotedPathAlone()
{
Assert.AreEqual("plain.txt", GitPatchParser.UnquotePath("plain.txt", "line"));
}

[TestMethod]
public void KeepsCharactersOutsideTheBasicPlane()
{
Assert.AreEqual("a\U0001F600b.txt", GitPatchParser.UnquotePath("\"a\U0001F600b.txt\"", "line"));
}

[TestMethod]
public void RefusesMalformedQuoting()
{
_ = Assert.ThrowsExactly<GitParseException>(() => GitPatchParser.UnquotePath("\"a\\qb\"", "line"));
_ = Assert.ThrowsExactly<GitParseException>(() => GitPatchParser.UnquotePath("\"never closed", "line"));
_ = Assert.ThrowsExactly<GitParseException>(() => GitPatchParser.UnquotePath("\"a\" trailing", "line"));
}

[TestMethod]
public void ReadsAQuotedNewSideAfterAnUnquotedOldSide()
{
const string output = "diff --git a/plain.txt \"b/new \\\"y\\\".txt\"\n"
+ "similarity index 100%\n"
+ "rename from plain.txt\n"
+ "rename to \"new \\\"y\\\".txt\"\n";

GitFilePatch file = GitPatchParser.Parse(output).Files.Single();

Assert.AreEqual(GitChangeKind.Renamed, file.Kind);
Assert.AreEqual("plain.txt", file.OriginalPath?.WeakString);
Assert.AreEqual("new \"y\".txt", file.Path.WeakString);
}

[TestMethod]
public void ReadsAnUnquotedNewSideAfterAQuotedOldSide()
{
const string output = "diff --git \"a/old \\\"x\\\".txt\" b/plain.txt\n"
+ "similarity index 100%\n"
+ "rename from \"old \\\"x\\\".txt\"\n"
+ "rename to plain.txt\n";

GitFilePatch file = GitPatchParser.Parse(output).Files.Single();

Assert.AreEqual("old \"x\".txt", file.OriginalPath?.WeakString);
Assert.AreEqual("plain.txt", file.Path.WeakString);
}

[TestMethod]
public void RefusesAQuotedHeaderWithNoNewSidePath()
{
_ = Assert.ThrowsExactly<GitParseException>(
() => GitPatchParser.Parse("diff --git \"a/x.txt\" \"c/x.txt\"\n"));
_ = Assert.ThrowsExactly<GitParseException>(
() => GitPatchParser.Parse("diff --git a/x.txt \"c/x.txt\"\n"));
}

[TestMethod]
public void OneQuotedPathDoesNotBreakTheOtherFiles()
{
string output = Fixture("patch-quoted-path.txt") + Fixture("patch-two-hunks.txt");

GitPatch patch = GitPatchParser.Parse(output);

Assert.AreEqual(2, patch.Files.Count);

Check warning on line 185 in GitIntegration.Test/Parsing/GitPatchParserTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGYBLAduN7hzMOc5z&open=AaDjGYBLAduN7hzMOc5z&pullRequest=129
Assert.AreEqual("f.txt", patch.Files[1].Path.WeakString);
}

[TestMethod]
public void FlagsABinaryFileAndGivesItNoHunks()
{
Expand Down
146 changes: 141 additions & 5 deletions GitIntegration/Parsing/GitPatchParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;

using ktsu.Semantics.Paths;

Expand Down Expand Up @@ -106,7 +107,7 @@
line.StartsWith(GitHeaderPrefix, StringComparison.Ordinal) ||
line.StartsWith(CombinedHeaderPrefix, StringComparison.Ordinal);

private static GitFilePatch ParseFile(string output, List<(int Start, int End)> lines, ref int index)

Check warning on line 110 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

Check warning on line 110 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
{
int fileStart = lines[index].Start;
string firstLine = Line(output, lines, index);
Expand Down Expand Up @@ -185,27 +186,162 @@
/// Reads the path to use until a more specific header line overrides it: the combined format's
/// single path, or the ordinary format's new-side path.
/// </summary>
/// <remarks>
/// Without a rename or copy, both sides of <c>a/P b/P</c> name the same path, so the header is
/// split at its midpoint, as git's own <c>git_header_name</c> does. Searching for <c>" b/"</c>
/// cannot work there, because the path itself may contain that text. A rename's header is
/// ambiguous in the same way, and its <c>rename to</c> line replaces the guess made here.
/// </remarks>
/// <param name="line">The <c>diff --git</c> or <c>diff --cc</c> line that starts the file.</param>
/// <returns>The path found on that line.</returns>
/// <exception cref="GitParseException">The line does not carry a recognizable path.</exception>
private static string ReadPathFromFileStart(string line)
{
if (line.StartsWith(CombinedHeaderPrefix, StringComparison.Ordinal))
{
return line[CombinedHeaderPrefix.Length..];
return UnquotePath(line[CombinedHeaderPrefix.Length..], line);
}

string remainder = line[GitHeaderPrefix.Length..];

// Searched from the end rather than the first match, because the old-side path can itself
// contain the literal text " b/" as part of a directory or file name.
// A path git had to C-quote puts the whole operand, prefix included, inside the quotes.
if (remainder.StartsWith('"'))
{
int end = QuotedOperandEnd(remainder, 0, line);
return StripNewSidePrefix(UnquotePath(remainder[(end + 1)..].TrimStart(' '), line), line);
}

if (remainder.EndsWith('"'))
{
int start = remainder.LastIndexOf(" \"b/", StringComparison.Ordinal);

return start < 0
? throw new GitParseException($"A diff header has no recognizable new-side path: '{line}'.")
: StripNewSidePrefix(UnquotePath(remainder[(start + 1)..], line), line);
}

if (remainder.Length % 2 == 1)
{
int middle = remainder.Length / 2;
string oldSide = remainder[..middle];
string newSide = remainder[(middle + 1)..];

if (remainder[middle] == ' ' &&
oldSide.StartsWith("a/", StringComparison.Ordinal) &&
newSide.StartsWith("b/", StringComparison.Ordinal) &&
oldSide.AsSpan(2).SequenceEqual(newSide.AsSpan(2)))
{
return newSide[2..];
}
}

// The two sides differ, so this is a rename or copy whose own header line names the
// new path. The guess only has to be a valid path until that line replaces it.
int split = remainder.LastIndexOf(BSidePathMarker, StringComparison.Ordinal);

return split < 0
? throw new GitParseException($"A diff header has no recognizable new-side path: '{line}'.")
: remainder[(split + BSidePathMarker.Length)..];
}

private static string StripNewSidePrefix(string operand, string line) =>
operand.StartsWith("b/", StringComparison.Ordinal)
? operand[2..]
: throw new GitParseException($"A diff header has no recognizable new-side path: '{line}'.");

/// <summary>
/// Finds the closing quote of a C-quoted operand that opens at <paramref name="start"/>.
/// </summary>
private static int QuotedOperandEnd(string text, int start, string line)
{
for (int position = start + 1; position < text.Length; position++)
{
if (text[position] == '\\')
{
position++;

Check warning on line 261 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not update the stop condition variable 'position' in the body of the for loop.

Check warning on line 261 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not update the stop condition variable 'position' in the body of the for loop.

Check warning on line 261 in GitIntegration/Parsing/GitPatchParser.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not update the stop condition variable 'position' in the body of the for loop.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGX-XAduN7hzMOc5s&open=AaDjGX-XAduN7hzMOc5s&pullRequest=129
}
else if (text[position] == '"')
{
return position;
}
}

throw new GitParseException($"A quoted path in a diff header is not closed: '{line}'.");
}

/// <summary>
/// Decodes a path git printed with C-style quoting, or returns it unchanged when it is not
/// quoted. <c>core.quotepath=false</c> stops git quoting non-ASCII bytes, but a name holding a
/// double quote, a backslash, a tab or another control character is quoted regardless.
/// </summary>
/// <param name="value">The path as git printed it.</param>
/// <param name="line">The header line, for the error message.</param>
/// <returns>The path as it is named on disk.</returns>
/// <exception cref="GitParseException">The quoting is malformed.</exception>
internal static string UnquotePath(string value, string line)

Check warning on line 281 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

Check warning on line 281 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

Check failure on line 281 in GitIntegration/Parsing/GitPatchParser.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGX-XAduN7hzMOc5t&open=AaDjGX-XAduN7hzMOc5t&pullRequest=129
{
if (!value.StartsWith('"'))
{
return value;
}

if (QuotedOperandEnd(value, 0, line) != value.Length - 1)
{
throw new GitParseException($"A quoted path in a diff header has trailing text: '{line}'.");
}

// Octal escapes carry raw bytes, so the name is rebuilt as UTF-8 and decoded once at the end.
List<byte> bytes = [];
Span<byte> encoded = stackalloc byte[4];

for (int position = 1; position < value.Length - 1; position++)
{
char character = value[position];

if (character != '\\')
{
int length = char.IsHighSurrogate(character) && position + 1 < value.Length - 1
? Encoding.UTF8.GetBytes(value.AsSpan(position++, 2), encoded)

Check warning on line 304 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not update the stop condition variable 'position' in the body of the for loop.

Check warning on line 304 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Do not update the stop condition variable 'position' in the body of the for loop.

Check warning on line 304 in GitIntegration/Parsing/GitPatchParser.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not update the stop condition variable 'position' in the body of the for loop.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGX-XAduN7hzMOc5u&open=AaDjGX-XAduN7hzMOc5u&pullRequest=129
: Encoding.UTF8.GetBytes(value.AsSpan(position, 1), encoded);

for (int offset = 0; offset < length; offset++)
{
bytes.Add(encoded[offset]);
}

continue;
}

char escape = value[++position];

Check warning on line 315 in GitIntegration/Parsing/GitPatchParser.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not update the stop condition variable 'position' in the body of the for loop.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGX-XAduN7hzMOc5v&open=AaDjGX-XAduN7hzMOc5v&pullRequest=129

if (escape is >= '0' and <= '3' &&
position + 2 < value.Length - 1 &&
value[position + 1] is >= '0' and <= '7' &&
value[position + 2] is >= '0' and <= '7')
{
bytes.Add((byte)(((escape - '0') << 6) | ((value[position + 1] - '0') << 3) | (value[position + 2] - '0')));
position += 2;

Check warning on line 323 in GitIntegration/Parsing/GitPatchParser.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not update the stop condition variable 'position' in the body of the for loop.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaDjGX-XAduN7hzMOc5w&open=AaDjGX-XAduN7hzMOc5w&pullRequest=129
continue;
}

bytes.Add(escape switch
{
'a' => (byte)'\a',
'b' => (byte)'\b',
't' => (byte)'\t',
'n' => (byte)'\n',
'v' => (byte)'\v',
'f' => (byte)'\f',
'r' => (byte)'\r',
'"' => (byte)'"',
'\\' => (byte)'\\',
_ => throw new GitParseException($"A quoted path in a diff header has an unknown escape '\\{escape}': '{line}'."),
});
}

return Encoding.UTF8.GetString([.. bytes]);
}

private static void ApplyHeaderLine(
string line,
ref GitChangeKind kind,
Expand All @@ -215,12 +351,12 @@
{
if (line.StartsWith(RenameFromPrefix, StringComparison.Ordinal))
{
originalPath = GitParseValues.ToRelativeFilePath(line[RenameFromPrefix.Length..]);
originalPath = GitParseValues.ToRelativeFilePath(UnquotePath(line[RenameFromPrefix.Length..], line));
kind = GitChangeKind.Renamed;
}
else if (line.StartsWith(RenameToPrefix, StringComparison.Ordinal))
{
path = GitParseValues.ToRelativeFilePath(line[RenameToPrefix.Length..]);
path = GitParseValues.ToRelativeFilePath(UnquotePath(line[RenameToPrefix.Length..], line));
kind = GitChangeKind.Renamed;
}
else if (line.StartsWith(NewFileModePrefix, StringComparison.Ordinal))
Expand All @@ -238,7 +374,7 @@
}
}

private static GitHunk ParseHunk(string output, List<(int Start, int End)> lines, ref int index)

Check warning on line 377 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 377 in GitIntegration/Parsing/GitPatchParser.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
int hunkStart = lines[index].Start;
(int oldStart, int oldCount, int newStart, int newCount, string heading) =
Expand Down
Loading