fix(ui): UnifiedDiffParser handles paths with spaces and git-quoted paths

# Conflicts:
#	docs/open.md
This commit is contained in:
mika kuns
2026-07-23 20:07:53 +02:00
3 changed files with 206 additions and 10 deletions
@@ -1,3 +1,5 @@
using System.Text;
namespace ClaudeDo.Ui.ViewModels.Modals;
/// Shared unified-diff parser used by both the per-task diff viewer and the
@@ -16,10 +18,13 @@ public static class UnifiedDiffParser
{
if (line.StartsWith("diff --git ", StringComparison.Ordinal))
{
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs"
var parts = line.Split(' ');
var path = parts.Length >= 4 ? parts[3][2..] : line;
current = new DiffFileViewModel { Path = path };
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs". Paths may each be
// git-quoted (C-style, octal-escaped) when they contain non-ASCII
// bytes, and unquoted paths may themselves contain spaces, so the
// two paths can't be split on a plain ' ' - this is only a best
// effort default; "--- "/"+++ "/rename lines below correct it.
var (_, newPath) = SplitGitHeaderPaths(line["diff --git ".Length..]);
current = new DiffFileViewModel { Path = newPath };
files.Add(current);
oldLine = 0; newLine = 0;
continue;
@@ -41,13 +46,13 @@ public static class UnifiedDiffParser
if (line.StartsWith("rename from ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.OldPath = line["rename from ".Length..];
current.OldPath = UnquotePath(line["rename from ".Length..]);
continue;
}
if (line.StartsWith("rename to ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.Path = line["rename to ".Length..];
current.Path = UnquotePath(line["rename to ".Length..]);
continue;
}
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
@@ -64,10 +69,27 @@ public static class UnifiedDiffParser
continue;
}
// "--- a/..." / "+++ b/..." each carry a single unambiguous path (unlike
// the two-paths-on-one-line "diff --git" header above), so use them to
// correct the file's identity - git also appends a disambiguating tab
// after unquoted paths that contain whitespace, which must be trimmed.
if (line.StartsWith("--- ", StringComparison.Ordinal))
{
var oldPath = UnquotePath(line[4..].TrimEnd('\t'));
if (oldPath != "/dev/null" && current.Status == DiffFileStatus.Deleted)
current.Path = StripPrefix(oldPath, "a/");
continue;
}
if (line.StartsWith("+++ ", StringComparison.Ordinal))
{
var newPath = UnquotePath(line[4..].TrimEnd('\t'));
if (newPath != "/dev/null" && current.Status != DiffFileStatus.Renamed)
current.Path = StripPrefix(newPath, "b/");
continue;
}
// Skip remaining diff metadata lines
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
line.StartsWith("index ", StringComparison.Ordinal) ||
if (line.StartsWith("index ", StringComparison.Ordinal) ||
line.StartsWith("old mode", StringComparison.Ordinal) ||
line.StartsWith("new mode", StringComparison.Ordinal) ||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
@@ -140,4 +162,111 @@ public static class UnifiedDiffParser
newStart = n;
}
}
/// Splits a "diff --git" header's remainder ("a/<old> b/<new>", where
/// either side may be individually git-quoted) into its two paths.
private static (string OldPath, string NewPath) SplitGitHeaderPaths(string content)
{
string oldToken, newToken;
if (content.Length > 0 && content[0] == '"')
{
var close = FindClosingQuote(content, 0);
oldToken = content[..(close + 1)];
newToken = content[(close + 1)..].TrimStart(' ');
}
else
{
var quoteAt = content.IndexOf('"');
if (quoteAt >= 0)
{
// Mixed quoting: an unquoted path never contains a literal quote
// (git would have quoted it), so the quote marks the other side.
oldToken = content[..quoteAt].TrimEnd(' ');
newToken = content[quoteAt..];
}
else
{
var splitAt = content.IndexOf(" b/", StringComparison.Ordinal);
oldToken = splitAt < 0 ? content : content[..splitAt];
newToken = splitAt < 0 ? content : content[(splitAt + 1)..];
}
}
return (StripPrefix(UnquotePath(oldToken), "a/"), StripPrefix(UnquotePath(newToken), "b/"));
}
private static int FindClosingQuote(string s, int start)
{
var i = start + 1;
while (i < s.Length)
{
if (s[i] == '\\') { i += 2; continue; }
if (s[i] == '"') return i;
i++;
}
return s.Length - 1;
}
private static string StripPrefix(string path, string prefix) =>
path.StartsWith(prefix, StringComparison.Ordinal) ? path[prefix.Length..] : path;
/// Reverses git's C-style path quoting: a quoted token is wrapped in double
/// quotes with '\\', '"' and non-ASCII bytes escaped as \nnn octal sequences
/// (used whenever core.quotePath would otherwise consider the path "unusual").
private static string UnquotePath(string token)
{
if (token.Length < 2 || token[0] != '"' || token[^1] != '"')
return token;
var bytes = new List<byte>();
var end = token.Length - 1;
var i = 1;
while (i < end)
{
var c = token[i];
if (c != '\\')
{
bytes.Add((byte)c);
i++;
continue;
}
i++;
if (i >= end) break;
switch (token[i])
{
case '"': bytes.Add((byte)'"'); i++; break;
case '\\': bytes.Add((byte)'\\'); i++; break;
case 'a': bytes.Add(0x07); i++; break;
case 'b': bytes.Add(0x08); i++; break;
case 'f': bytes.Add(0x0C); i++; break;
case 'n': bytes.Add((byte)'\n'); i++; break;
case 'r': bytes.Add((byte)'\r'); i++; break;
case 't': bytes.Add((byte)'\t'); i++; break;
case 'v': bytes.Add(0x0B); i++; break;
default:
if (token[i] is >= '0' and <= '7')
{
var value = 0;
var digits = 0;
while (digits < 3 && i < end && token[i] is >= '0' and <= '7')
{
value = value * 8 + (token[i] - '0');
i++;
digits++;
}
bytes.Add((byte)value);
}
else
{
bytes.Add((byte)token[i]);
i++;
}
break;
}
}
return Encoding.UTF8.GetString(bytes.ToArray());
}
}