summaryrefslogtreecommitdiff
path: root/src/diff.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 18:18:47 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit3f6c259711252b57646f447de85e44c5239ebd0d (patch)
treee75a1239f69a7b9ea0b3a7784315a0d71e8a15d2 /src/diff.zig
parent97464fad2d4f6a58608cf92729fd8395db89c7b4 (diff)
downloadpardes-3f6c259711252b57646f447de85e44c5239ebd0d.tar.gz
pardes-3f6c259711252b57646f447de85e44c5239ebd0d.zip
A diff look names a file as the repository does: git's side prefix dropped by git's rules, a plain diff's names as written
In a git section (one with a `diff --git` line) the one-letter side prefix git writes, `a/`/`b/` or with diff.mnemonicPrefix `c/`, `i/`, `w/`, `o/`, is dropped from its paths only when both paths of that line carry one, different ones: `--no-prefix` writes none, so its `a/x a/x` is a real directory `a` and is kept. A plain `diff -u` never strips: its names are used as written, past the timestamp. `/dev/null` stays itself. The walker does this once (diff.Walk.side, diff.gitHeader), so the path a look resolves, and the one a miss says, are the repository's name: `Look: src/x.zig: no such file here`, `Look: lib/old.zig: deleted by this diff`. The look no longer tries a stripped name beside the written one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/diff.zig')
-rw-r--r--src/diff.zig208
1 files changed, 151 insertions, 57 deletions
diff --git a/src/diff.zig b/src/diff.zig
index 4962cddc..e2b4afd4 100644
--- a/src/diff.zig
+++ b/src/diff.zig
@@ -39,13 +39,17 @@ pub const Line = struct {
};
pub const Walk = struct {
- /// The section's paths, as written, borrowed from the lines walked:
- /// `a/` and `b/` kept, timestamps cut.
+ /// The section's paths, borrowed from the lines walked: timestamps
+ /// cut, and in a git section git's own side prefix (`strip`) dropped,
+ /// so they are the repository's names.
old_path: []const u8 = "",
new_path: []const u8 = "",
- /// `diff --git a/x b/y`'s b side, for a section with no `+++` yet
+ /// `diff --git a/x b/y`'s new side, for a section with no `+++` yet
/// (a mode change, a rename, a binary file).
git_path: []const u8 = "",
+ /// The section's `diff --git` line has git's side prefixes on both its
+ /// paths (gitHeader), so its `---`/`+++` paths have them too.
+ strip: bool = false,
old_left: usize = 0,
new_left: usize = 0,
old_at: usize = 0,
@@ -75,6 +79,13 @@ pub const Walk = struct {
return isDevNull(w.new_path);
}
+ /// A `---`/`+++` path as the repository names it: git's side prefix
+ /// dropped in a section whose `diff --git` line has them.
+ pub fn side(w: *const Walk, path_raw: []const u8) []const u8 {
+ if (w.strip and !isDevNull(path_raw)) return prefixed(path_raw) orelse path_raw;
+ return path_raw;
+ }
+
pub fn step(w: *Walk, line_raw: []const u8) Line {
const line = std.mem.trimEnd(u8, line_raw, "\r");
const got = w.stepInner(line);
@@ -120,18 +131,21 @@ pub const Walk = struct {
return .{ .kind = .no_newline, .old = w.old_at, .new = w.new_at };
if (std.mem.startsWith(u8, line, "diff ")) {
w.* = .{ .continued_rows = w.continued_rows };
- w.git_path = gitPath(line);
+ if (gitHeader(line)) |h| {
+ w.git_path = h.new;
+ w.strip = h.strip;
+ }
return .{ .kind = .meta };
}
if (std.mem.startsWith(u8, line, "--- ")) {
// A second `---` with no `+++` between starts another section
// (plain `diff -u` output has no `diff` line of its own).
- w.* = .{ .continued_rows = w.continued_rows, .git_path = w.git_path };
- w.old_path = cutPath(line[4..]);
+ w.* = .{ .continued_rows = w.continued_rows, .git_path = w.git_path, .strip = w.strip };
+ w.old_path = w.side(cutPath(line[4..]));
return .{ .kind = .old_path };
}
if (std.mem.startsWith(u8, line, "+++ ")) {
- w.new_path = cutPath(line[4..]);
+ w.new_path = w.side(cutPath(line[4..]));
return .{ .kind = .new_path };
}
if (parseHunk(line)) |h| {
@@ -196,13 +210,50 @@ fn isStamp(t: []const u8) bool {
return false;
}
-/// `diff --git a/x b/y`: the b side. A name with a space in it is split at
-/// the last ` b/`, which is right unless the old name has one too.
-fn gitPath(line: []const u8) []const u8 {
- if (!std.mem.startsWith(u8, line, "diff --git ")) return "";
+/// The one-letter side prefixes git writes: `a/`/`b/`, and with
+/// `diff.mnemonicPrefix` `c/` (commit), `i/` (index), `w/` (work tree) and
+/// `o/` (object).
+const side_letters = "abciow";
+
+/// `path` past a side prefix, or null when it has none.
+fn prefixed(path: []const u8) ?[]const u8 {
+ if (path.len > 2 and path[1] == '/' and std.mem.indexOfScalar(u8, side_letters, path[0]) != null) return path[2..];
+ return null;
+}
+
+pub const GitHeader = struct {
+ /// The new side, its prefix dropped when `strip`.
+ new: []const u8,
+ /// Both paths carry a side prefix, and different ones, as git writes
+ /// them (`a/x b/x`, `c/x w/x`, `i/x w/x`). `--no-prefix` writes none,
+ /// so `a/x a/x` there is a real directory `a`, kept.
+ strip: bool,
+};
+
+/// `diff --git <old> <new>`, split where both sides are prefixed (the
+/// names equal, else the last such split), else where the halves are
+/// equal (`--no-prefix`), else at the last blank. A name with a blank in
+/// it splits wrongly only in a rename, and only on a matching prefix.
+pub fn gitHeader(line_raw: []const u8) ?GitHeader {
+ const line = std.mem.trimEnd(u8, line_raw, "\r ");
+ if (!std.mem.startsWith(u8, line, "diff --git ")) return null;
const rest = line["diff --git ".len..];
- const at = std.mem.lastIndexOf(u8, rest, " b/") orelse return "";
- return std.mem.trimEnd(u8, rest[at + 1 ..], " \r");
+ var split: ?usize = null;
+ var at: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, rest, at, ' ')) |blank| : (at = blank + 1) {
+ const old = rest[0..blank];
+ const new = rest[blank + 1 ..];
+ const o = prefixed(old) orelse continue;
+ const n = prefixed(new) orelse continue;
+ if (old[0] == new[0]) continue;
+ split = blank;
+ if (std.mem.eql(u8, o, n)) break;
+ }
+ if (split) |blank| return .{ .new = rest[blank + 3 ..], .strip = true };
+ if (rest.len % 2 == 1 and rest[rest.len / 2] == ' ' and std.mem.eql(u8, rest[0 .. rest.len / 2], rest[rest.len / 2 + 1 ..]))
+ return .{ .new = rest[rest.len / 2 + 1 ..], .strip = false };
+ const blank = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return .{ .new = rest, .strip = false };
+ return .{ .new = rest[blank + 1 ..], .strip = false };
}
pub const HunkHeader = struct { old_start: usize, old_count: usize, new_start: usize, new_count: usize };
@@ -276,7 +327,7 @@ pub fn looksLikeDiff(lines: []const []const u8) bool {
pub const Target = struct {
/// The line's own kind.
kind: Kind,
- /// As written in the diff, `a/`/`b/` kept.
+ /// The repository's name in a git section (Walk.side), else as written.
path: []const u8,
/// From 1; 0 for the file alone (a `---`/`+++` line).
line: usize,
@@ -284,6 +335,19 @@ pub const Target = struct {
old_side: bool = false,
};
+/// Whether the section `row` is in deletes its file by a line still ahead
+/// of it (`deleted file mode`, `+++ /dev/null`): its `diff --git` and `---`
+/// lines come first, and are about a file this diff deletes all the same.
+fn deletesAhead(lines: []const []const u8, row: usize) bool {
+ for (lines[row + 1 ..]) |raw| {
+ const l = std.mem.trimEnd(u8, raw, "\r");
+ if (std.mem.startsWith(u8, l, "diff ") or std.mem.startsWith(u8, l, "@@")) return false;
+ if (std.mem.startsWith(u8, l, "deleted file mode") or std.mem.eql(u8, l, "+++ /dev/null") or
+ (std.mem.startsWith(u8, l, "+++ /dev/null") and l.len > 13 and (l[13] == '\t' or l[13] == ' '))) return true;
+ }
+ return false;
+}
+
/// What a look at `lines[row]` opens, or null for a line that is no part
/// of a file section (a commit message, `index ...`). The whole line is
/// the look's: a click on a `+++`, `---` or `@@` marker is one on its path
@@ -299,19 +363,6 @@ pub const Target = struct {
/// from the end of the file. In a section that deletes its file there is
/// no new file, so a removed or context line opens the old one at its old
/// line (it is there while the diff is not applied).
-/// Whether the section `row` is in deletes its file by a line still ahead
-/// of it (`deleted file mode`, `+++ /dev/null`): its `diff --git` and `---`
-/// lines come first, and are about a file this diff deletes all the same.
-fn deletesAhead(lines: []const []const u8, row: usize) bool {
- for (lines[row + 1 ..]) |raw| {
- const l = std.mem.trimEnd(u8, raw, "\r");
- if (std.mem.startsWith(u8, l, "diff ") or std.mem.startsWith(u8, l, "@@")) return false;
- if (std.mem.startsWith(u8, l, "deleted file mode") or std.mem.eql(u8, l, "+++ /dev/null") or
- (std.mem.startsWith(u8, l, "+++ /dev/null") and l.len > 13 and (l[13] == '\t' or l[13] == ' '))) return true;
- }
- return false;
-}
-
pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Target {
if (row >= lines.len) return null;
var w: Walk = .{ .continued_rows = continued_rows };
@@ -327,14 +378,14 @@ pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Ta
return null;
},
.old_path, .new_path => {
- const own = cutPath(line[4..]);
+ const own = w.side(cutPath(line[4..]));
// `---` names the old file, but the new one on the `+++` under
// it wins when they differ: a new file (`--- /dev/null`), a
// rename, `diff -u old/x new/x`. The old name is the one
// likely gone.
if (got.kind == .old_path and row + 1 < lines.len and std.mem.startsWith(u8, lines[row + 1], "+++ ")) {
- const new = cutPath(std.mem.trimEnd(u8, lines[row + 1], "\r")[4..]);
- if (!isDevNull(new) and (isDevNull(own) or !std.mem.eql(u8, stripSide(new), stripSide(own))))
+ const new = w.side(cutPath(std.mem.trimEnd(u8, lines[row + 1], "\r")[4..]));
+ if (!isDevNull(new) and (isDevNull(own) or !std.mem.eql(u8, new, own)))
return .{ .kind = got.kind, .path = new, .line = 0 };
}
if (!isDevNull(own)) return .{ .kind = got.kind, .path = own, .line = 0, .old_side = got.kind == .old_path and (w.deleted() or deletesAhead(lines, row)) };
@@ -358,12 +409,6 @@ pub fn targetAt(lines: []const []const u8, row: usize, continued_rows: bool) ?Ta
}
}
-/// `a/x` and `b/x` as git writes them: the name in the repository.
-pub fn stripSide(path: []const u8) []const u8 {
- if (path.len > 2 and (path[0] == 'a' or path[0] == 'b') and path[1] == '/') return path[2..];
- return path;
-}
-
// ---- tests ----
const testing = std.testing;
@@ -408,7 +453,7 @@ test "diff every line of a section that deletes its file is about a file this di
for (lines, 0..) |line, row| {
if (row < 15) continue; // the deleting section: `diff --git a/old.py` on
const t = targetAt(&lines, row, false) orelse continue;
- try testing.expectEqualStrings("old.py", stripSide(t.path));
+ try testing.expectEqualStrings("old.py", t.path);
try testing.expect(t.old_side);
_ = line;
rows += 1;
@@ -440,25 +485,25 @@ test "diff a look on each kind of line names the new file and its line" {
try testing.expectEqual(line, t.line);
}
};
- try expect.at(&lines, 0, "b/src/a.zig", 0); // diff --git
+ try expect.at(&lines, 0, "src/a.zig", 0); // diff --git
try testing.expect(targetAt(&lines, 1, false) == null); // index
- try expect.at(&lines, 2, "a/src/a.zig", 0); // ---
- try expect.at(&lines, 3, "b/src/a.zig", 0); // +++
- try expect.at(&lines, 4, "b/src/a.zig", 10); // @@
- try expect.at(&lines, 5, "b/src/a.zig", 10); // context
- try expect.at(&lines, 6, "b/src/a.zig", 11); // removed: the line now there
- try expect.at(&lines, 7, "b/src/a.zig", 11); // added
- try expect.at(&lines, 8, "b/src/a.zig", 12);
- try expect.at(&lines, 9, "b/src/a.zig", 13);
+ try expect.at(&lines, 2, "src/a.zig", 0); // ---
+ try expect.at(&lines, 3, "src/a.zig", 0); // +++
+ try expect.at(&lines, 4, "src/a.zig", 10); // @@
+ try expect.at(&lines, 5, "src/a.zig", 10); // context
+ try expect.at(&lines, 6, "src/a.zig", 11); // removed: the line now there
+ try expect.at(&lines, 7, "src/a.zig", 11); // added
+ try expect.at(&lines, 8, "src/a.zig", 12);
+ try expect.at(&lines, 9, "src/a.zig", 13);
// `--- x` is a removed line, gone from past the hunk's new lines.
- try expect.at(&lines, 10, "b/src/a.zig", 13);
- try expect.at(&lines, 11, "b/src/a.zig", 41);
- try expect.at(&lines, 12, "b/src/a.zig", 41);
- try expect.at(&lines, 13, "b/src/a.zig", 42);
- try expect.at(&lines, 14, "b/src/a.zig", 42);
+ try expect.at(&lines, 10, "src/a.zig", 13);
+ try expect.at(&lines, 11, "src/a.zig", 41);
+ try expect.at(&lines, 12, "src/a.zig", 41);
+ try expect.at(&lines, 13, "src/a.zig", 42);
+ try expect.at(&lines, 14, "src/a.zig", 42);
// A removed line of a deleted file: its old line in the old file.
const gone = targetAt(&lines, 21, false).?;
- try testing.expectEqualStrings("a/old.py", gone.path);
+ try testing.expectEqualStrings("old.py", gone.path);
try testing.expectEqual(@as(usize, 2), gone.line);
try testing.expect(gone.old_side);
try testing.expect(targetAt(&lines, 1, false) == null);
@@ -490,8 +535,6 @@ test "diff plain diff -u output: no a/ b/, timestamps after the names" {
try testing.expectEqual(@as(usize, 4), t.line);
// `--- old/x.zig` with `+++ new/x.zig` under it: the new file.
try testing.expectEqualStrings("new/x.zig", targetAt(&lines, 0, false).?.path);
- try testing.expectEqualStrings("new/x.zig", stripSide("new/x.zig"));
- try testing.expectEqualStrings("x.zig", stripSide("b/x.zig"));
}
test "diff -u in a command pane, its tabs turned to blanks by the terminal, names its files without the stamps" {
@@ -523,9 +566,9 @@ test "diff a --- line opens the new name when the +++ under it names another" {
\\+b
\\
);
- try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 0, false).?.path);
- try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 4, false).?.path);
- try testing.expectEqualStrings("b/new name.zig", targetAt(&lines, 5, false).?.path);
+ try testing.expectEqualStrings("new name.zig", targetAt(&lines, 0, false).?.path);
+ try testing.expectEqualStrings("new name.zig", targetAt(&lines, 4, false).?.path);
+ try testing.expectEqualStrings("new name.zig", targetAt(&lines, 5, false).?.path);
try testing.expect(targetAt(&lines, 2, false) == null);
}
@@ -569,3 +612,54 @@ test "diff a terminal's wrapped row stays in its line" {
for (lines[0..5]) |line| last = w.step(line).kind;
try testing.expectEqual(Kind.added, last);
}
+
+test "diff git's side prefixes: dropped when both sides carry different ones, else kept" {
+ const cases = [_]struct { header: []const u8, new: []const u8, strip: bool }{
+ .{ .header = "diff --git a/src/x.zig b/src/x.zig", .new = "src/x.zig", .strip = true },
+ // diff.mnemonicPrefix: commit, index, work tree.
+ .{ .header = "diff --git c/src/x.zig w/src/x.zig", .new = "src/x.zig", .strip = true },
+ .{ .header = "diff --git i/src/x.zig w/src/x.zig", .new = "src/x.zig", .strip = true },
+ .{ .header = "diff --git a/old name.zig b/new name.zig", .new = "new name.zig", .strip = true },
+ // --no-prefix: no prefix at all, and a real top-level `a/` kept.
+ .{ .header = "diff --git src/x.zig src/x.zig", .new = "src/x.zig", .strip = false },
+ .{ .header = "diff --git a/x.zig a/x.zig", .new = "a/x.zig", .strip = false },
+ .{ .header = "diff --git a/b x a/b x", .new = "a/b x", .strip = false },
+ };
+ for (cases) |c| {
+ const h = gitHeader(c.header).?;
+ try testing.expectEqualStrings(c.new, h.new);
+ try testing.expectEqual(c.strip, h.strip);
+ }
+ try testing.expect(gitHeader("diff -u old new") == null);
+}
+
+fn pathsOf(comptime text: []const u8) ![3][]const u8 {
+ const lines = splitLines(text);
+ var w: Walk = .{};
+ for (lines) |line| _ = w.step(line);
+ const t = targetAt(&lines, 3, false) orelse return error.NoTarget;
+ return .{ w.old_path, w.new_path, t.path };
+}
+
+test "diff a section's paths are the repository's names under every prefix git writes, and as written elsewhere" {
+ const expect = struct {
+ fn paths(got: [3][]const u8, old: []const u8, new: []const u8) !void {
+ try testing.expectEqualStrings(old, got[0]);
+ try testing.expectEqualStrings(new, got[1]);
+ try testing.expectEqualStrings(new, got[2]);
+ }
+ };
+ try expect.paths(try pathsOf("diff --git a/src/x.zig b/src/x.zig\n--- a/src/x.zig\n+++ b/src/x.zig\n@@ -1 +1 @@\n-a\n+b\n"), "src/x.zig", "src/x.zig");
+ try expect.paths(try pathsOf("diff --git c/src/x.zig w/src/x.zig\n--- c/src/x.zig\n+++ w/src/x.zig\n@@ -1 +1 @@\n-a\n+b\n"), "src/x.zig", "src/x.zig");
+ try expect.paths(try pathsOf("diff --git i/src/x.zig w/src/x.zig\n--- i/src/x.zig\n+++ w/src/x.zig\n@@ -1 +1 @@\n-a\n+b\n"), "src/x.zig", "src/x.zig");
+ // --no-prefix, in a repository with a real directory `a`.
+ try expect.paths(try pathsOf("diff --git a/x.zig a/x.zig\n--- a/x.zig\n+++ a/x.zig\n@@ -1 +1 @@\n-a\n+b\n"), "a/x.zig", "a/x.zig");
+ try expect.paths(try pathsOf("diff --git src/x.zig src/x.zig\n--- src/x.zig\n+++ src/x.zig\n@@ -1 +1 @@\n-a\n+b\n"), "src/x.zig", "src/x.zig");
+ // Plain diff -u never strips, even a name that looks prefixed.
+ try expect.paths(try pathsOf("--- old/x.zig\t2026-09-30 10:00:00 +0000\n+++ new/x.zig\t2026-09-30 11:00:00 +0000\n@@ -1 +1 @@\n-a\n"), "old/x.zig", "new/x.zig");
+ try expect.paths(try pathsOf("--- a/x.zig\n+++ b/x.zig\n@@ -1 +1 @@\n-a\n"), "a/x.zig", "b/x.zig");
+ // /dev/null stays itself.
+ const made = try pathsOf("diff --git a/n.zig b/n.zig\n--- /dev/null\n+++ b/n.zig\n@@ -0,0 +1 @@\n+a\n");
+ try testing.expectEqualStrings("/dev/null", made[0]);
+ try testing.expectEqualStrings("n.zig", made[1]);
+}