// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `zig-git diff-tree` compared with `git diff-tree` over a history with //! every kind of change: additions, deletions, modifications, mode //! changes, a file becoming a symbolic link, binary files, a gitlink, //! directories, attributes and odd names, in every output format. 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 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 }); } fn remove(repo: *Repo, path: []const u8) !void { try repo.tmp.dir.deleteFile(repo.io, path); } fn revParse(repo: *Repo, rev: []const u8) ![]u8 { const out = try repo.output(&.{ "rev-parse", rev }); defer repo.gpa.free(out); return repo.gpa.dupe(u8, std.mem.trimEnd(u8, out, "\n")); } /// A history of two commits after a root, and a merge. pub fn makeHistory(repo: *Repo) !void { try write(repo, "mod", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"); try write(repo, "gone", "gone\n"); try write(repo, "exec", "x\n"); try write(repo, "tolink", "a file\n"); try write(repo, "bin", "a\x00b"); try write(repo, "dir/sub/f", "deep\n"); try write(repo, "dir/g", "g\n"); try write(repo, "sp ace", "space\n"); try write(repo, "caf\xc3\xa9", "unicode\n"); try write(repo, "ws", "a b\nc\n"); try write(repo, "blank", "a\nb\n"); try write(repo, "attr.dat", "text but -diff\n"); try write(repo, ".gitattributes", "*.dat -diff\n"); try repo.git(&.{ "add", "." }); try repo.commit("root"); const root = try revParse(repo, "HEAD"); defer repo.gpa.free(root); try write(repo, "mod", "one\n2\nthree\nfour\nfive\nsix\nseven\neight\nnine\n10\neleven\n"); try remove(repo, "gone"); try remove(repo, "tolink"); try repo.tmp.dir.symLink(repo.io, "target", "tolink", .{}); try write(repo, "bin", "a\x00c\x00d"); try write(repo, "dir/sub/f", "deeper\n"); try write(repo, "added", "new\n"); try write(repo, "ws", "a b\nc \n"); try write(repo, "blank", "a\n\nb\n"); try write(repo, "attr.dat", "changed text\n"); try repo.git(&.{ "add", "-A" }); try repo.git(&.{ "update-index", "--chmod=+x", "exec" }); const info = try std.fmt.allocPrint(repo.gpa, "160000,{s},vendor", .{root}); defer repo.gpa.free(info); try repo.git(&.{ "update-index", "--add", "--cacheinfo", info }); try repo.commit("second"); try repo.git(&.{ "checkout", "-q", "-f", "-b", "side", "HEAD~" }); try write(repo, "side-file", "side\n"); try repo.git(&.{ "add", "side-file" }); try repo.commit("side"); try repo.git(&.{ "checkout", "-q", "-f", "main" }); try repo.git(&.{ "merge", "-q", "--no-edit", "side" }); } test "diff-tree: every format over every kind of change" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); const formats = [_][]const []const u8{ &.{}, &.{"-r"}, &.{"-t"}, &.{"-p"}, &.{"--raw"}, &.{"--stat"}, &.{"--numstat"}, &.{"--shortstat"}, &.{"--summary"}, &.{"--name-only"}, &.{"--name-status"}, &.{"-z"}, &.{ "-r", "-z" }, &.{ "-p", "-z" }, &.{"--abbrev"}, &.{"--abbrev=10"}, &.{"-R"}, &.{ "-p", "-R" }, &.{"--full-index"}, &.{ "-p", "-U0" }, &.{ "-p", "-U1" }, &.{ "-p", "-w" }, &.{ "-r", "-b" }, &.{"--ignore-blank-lines"}, &.{"--no-commit-id"}, &.{"-s"}, &.{"--patch-with-stat"}, &.{"--patch-with-raw"}, &.{ "-p", "-a" }, &.{ "--stat", "-a" }, &.{"--stat=40,10"}, &.{"--stat-count=2"}, &.{"--stat-graph-width=3"}, &.{"--diff-filter=AD"}, &.{"--diff-filter=m"}, &.{"--exit-code"}, &.{"--quiet"}, &.{ "--stat", "--shortstat", "--summary", "--numstat", "--raw", "-p" }, &.{ "-s", "--name-only" }, }; const gpa = testing.allocator; for (formats) |opts| { for ([_][]const []const u8{ &.{"HEAD~"}, &.{ "HEAD~2", "HEAD~" }, &.{"HEAD"} }) |revs| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.append(gpa, "diff-tree"); try args.appendSlice(gpa, opts); try args.appendSlice(gpa, revs); try same(&repo, args.items); } } try same(&repo, &.{ "diff-tree", "-m", "HEAD" }); try same(&repo, &.{ "diff-tree", "-m", "-p", "HEAD" }); try same(&repo, &.{ "diff-tree", "--root", "-p", "HEAD~2" }); try same(&repo, &.{ "diff-tree", "--always", "HEAD~2" }); try same(&repo, &.{ "diff-tree", "--no-commit-id", "--always", "HEAD~" }); try same(&repo, &.{ "diff-tree", "HEAD~2..HEAD~" }); try same(&repo, &.{ "diff-tree", "HEAD~", "mod" }); try same(&repo, &.{ "diff-tree", "HEAD~2", "HEAD~", "mod", "nonexistent" }); try same(&repo, &.{ "diff-tree", "HEAD~2", "HEAD~", "nonexistent" }); try same(&repo, &.{ "diff-tree", "--bogus", "HEAD" }); } test "diff-tree: --exit-code when only --summary or --dirstat is written" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); const gpa = testing.allocator; try write(&repo, "bin", "bin\x00ary data\x00\x01\x02 more\n"); try write(&repo, "t", "text\n"); try write(&repo, "ws", "a b\n"); try repo.git(&.{ "add", "." }); try repo.commit("root"); // A binary file copied from one left unchanged, which -C20% alone // does not look at, so it is a file added; a text file changed; a // change of whitespace alone. try write(&repo, "bin2", "bin\x00ary data\x00\x01\x02 more\n"); try repo.git(&.{ "add", "." }); try repo.commit("copy"); try write(&repo, "t", "other\n"); try repo.git(&.{ "add", "." }); try repo.commit("text"); try write(&repo, "ws", "a b\n"); try repo.git(&.{ "add", "." }); try repo.commit("space"); // Under a whitespace option or -I, only --summary and --dirstat by // changes or files exit 0 whatever changed, as git does; -s or // --quiet after them, or another format with them, and they count. const ignoring = [_][]const []const u8{ &.{}, &.{"-w"}, &.{"-b"}, &.{"--ignore-space-at-eol"}, &.{"--ignore-cr-at-eol"}, &.{ "-I", "zz" }, &.{"--ignore-blank-lines"} }; const formats = [_][]const []const u8{ &.{"--summary"}, &.{"--dirstat"}, &.{"--dirstat=files"}, &.{"--dirstat=lines"}, &.{ "--summary", "--dirstat" }, &.{ "--summary", "--raw" }, &.{ "--summary", "--stat" }, &.{ "--summary", "--check" }, &.{ "-s", "--summary" }, &.{ "--summary", "-s" }, &.{ "--quiet", "--summary" }, &.{ "--dirstat", "--name-only" }, &.{"-p"}, }; for (ignoring) |ign| for (formats) |f| for ([_][]const u8{ "HEAD~2", "HEAD~", "HEAD" }) |rev| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, &.{ "diff-tree", "-r", "-C20%", "--exit-code" }); try args.appendSlice(gpa, ign); try args.appendSlice(gpa, f); try args.append(gpa, rev); try same(&repo, args.items); }; // git diff exits 0 for them whatever the options, and diff-index // as diff-tree does. for ([_][]const []const u8{ &.{}, &.{"-w"} }) |ign| for (formats) |f| { for ([_][]const []const u8{ &.{ "diff", "--exit-code" }, &.{ "diff-index", "--exit-code", "--cached" } }) |command| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.appendSlice(gpa, command); try args.appendSlice(gpa, ign); try args.appendSlice(gpa, f); try args.append(gpa, "HEAD~2"); try same(&repo, args.items); } }; } test "diff-tree: pathspecs, with and without -r and -t" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); const gpa = testing.allocator; for ([_][]const u8{ "dir", "dir/sub/f", "dir/*", "d*", "*.c", "*/f", ":!dir", ":!d*", "dir/sub/nonexistent", ":(glob)dir/**" }) |spec| { for ([_][]const []const u8{ &.{}, &.{"-r"}, &.{"-t"}, &.{"--name-only"} }) |opts| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); try args.append(gpa, "diff-tree"); try args.appendSlice(gpa, opts); try args.appendSlice(gpa, &.{ "HEAD~", "--", spec }); try same(&repo, args.items); } } } test "diff-tree: --stdin" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo); const gpa = testing.allocator; const c = try revParse(&repo, "HEAD~"); defer gpa.free(c); const t1 = try revParse(&repo, "HEAD~2^{tree}"); defer gpa.free(t1); const t2 = try revParse(&repo, "HEAD~^{tree}"); defer gpa.free(t2); const input = try std.fmt.allocPrint(gpa, "{s}\n{s} {s}\n", .{ c, t1, t2 }); defer gpa.free(input); try tool.expectSameAsGitHow(&repo, &.{ "diff-tree", "--stdin", "-r" }, .{ .input = input }); try tool.expectSameAsGitHow(&repo, &.{ "diff-tree", "--stdin", "-p" }, .{ .input = input }); }