diff --git a/GitIntegration.Test/Fixtures/patch-b-slash-in-path.txt b/GitIntegration.Test/Fixtures/patch-b-slash-in-path.txt new file mode 100644 index 0000000..c35d6e1 --- /dev/null +++ b/GitIntegration.Test/Fixtures/patch-b-slash-in-path.txt @@ -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 diff --git a/GitIntegration.Test/Fixtures/patch-quoted-path.txt b/GitIntegration.Test/Fixtures/patch-quoted-path.txt new file mode 100644 index 0000000..c3fd6ae --- /dev/null +++ b/GitIntegration.Test/Fixtures/patch-quoted-path.txt @@ -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 diff --git a/GitIntegration.Test/Fixtures/patch-quoted-rename.txt b/GitIntegration.Test/Fixtures/patch-quoted-rename.txt new file mode 100644 index 0000000..1578713 --- /dev/null +++ b/GitIntegration.Test/Fixtures/patch-quoted-rename.txt @@ -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" diff --git a/GitIntegration.Test/Parsing/GitPatchParserTests.cs b/GitIntegration.Test/Parsing/GitPatchParserTests.cs index eac6db7..21a3a31 100644 --- a/GitIntegration.Test/Parsing/GitPatchParserTests.cs +++ b/GitIntegration.Test/Parsing/GitPatchParserTests.cs @@ -6,6 +6,9 @@ namespace ktsu.GitIntegration.Test; using System.IO; using System.Linq; +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; + [TestClass] public class GitPatchParserTests { @@ -63,6 +66,126 @@ public void ReadsARenamedFileThatAlsoChanged() "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().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); + } + + [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); + } + + [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(() => GitPatchParser.UnquotePath("\"a\\qb\"", "line")); + _ = Assert.ThrowsExactly(() => GitPatchParser.UnquotePath("\"never closed", "line")); + _ = Assert.ThrowsExactly(() => 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( + () => GitPatchParser.Parse("diff --git \"a/x.txt\" \"c/x.txt\"\n")); + _ = Assert.ThrowsExactly( + () => 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); + Assert.AreEqual("f.txt", patch.Files[1].Path.WeakString); + } + [TestMethod] public void FlagsABinaryFileAndGivesItNoHunks() { diff --git a/GitIntegration/Parsing/GitPatchParser.cs b/GitIntegration/Parsing/GitPatchParser.cs index 453c4a9..b674879 100644 --- a/GitIntegration/Parsing/GitPatchParser.cs +++ b/GitIntegration/Parsing/GitPatchParser.cs @@ -5,6 +5,7 @@ namespace ktsu.GitIntegration; using System; using System.Collections.Generic; using System.Globalization; +using System.Text; using ktsu.Semantics.Paths; @@ -185,6 +186,12 @@ private static GitFilePatch ParseFile(string output, List<(int Start, int End)> /// 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. /// + /// + /// Without a rename or copy, both sides of a/P b/P name the same path, so the header is + /// split at its midpoint, as git's own git_header_name does. Searching for " b/" + /// cannot work there, because the path itself may contain that text. A rename's header is + /// ambiguous in the same way, and its rename to line replaces the guess made here. + /// /// The diff --git or diff --cc line that starts the file. /// The path found on that line. /// The line does not carry a recognizable path. @@ -192,13 +199,44 @@ 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 @@ -206,6 +244,104 @@ private static string ReadPathFromFileStart(string 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}'."); + + /// + /// Finds the closing quote of a C-quoted operand that opens at . + /// + private static int QuotedOperandEnd(string text, int start, string line) + { + for (int position = start + 1; position < text.Length; position++) + { + if (text[position] == '\\') + { + position++; + } + else if (text[position] == '"') + { + return position; + } + } + + throw new GitParseException($"A quoted path in a diff header is not closed: '{line}'."); + } + + /// + /// Decodes a path git printed with C-style quoting, or returns it unchanged when it is not + /// quoted. core.quotepath=false stops git quoting non-ASCII bytes, but a name holding a + /// double quote, a backslash, a tab or another control character is quoted regardless. + /// + /// The path as git printed it. + /// The header line, for the error message. + /// The path as it is named on disk. + /// The quoting is malformed. + internal static string UnquotePath(string value, string line) + { + 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 bytes = []; + Span 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) + : Encoding.UTF8.GetBytes(value.AsSpan(position, 1), encoded); + + for (int offset = 0; offset < length; offset++) + { + bytes.Add(encoded[offset]); + } + + continue; + } + + char escape = value[++position]; + + 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; + 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, @@ -215,12 +351,12 @@ private static void ApplyHeaderLine( { 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))