From 5225271f038af74ee57dc3a85cdb014a5420b662 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:26:10 +0000 Subject: [PATCH 1/3] [patch] Read quoted and " b/"-containing paths in Patch() headers The diff --git header was split at the last " b/", so a path such as "Plan b/notes.txt" came back as "notes.txt", and a header whose paths git had C-quoted threw GitParseException for the whole patch. Headers without a rename are now split at the midpoint, as git's own git_header_name does, and quoted operands and rename from/to lines are decoded, octal escapes included. Fixes ktsu-dev/GitIntegration#120 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKxYoUXQqgRKJR6ynbvnms --- .../Fixtures/patch-b-slash-in-path.txt | 8 + .../Fixtures/patch-quoted-path.txt | 8 + .../Fixtures/patch-quoted-rename.txt | 4 + .../Parsing/GitPatchParserTests.cs | 54 +++++++ GitIntegration/Parsing/GitPatchParser.cs | 146 +++++++++++++++++- 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 GitIntegration.Test/Fixtures/patch-b-slash-in-path.txt create mode 100644 GitIntegration.Test/Fixtures/patch-quoted-path.txt create mode 100644 GitIntegration.Test/Fixtures/patch-quoted-rename.txt 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..0a09214 100644 --- a/GitIntegration.Test/Parsing/GitPatchParserTests.cs +++ b/GitIntegration.Test/Parsing/GitPatchParserTests.cs @@ -63,6 +63,60 @@ 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", + 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 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..de17a6b 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. + private 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)) From f04dd6bd056e0ccdfda35a60d2e625750d9d4eb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:30:28 +0000 Subject: [PATCH 2/3] [patch] Compare the parsed path in its platform form RelativeFilePath normalizes separators, so on Windows the parsed "Plan b/notes.txt" reads back as "Plan b\notes.txt". Compare against the same value built as a RelativeFilePath, as the builder tests do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKxYoUXQqgRKJR6ynbvnms --- GitIntegration.Test/Parsing/GitPatchParserTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/GitIntegration.Test/Parsing/GitPatchParserTests.cs b/GitIntegration.Test/Parsing/GitPatchParserTests.cs index 0a09214..33871e9 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 { @@ -69,7 +72,7 @@ public void ReadsAPathThatContainsTheNewSidePrefix() GitFilePatch file = GitPatchParser.Parse(Fixture("patch-b-slash-in-path.txt")).Files.Single(); Assert.AreEqual( - "Plan b/notes.txt", + "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); From 5edce143f1c789c4cef6cafbac47b6140eac85fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:43:01 +0000 Subject: [PATCH 3/3] [patch] Cover the quoted-path decoder's escapes and error paths UnquotePath is now internal so its escape table can be tested directly; most escapes decode to control characters that RelativeFilePath refuses on Windows, so they cannot go through a parsed header. Adds header cases for one side quoted and the other not, and for malformed quoting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKxYoUXQqgRKJR6ynbvnms --- .../Parsing/GitPatchParserTests.cs | 66 +++++++++++++++++++ GitIntegration/Parsing/GitPatchParser.cs | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/GitIntegration.Test/Parsing/GitPatchParserTests.cs b/GitIntegration.Test/Parsing/GitPatchParserTests.cs index 33871e9..21a3a31 100644 --- a/GitIntegration.Test/Parsing/GitPatchParserTests.cs +++ b/GitIntegration.Test/Parsing/GitPatchParserTests.cs @@ -109,6 +109,72 @@ public void DecodesOctalEscapesAsUtf8Bytes() 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() { diff --git a/GitIntegration/Parsing/GitPatchParser.cs b/GitIntegration/Parsing/GitPatchParser.cs index de17a6b..b674879 100644 --- a/GitIntegration/Parsing/GitPatchParser.cs +++ b/GitIntegration/Parsing/GitPatchParser.cs @@ -278,7 +278,7 @@ private static int QuotedOperandEnd(string text, int start, string line) /// The header line, for the error message. /// The path as it is named on disk. /// The quoting is malformed. - private static string UnquotePath(string value, string line) + internal static string UnquotePath(string value, string line) { if (!value.StartsWith('"')) {