Something went wrong. Try again.
A Zig library that reads git repositories without running git, with log, show and diff output byte-identical to git's.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! `zig-git diff --no-index` compared with `git diff --no-index`: files,//! directories, modes, symbolic links, binary files, the options, and//! git's errors, under SHA-1 and SHA-256. Where zig-git refuses a case by//! name, the test checks that it refuses rather than answers.
const std = @import("std");const testing = std.testing;
const tool = @import("zig_git.zig");const Repo = tool.Repo;
fn same(repo: *Repo, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{});}
fn refused(repo: *Repo, args: []const []const u8) !void { const ours = try tool.runOurs(repo, args, .{}); defer ours.deinit(repo.gpa); errdefer std.debug.print("expected a refusal, got: {s}{s}\n", .{ ours.stdout, ours.stderr }); try testing.expectEqual(@as(u8, 128), ours.code); try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null);}
fn write(repo: *Repo, path: []const u8, contents: []const u8) !void { if (std.fs.path.dirname(path)) |d| try repo.tmp.dir.createDirPath(repo.io, d); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = contents });}
test "diff --no-index: two files" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try write(&repo, "a", "one\ntwo\nthree\nfour\n\nfive\nsix\nseven\neight\n"); try write(&repo, "b", "one\n2\nthree\nfour\nfive\nsix\nseven\neight\nnine"); try write(&repo, "same", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n"); try write(&repo, "empty", ""); try write(&repo, "ws", "one\ntwo \nthree\nfour\nfive\nsix\nseven\neight\r\n"); const n = "--no-indent-heuristic"; try same(&repo, &.{ "diff", "--no-index", n, "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "a", "same" }); try same(&repo, &.{ "diff", "--no-index", n, "empty", "a" }); try same(&repo, &.{ "diff", "--no-index", n, "a", "empty" }); try same(&repo, &.{ "diff", "--no-index", n, "/dev/null", "a" }); try same(&repo, &.{ "diff", "--no-index", n, "b", "/dev/null" }); for ([_][]const []const u8{ &.{"-U0"}, &.{"-U1"}, &.{"--unified=7"}, &.{"--inter-hunk-context=4"}, &.{"-w"}, &.{"-b"}, &.{"--ignore-space-at-eol"}, &.{"--ignore-cr-at-eol"}, &.{"--ignore-blank-lines"}, &.{ "-I", "t" }, &.{"--minimal"}, &.{"--diff-algorithm=myers"}, &.{"-R"}, &.{"--no-prefix"}, &.{"--default-prefix"}, &.{ "--src-prefix=x/", "--dst-prefix=y/" }, &.{"--full-index"}, &.{"--abbrev=10"}, &.{"--abbrev=2"}, &.{"--quiet"}, &.{"--exit-code"}, &.{"-a"}, &.{ "-c", "diff.noprefix=true" }, }) |opts| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(testing.allocator); const config = opts.len == 2 and std.mem.eql(u8, opts[0], "-c"); if (config) try args.appendSlice(testing.allocator, opts); try args.appendSlice(testing.allocator, &.{ "diff", "--no-index", n }); if (!config) try args.appendSlice(testing.allocator, opts); try args.appendSlice(testing.allocator, &.{ "a", "ws" }); try same(&repo, args.items); } // Only whitespace, hidden: nothing printed, and no difference. try same(&repo, &.{ "diff", "--no-index", n, "-w", "same", "ws" }); try same(&repo, &.{ "diff", "--no-index", n, "--ignore-blank-lines", "same", "ws" });}
test "diff --no-index: modes, links, binary files and odd names" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try write(&repo, "plain", "x\ny\n"); try write(&repo, "exec", "x\ny\n"); try write(&repo, "exec2", "x\nz\n"); const chmod = try std.process.run(testing.allocator, testing.io, .{ .argv = &.{ "chmod", "+x", "exec", "exec2" }, .cwd = .{ .path = repo.path } }); testing.allocator.free(chmod.stdout); testing.allocator.free(chmod.stderr); try write(&repo, "bin1", "a\x00b\n"); try write(&repo, "bin2", "a\x00c\n"); try write(&repo, "with space", "x\nq\n"); try write(&repo, "caf\xc3\xa9", "x\nr\n"); try repo.tmp.dir.symLink(repo.io, "target-one", "link1", .{}); try repo.tmp.dir.symLink(repo.io, "target-two", "link2", .{}); const n = "--no-indent-heuristic"; try same(&repo, &.{ "diff", "--no-index", n, "plain", "exec" }); try same(&repo, &.{ "diff", "--no-index", n, "plain", "exec2" }); try same(&repo, &.{ "diff", "--no-index", n, "bin1", "bin2" }); try same(&repo, &.{ "diff", "--no-index", n, "--text", "bin1", "bin2" }); try same(&repo, &.{ "diff", "--no-index", n, "plain", "bin1" }); try same(&repo, &.{ "diff", "--no-index", n, "plain", "with space" }); try same(&repo, &.{ "diff", "--no-index", n, "plain", "caf\xc3\xa9" }); try same(&repo, &.{ "-c", "core.quotePath=false", "diff", "--no-index", n, "plain", "caf\xc3\xa9" }); try same(&repo, &.{ "diff", "--no-index", n, "link1", "link2" }); try same(&repo, &.{ "diff", "--no-index", n, "/dev/null", "link1" }); try same(&repo, &.{ "diff", "--no-index", n, "plain", "link1" }); try same(&repo, &.{ "diff", "--no-index", n, "-R", "link1", "plain" }); try refused(&repo, &.{ "diff", "--no-index", n, "--binary", "bin1", "bin2" });}
test "diff --no-index: directories" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try write(&repo, "d1/same", "s\n"); try write(&repo, "d2/same", "s\n"); try write(&repo, "d1/changed", "one\ntwo\n"); try write(&repo, "d2/changed", "one\n2\n"); try write(&repo, "d1/only-old", "gone\n"); try write(&repo, "d2/only-new", "new\n"); try write(&repo, "d1/sub/deep", "deep\n"); try write(&repo, "d2/sub/deep", "deeper\n"); try write(&repo, "d1/kind/file", "was a directory\n"); try write(&repo, "d2/kind", "now a file\n"); try write(&repo, "d2/newdir/x", "x\n"); try write(&repo, "lone", "one\n2\n"); const n = "--no-indent-heuristic"; try same(&repo, &.{ "diff", "--no-index", n, "d1", "d2" }); try same(&repo, &.{ "diff", "--no-index", n, "d1/", "d2" }); try same(&repo, &.{ "diff", "--no-index", n, "-R", "d1", "d2" }); try same(&repo, &.{ "diff", "--no-index", n, "d1/changed", "d2" }); try same(&repo, &.{ "diff", "--no-index", n, "lone", "d2" }); // A file on each side alone, alike enough for git to call it a // rename, which is not matched: refused, unless diff.renames is off. try write(&repo, "d1/moved", "1\n2\n3\n4\n5\n6\n7\n8\n"); try write(&repo, "d2/sub/moved-here", "1\n2\n3\n4\n5\n6\n7\n8\n9\n"); try refused(&repo, &.{ "diff", "--no-index", n, "d1", "d2" }); try same(&repo, &.{ "-c", "diff.renames=false", "diff", "--no-index", n, "d1", "d2" }); try same(&repo, &.{ "-c", "diff.renames=bogus", "diff", "--no-index", n, "d1", "d2" }); try same(&repo, &.{ "-c", "diff.renames=copies", "-c", "diff.autoRefreshIndex=bogus", "diff", "--no-index", n, "d1", "d2" }); // --no-renames is diff.renames=false from the command line, over the // configuration's; it takes no value. try same(&repo, &.{ "diff", "--no-index", "--no-renames", n, "d1", "d2" }); try same(&repo, &.{ "-c", "diff.renames=copies", "diff", "--no-index", n, "--no-renames", "d1", "d2" }); try same(&repo, &.{ "diff", "--no-index", "--no-renames", n, "d1/changed", "d2/changed" }); try same(&repo, &.{ "diff", "--no-index", "--no-renames=x", n, "d1", "d2" });}
test "diff --no-index: -I with --ignore-blank-lines leaves out only changes one of them covers whole" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try write(&repo, "a", "p\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz\n"); // A blank line and a matching one added together, then two blank // lines, then two matching ones: only the first change is shown. try write(&repo, "b", "p\nq\n\nbar(x)\nr\ns\n\n\nt\nu\nxx\nx1\nv\nw\nx\ny\nz\n"); for ([_][]const []const u8{ &.{ "-I", "x", "--ignore-blank-lines" }, &.{ "-I", "x" }, &.{"--ignore-blank-lines"}, &.{ "-I", "x", "--ignore-blank-lines", "-U0" }, }) |opts| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(testing.allocator); try args.appendSlice(testing.allocator, &.{ "diff", "--no-index" }); try args.appendSlice(testing.allocator, opts); try args.appendSlice(testing.allocator, &.{ "a", "b" }); try same(&repo, args.items); }}
test "diff --no-index: a change left out between two others counts toward joining them" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); // Two changes with a blank line deleted between them, too far from // either to be taken along: they join when the lines between them, // the blank one included, are few enough in both texts. try write(&repo, "a", "l1\nl2\nl3\nl4\no5\nl6\n\nl8\nl9\no10\nl11\nl12\nl13\nl14\n"); try write(&repo, "b", "l1\nl2\nl3\nl4\nn5\nl6\nl8\nl9\nn10\nl11\nl12\nl13\nl14\n"); try write(&repo, "c", "l1\nl2\nl3\nl4\nn5\nl6\nl8\n\n\nl9\nn10\nl11\nl12\nl13\nl14\n"); for ([_][]const u8{ "--inter-hunk-context=0", "--inter-hunk-context=1", "--inter-hunk-context=2", "--inter-hunk-context=3" }) |inter| { for ([_][]const u8{ "b", "c" }) |other| { try same(&repo, &.{ "diff", "--no-index", "--ignore-blank-lines", "-U1", inter, "a", other }); try same(&repo, &.{ "diff", "--no-index", "--ignore-blank-lines", "-U1", inter, other, "a" }); } }}
test "diff --no-index: errors and refusals" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try write(&repo, "a", "x\n"); try write(&repo, "b", "y\n"); const n = "--no-indent-heuristic"; try same(&repo, &.{ "diff", "--no-index", n, "missing", "a" }); try same(&repo, &.{ "diff", "--no-index", n, "a" }); try same(&repo, &.{ "diff", "--no-index", n, "--bogus", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "-U-1", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "-Ux", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "--diff-algorithm=foo", "a", "b" }); try same(&repo, &.{ "-c", "diff.algorithm=foo", "diff", "--no-index", n, "a", "b" }); try same(&repo, &.{ "diff", "a", "b" }); try refused(&repo, &.{ "diff", "--no-index", n, "--stat", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "--patience", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "--anchored=x", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "--anchored", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "--histogram", "a", "b" }); try refused(&repo, &.{ "diff", "--no-index", n, "--color=always", "a", "b" }); try same(&repo, &.{ "diff", "--no-index", n, "-W", "a", "b" });}
/// a = A B C, b = C B A, `half` lines each, C of 300 lines.fn writeBlockSwap(repo: *Repo, half: usize, old: []const u8, new: []const u8) !void { const gpa = repo.gpa; var a_part: std.ArrayList(u8) = .empty; defer a_part.deinit(gpa); var c_part: std.ArrayList(u8) = .empty; defer c_part.deinit(gpa); for (0..half - 303) |i| try a_part.print(gpa, "a{d}\n", .{i}); for (0..300) |i| try c_part.print(gpa, "c{d}\n", .{i}); const old_text = try std.mem.concat(gpa, u8, &.{ a_part.items, "b0\nb1\nb2\n", c_part.items }); defer gpa.free(old_text); const new_text = try std.mem.concat(gpa, u8, &.{ c_part.items, "b0\nb1\nb2\n", a_part.items }); defer gpa.free(new_text); try write(repo, old, old_text); try write(repo, new, new_text);}
test "diff --no-index: the search limit on large texts" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); const n = "--no-indent-heuristic"; // 65,532 lines: git gives up after 256 steps, as zig-git does. try writeBlockSwap(&repo, 32766, "a1", "b1"); try same(&repo, &.{ "diff", "--no-index", n, "-U1", "a1", "b1" }); // 65,534 lines: the search may go on to 512 steps, past where git's // choices are known, so zig-git refuses; --minimal still answers. try writeBlockSwap(&repo, 32767, "a2", "b2"); try refused(&repo, &.{ "diff", "--no-index", n, "-U1", "a2", "b2" }); try refused(&repo, &.{ "diff", "--no-index", "-U1", "a2", "b2" }); try same(&repo, &.{ "diff", "--no-index", n, "--minimal", "-U1", "a2", "b2" });}
test "diff --no-index: the indent heuristic" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try write(&repo, "a", "int f(void)\n{\n\treturn 0;\n}\n"); try write(&repo, "b", "int f(void)\n{\n\treturn 1;\n}\n"); try write(&repo, "c", "int f(void)\n{\n\treturn 1;\n}\n\nint g(void)\n{\n\treturn 1;\n}\n"); try same(&repo, &.{ "diff", "--no-index", "a", "b" }); // An added function could start at either blank line. for ([_][]const []const u8{ &.{}, &.{"--indent-heuristic"}, &.{"--no-indent-heuristic"}, &.{ "-c", "diff.indentHeuristic=false" }, &.{ "-c", "diff.indentHeuristic=true" }, &.{"-U0"}, }) |opts| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); const config = opts.len == 2 and std.mem.eql(u8, opts[0], "-c"); if (config) try args.appendSlice(gpa, opts); try args.appendSlice(gpa, &.{ "diff", "--no-index" }); if (!config) try args.appendSlice(gpa, opts); try args.appendSlice(gpa, &.{ "b", "c" }); try same(&repo, args.items); }
// Probes the model was found from, each a run that can slide: the // file with one copy of the run, then with two. const probes = [_][2][]const u8{ // Only placements at most the run's length plus one line above // the lowest are candidates: here the top wins with three lines // to slide over and loses with four. .{ "a\n x\n x\n b\n", "a\n x\n x\n x\n b\n" }, .{ "a\n x\n x\n x\n b\n", "a\n x\n x\n x\n x\n b\n" }, // Tabs reach the next multiple of 8; a carriage return adds nothing. .{ "a\n x\n\tb\n", "a\n x\n x\n\tb\n" }, .{ "a\n x\n \r b\n", "a\n x\n x\n \r b\n" }, // Only the sign of the difference in indentation counts. .{ " a\n\nx\n p\n q\nx\n\n b\n", " a\n\nx\n p\n q\nx\n p\n q\nx\n\n b\n" }, // Blank lines are counted up to 20. .{ "a\n" ++ @as([17]u8, @splat('\n')) ++ "x\n" ++ @as([25]u8, @splat('\n')) ++ "b\n", "a\n" ++ @as([17]u8, @splat('\n')) ++ "x\nx\n" ++ @as([25]u8, @splat('\n')) ++ "b\n" }, .{ "a\n" ++ @as([16]u8, @splat('\n')) ++ "x\n" ++ @as([20]u8, @splat('\n')) ++ "b\n", "a\n" ++ @as([16]u8, @splat('\n')) ++ "x\nx\n" ++ @as([20]u8, @splat('\n')) ++ "b\n" }, // Twenty blank lines are all a split's look upward sees: these at // the start of the text read as a line of indentation 0 above // them, not as the start. .{ " \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n p0\np1\n \n \n\n \n \n\n\n \n\n \n \n\n \n\n \n\n \n\n \n\n \n\n \n\n", " \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n p0\np1\n \n p0\np1\n \n \n\n \n \n\n\n \n\n \n \n\n \n\n \n\n \n\n \n\n \n\n \n\n" }, // Below, the line at the split and twenty more: in a run of blank // lines longer than that, the line after them is not seen. .{ "\t\n \t\n\n \n\n\t\n\t\n \n \n \n\t\n \n\t\n\t\n\t\n \n\nb1\n", "\t\n \t\n\n \n\n\t\n\t\n \n \n \n\t\n \n\t\n\t\n\t\n \n\n\t\n\t\n \n \n \n\t\n \n\t\n\t\n\t\n \n\nb1\n" }, .{ "\n \n \t\n \t\n \n \n \n\n\n \n \n \n \n\n \n \n\t\n\t\n \t\n \n b0\n", "\n\n \n \t\n \t\n \n \n \n\n\n \n \n \n \n\n \n \n\t\n\t\n \t\n \n b0\n" }, // Whether the line after the one below is indented further counts // only within twenty lines of it: 19 blank lines between, then 20. .{ "a\n p0\nb0\n" ++ @as([57]u8, @bitCast(@as([19][3]u8, @splat(" \n".*)))) ++ " b1\nz\n", "a\n p0\n p1\n p0\nb0\n" ++ @as([57]u8, @bitCast(@as([19][3]u8, @splat(" \n".*)))) ++ " b1\nz\n" }, .{ "a\n p0\nb0\n" ++ @as([60]u8, @bitCast(@as([20][3]u8, @splat(" \n".*)))) ++ " b1\nz\n", "a\n p0\n p1\n p0\nb0\n" ++ @as([60]u8, @bitCast(@as([20][3]u8, @splat(" \n".*)))) ++ " b1\nz\n" }, // Exact ties between blank lines and indentation, which only // git's integer weights reproduce. .{ " \n\t\n \t\n \np0\n \n \n \n \n \nbx\n b1\n", " \n\t\n \t\n \np0\np0\n \n \n \n \n \nbx\n b1\n" }, .{ " a2\n\t\np0\n\t\np0\n", " a2\n\t\np0\n\t\n p2\np0\n\t\np0\n" }, // At the end of the text only blank lines below the split count, // not those above it. .{ "a0\n\t\n\n\n p2\n\n", "a0\n\t\n\n\n p2\n\n\n\n p2\n\n" }, }; for (probes) |p| { try write(&repo, "p1", p[0]); try write(&repo, "p2", p[1]); try same(&repo, &.{ "diff", "--no-index", "p1", "p2" }); try same(&repo, &.{ "diff", "--no-index", "-U0", "p2", "p1" }); }}
test "diff --no-index: -U0 sets the common tail aside in blocks of 1024 bytes" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); // The run's placement depends on whether the line two after it is // still compared once the tail is set aside. var filler: std.ArrayList(u8) = .empty; defer filler.deinit(gpa); for (0..100) |_| try filler.appendSlice(gpa, "ggggggggg\n"); for (0..12) |extra| { var old: std.ArrayList(u8) = .empty; defer old.deinit(gpa); var new: std.ArrayList(u8) = .empty; defer new.deinit(gpa); try old.appendSlice(gpa, "a\n x\n\n b\n"); try new.appendSlice(gpa, "a\n x\n x\n\n b\n"); for ([_]*std.ArrayList(u8){ &old, &new }) |t| { try t.appendSlice(gpa, filler.items); try t.appendNTimes(gpa, 'h', extra); try t.append(gpa, '\n'); } try write(&repo, "t1", old.items); try write(&repo, "t2", new.items); try same(&repo, &.{ "diff", "--no-index", "-U0", "t1", "t2" }); try same(&repo, &.{ "diff", "--no-index", "-U0", "--no-indent-heuristic", "t1", "t2" }); try same(&repo, &.{ "diff", "--no-index", "-U1", "t1", "t2" }); }}
test "diff --no-index: --output" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try write(&repo, "a", "x\n"); try write(&repo, "b", "y\n"); const theirs = try repo.runIn(".", &.{ "diff", "--no-index", "--output=out-git", "a", "b" }, ""); defer theirs.deinit(repo.gpa); const ours = try tool.runOurs(&repo, &.{ "diff", "--no-index", "--output=out-ours", "a", "b" }, .{}); defer ours.deinit(repo.gpa); try testing.expectEqual(theirs.code, ours.code); try testing.expectEqualStrings(theirs.stdout, ours.stdout); const g = try repo.tmp.dir.readFileAlloc(repo.io, "out-git", testing.allocator, .unlimited); defer testing.allocator.free(g); const o = try repo.tmp.dir.readFileAlloc(repo.io, "out-ours", testing.allocator, .unlimited); defer testing.allocator.free(o); try testing.expectEqualStrings(g, o);}