// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! How `log`, `show` and `diff-tree` show merges, compared with git: `-m` //! and `--diff-merges` in each mode, `log.diffMerges`, `--dd`, //! `--first-parent`, with renames on either side. 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); try testing.expectEqual(128, ours.code); try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null); } fn write(repo: *Repo, path: []const u8, data: []const u8) !void { if (std.fs.path.dirname(path)) |dir| try repo.tmp.dir.createDirPath(repo.io, dir); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = data }); } /// Two branches merged: one renames a file, the other edits it and adds /// one of its own; the merge takes both, and changes a line of its own. pub fn makeMerge(repo: *Repo) !void { try write(repo, "f", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n"); try write(repo, "g", "a\nb\nc\nd\ne\nf\ng\nh\n"); try repo.git(&.{ "add", "." }); try repo.commit("base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); try write(repo, "f", "1\n2\nthree\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n"); try repo.git(&.{ "mv", "g", "g-renamed" }); try write(repo, "side-only", "s\n"); try repo.git(&.{ "add", "-A" }); try repo.commit("side"); try repo.git(&.{ "checkout", "-q", "main" }); try write(repo, "f", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\nfourteen\n15\n"); try write(repo, "main-only", "m\n"); try repo.git(&.{ "add", "-A" }); try repo.commit("main"); try repo.git(&.{ "merge", "-q", "--no-commit", "side" }); try write(repo, "f", "1\n2\nthree\n4\n5\n6\nSEVEN\n8\n9\n10\n11\n12\n13\nfourteen\n15\n"); try repo.git(&.{ "add", "-A" }); try repo.commit("merge"); } test "merges: -m and --diff-merges in log, show and diff-tree" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeMerge(&repo); for ([_][]const []const u8{ &.{ "log", "-m", "--oneline" }, &.{ "log", "-m", "-p" }, &.{ "log", "-m", "--name-status" }, &.{ "log", "-m", "-s", "--oneline" }, &.{ "log", "-m", "--full-diff", "--oneline" }, &.{ "log", "--diff-merges=separate", "--stat" }, &.{ "log", "--diff-merges=separate", "-s", "--oneline" }, &.{ "log", "--diff-merges=first-parent", "--raw" }, &.{ "log", "--diff-merges=1", "--format=%h" }, &.{ "log", "--diff-merges=off", "-p" }, &.{ "log", "--no-diff-merges", "-p" }, &.{ "log", "--dd", "--stat" }, &.{ "log", "-m", "-p", "--first-parent" }, &.{ "log", "-m", "--diff-filter=A", "--oneline", "--name-status" }, &.{ "log", "-m", "--format=%h%n", "--name-only" }, &.{ "log", "-m", "--pretty=fuller", "--abbrev-commit", "--stat" }, &.{ "log", "--diff-merges=bogus" }, &.{ "log", "--diff-merges" }, &.{ "-c", "log.diffMerges=first-parent", "log", "-m", "-p" }, &.{ "-c", "log.diffMerges=bogus", "log", "-m", "-p" }, &.{ "show", "-m" }, &.{ "show", "-m", "-s", "--oneline" }, &.{ "show", "--diff-merges=separate", "--stat", "--summary" }, &.{ "show", "--diff-merges=off" }, &.{ "show", "--dd" }, &.{ "show", "--first-parent", "--name-status" }, &.{ "diff-tree", "-m", "-r", "HEAD" }, &.{ "diff-tree", "-m", "-r", "-M", "--name-status", "HEAD" }, }) |args| try same(&repo, args); try refused(&repo, &.{ "log", "--diff-merges=remerge" }); try same(&repo, &.{"show"}); try same(&repo, &.{ "show", "-c", "-U0" }); try refused(&repo, &.{ "log", "--remerge-diff" }); } test "merges: combined diffs with -c" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeMerge(&repo); for ([_][]const []const u8{ &.{ "show", "-c" }, &.{ "show", "-c", "--raw" }, &.{ "show", "-c", "--name-status" }, &.{ "show", "-c", "--name-only" }, &.{ "show", "-c", "--stat", "-p" }, &.{ "show", "-c", "--raw", "-p", "--format=%s" }, &.{ "show", "-c", "-U1" }, &.{ "show", "--diff-merges=combined", "--abbrev=10", "--raw" }, &.{ "show", "-c", "--full-index" }, &.{ "log", "-c", "--oneline" }, &.{ "log", "-c", "-p" }, }) |args| try same(&repo, args); } /// The lines of a file for the driver tests: function lines, the lines /// of their bodies indented, `own` changing the lines it names. fn driverText(gpa: std.mem.Allocator, own: []const [2][]const u8) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); const w = &out.writer; try w.writeAll("fn alpha\n"); for (1..13) |i| try w.print(" a{d}\n", .{i}); try w.writeAll("def beta_with_a_name_long_enough_to_be_cut_short():\n"); for (1..13) |i| try w.print(" b{d}\n", .{i}); var text = try out.toOwnedSlice(); for (own) |r| { const at = std.mem.find(u8, text, r[0]).?; const changed = try std.mem.concat(gpa, u8, &.{ text[0..at], r[1], text[at + r[0].len ..] }); gpa.free(text); text = changed; } return text; } test "merges: combined diffs and diff drivers" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); const files = [_][]const u8{ "f.t", "g.py", "h" }; const texts = [_][]const [2][]const u8{ &.{}, &.{.{ " a12\n", " side12\n" }}, &.{.{ " b10\n", " main10\n" }}, &.{ .{ " a12\n", " side12\n" }, .{ " b10\n", " main10\n" }, .{ " a2\n", " own2\n" }, .{ " a9\n", " own9\n" }, .{ " b5\n", " own5\n" } }, }; var contents: [texts.len][]u8 = undefined; for (texts, 0..) |t, k| contents[k] = try driverText(gpa, t); defer for (contents) |c| gpa.free(c); for (files) |f| try write(&repo, f, contents[0]); try write(&repo, "bin.t", "x\x00\n"); try repo.git(&.{ "add", "." }); try repo.commit("base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); for (files) |f| try write(&repo, f, contents[1]); try write(&repo, "bin.t", "side\x00\n"); try repo.git(&.{ "commit", "-q", "-a", "-m", "side" }); try repo.git(&.{ "checkout", "-q", "main" }); for (files) |f| try write(&repo, f, contents[2]); try repo.git(&.{ "commit", "-q", "-a", "-m", "main" }); try repo.git(&.{ "merge", "-q", "--no-commit", "side" }); for (files) |f| try write(&repo, f, contents[3]); try write(&repo, "bin.t", "merge\x00\n"); try repo.git(&.{ "add", "-A" }); try repo.commit("merge"); // Attributes in the working tree, read as the commands run. for ([_][]const u8{ "*.t diff=t\n*.py diff=python\n", "* diff=t\nbin.t diff\n", "h binary\n", }) |attributes| { try write(&repo, ".gitattributes", attributes); for ([_][]const []const u8{ &.{ "show", "-c" }, &.{ "show", "-c", "-U1" }, &.{ "show", "-c", "-W" }, &.{ "show", "--diff-merges=combined", "--function-context", "-U5" }, &.{ "-c", "diff.t.xfuncname=^fn()", "show", "-c" }, &.{ "-c", "diff.t.xfuncname=[", "show", "-c" }, &.{ "-c", "diff.t.xfuncname=[", "log", "-c", "-p", "--format=%s" }, &.{ "-c", "diff.t.binary=true", "show", "-c" }, &.{ "-c", "diff.t.binary=false", "show", "-c" }, &.{ "-c", "diff.t.binary=false", "show", "-m", "-p" }, &.{ "-c", "diff.u.binary=maybe", "show", "-c", "--raw" }, }) |args| try same(&repo, args); } } test "merges: an empty format string before each way of showing one" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeMerge(&repo); for ([_][]const u8{ "--format=", "--pretty=format:", "--oneline" }) |pretty| { for ([_][]const []const u8{ &.{ "log", "-c", "-p" }, &.{ "log", "-c", "--raw" }, &.{ "log", "-c", "--name-status" }, &.{ "log", "-c", "--stat", "-p" }, &.{ "log", "-m", "--raw" }, &.{ "log", "--first-parent", "--raw" }, &.{ "show", "-c", "--name-only" }, &.{ "show", "-m", "-p", "--stat" }, }) |args| { var all: [8][]const u8 = undefined; @memcpy(all[0..args.len], args); all[args.len] = pretty; try same(&repo, all[0 .. args.len + 1]); } } } /// A file of numbered lines with some of them replaced: `changes` holds /// the line numbers (from 1) and their new text; a null text deletes the /// line. fn numbered(gpa: std.mem.Allocator, changes: []const struct { usize, ?[]const u8 }) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); line: for (1..31) |i| { for (changes) |c| if (c[0] == i) { if (c[1]) |t| try out.writer.print("{s}\n", .{t}); continue :line; }; try out.writer.print("l{d}\n", .{i}); } return out.toOwnedSlice(); } test "merges: dense combined diffs with --cc, show's default" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); // Each file: the first parent's changes, the second's, the merge's. const Change = struct { usize, ?[]const u8 }; const files = [_]struct { name: []const u8, ours: []const Change, theirs: []const Change, merged: []const Change }{ // Only the conflict is left: each side's own change is taken. .{ .name = "picks", .ours = &.{ .{ 3, "A3" }, .{ 15, "A15" } }, .theirs = &.{ .{ 3, "B3" }, .{ 10, "B10" } }, .merged = &.{ .{ 3, "R3" }, .{ 10, "B10" }, .{ 15, "A15" } } }, // Taken changes close to the conflict are shown with it: one, two // and three lines away, the last left out. .{ .name = "near", .ours = &.{ .{ 3, "A3" }, .{ 5, "A5" }, .{ 8, "A8" }, .{ 20, "A20" } }, .theirs = &.{ .{ 3, "B3" }, .{ 24, "B24" } }, .merged = &.{ .{ 3, "R3" }, .{ 5, "A5" }, .{ 8, "A8" }, .{ 20, "A20" }, .{ 24, "B24" } } }, // Two taken changes close together make a stretch the result // takes from neither parent. .{ .name = "together", .ours = &.{.{ 3, "A3" }}, .theirs = &.{.{ 5, "B5" }}, .merged = &.{ .{ 3, "A3" }, .{ 5, "B5" } } }, // Deletions, and a lost line at the edge of a hunk. .{ .name = "deleted", .ours = &.{ .{ 3, "A3" }, .{ 7, null } }, .theirs = &.{ .{ 3, "B3" }, .{ 12, null } }, .merged = &.{ .{ 3, "R3" }, .{ 7, null }, .{ 12, null } } }, // Every change taken from one side: the file is left out. .{ .name = "quiet", .ours = &.{.{ 3, "A3" }}, .theirs = &.{.{ 20, "B20" }}, .merged = &.{ .{ 3, "A3" }, .{ 20, "B20" } } }, }; for (files) |f| { const text = try numbered(gpa, &.{}); defer gpa.free(text); try write(&repo, f.name, text); } try write(&repo, "mode-only", "m\n"); try repo.git(&.{ "add", "." }); try repo.commit("base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); for (files) |f| { const text = try numbered(gpa, f.theirs); defer gpa.free(text); try write(&repo, f.name, text); } try write(&repo, "mode-only", "side\n"); try repo.git(&.{ "commit", "-q", "-a", "-m", "side" }); try repo.git(&.{ "checkout", "-q", "main" }); for (files) |f| { const text = try numbered(gpa, f.ours); defer gpa.free(text); try write(&repo, f.name, text); } try repo.git(&.{ "update-index", "--chmod=+x", "mode-only" }); try repo.git(&.{ "commit", "-q", "-a", "-m", "main" }); // Conflicted: the strategy only records the second parent, and the // merge's own contents are written below. try repo.git(&.{ "merge", "-q", "-s", "ours", "--no-commit", "side" }); for (files) |f| { const text = try numbered(gpa, f.merged); defer gpa.free(text); try write(&repo, f.name, text); } // The merge takes the side's contents and main's mode: a mode line // and no hunks. try write(&repo, "mode-only", "side\n"); try repo.git(&.{ "add", "-A" }); try repo.git(&.{ "update-index", "--chmod=+x", "mode-only" }); try repo.commit("merge"); for ([_][]const []const u8{ &.{"show"}, &.{ "show", "--cc" }, &.{ "show", "--diff-merges=dense-combined" }, &.{ "show", "--diff-merges=cc", "--raw", "-p" }, &.{ "show", "--cc", "-U1" }, &.{ "show", "--cc", "-U2" }, &.{ "show", "--cc", "-U5" }, &.{ "show", "--cc", "-U0" }, &.{ "show", "-c", "-U0" }, &.{ "log", "--cc", "-p", "-U0", "--format=%s" }, &.{ "show", "--cc", "--inter-hunk-context=3" }, &.{ "show", "-c", "--inter-hunk-context=3" }, &.{ "show", "--cc", "--stat", "-p" }, &.{ "show", "--cc", "--name-status" }, &.{ "show", "--cc", "--format=" }, &.{ "show", "--cc", "-w" }, &.{ "show", "--cc", "-Ia" }, &.{ "show", "-c", "-Ia" }, &.{ "log", "--cc", "-p", "--format=%s" }, &.{ "log", "--cc", "--oneline" }, &.{ "log", "--cc", "-p", "--format=%s", "--", "quiet" }, }) |args| try same(&repo, args); try refused(&repo, &.{ "show", "--cc", "--ignore-blank-lines" }); } test "merges: combined diffs of paths renamed against a parent" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); const base = try numbered(gpa, &.{}); defer gpa.free(base); try write(&repo, "f", base); try write(&repo, "same", base); try repo.git(&.{ "add", "." }); try repo.commit("base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); const side = try numbered(gpa, &.{.{ 3, "S3" }}); defer gpa.free(side); try write(&repo, "f", side); try repo.git(&.{ "commit", "-q", "-a", "-m", "side" }); try repo.git(&.{ "checkout", "-q", "main" }); try repo.git(&.{ "mv", "f", "g" }); const ours = try numbered(gpa, &.{.{ 15, "M15" }}); defer gpa.free(ours); try write(&repo, "g", ours); try repo.git(&.{ "commit", "-q", "-a", "-m", "main" }); try repo.git(&.{ "merge", "-q", "-s", "ours", "--no-commit", "side" }); const merged = try numbered(gpa, &.{ .{ 3, "S3" }, .{ 9, "E9" }, .{ 15, "M15" } }); defer gpa.free(merged); try write(&repo, "g", merged); // Renamed against both parents, its contents unchanged: listed, but // without a patch. try repo.git(&.{ "mv", "same", "same-moved" }); try repo.git(&.{ "add", "-A" }); try repo.commit("merge"); for ([_][]const []const u8{ &.{ "show", "-c" }, &.{"show"}, &.{ "show", "-c", "--raw" }, &.{ "show", "-c", "--name-status" }, &.{ "show", "--cc", "--stat" }, &.{ "show", "-c", "--no-renames" }, &.{ "show", "-c", "--raw", "--no-renames" }, &.{ "show", "-c", "-C", "--raw", "-p" }, &.{ "show", "-c", "-U0" }, &.{ "log", "-c", "-p", "--format=%s" }, // Each parent's path as well as the merge's. &.{ "show", "-c", "--combined-all-paths" }, &.{ "show", "--cc", "--combined-all-paths", "--raw", "-p" }, &.{ "show", "-c", "--combined-all-paths", "--name-status" }, &.{ "show", "-c", "--combined-all-paths", "--name-only", "-z" }, &.{ "show", "-c", "--combined-all-paths", "--raw", "-z" }, &.{ "show", "-c", "--combined-all-paths", "--no-renames" }, &.{ "show", "-c", "--combined-all-paths", "--src-prefix=S/", "--stat", "-p" }, &.{ "log", "--cc", "--combined-all-paths", "--format=%s" }, &.{ "-c", "log.diffMerges=combined", "log", "-m", "--combined-all-paths", "--raw", "--format=%s" }, // Forgotten at a later choice of how merges are shown, and // refused where none is combined. &.{ "show", "--combined-all-paths", "-c", "--name-status" }, &.{ "show", "--combined-all-paths", "-m", "--name-status" }, &.{ "show", "--combined-all-paths" }, &.{ "show", "-c", "-m", "--combined-all-paths" }, &.{ "show", "-c", "--combined-all-paths", "--no-diff-merges" }, &.{ "log", "--combined-all-paths", "--oneline" }, &.{ "show", "--dd", "--combined-all-paths" }, }) |args| try same(&repo, args); } /// Makes branch `name` a merge of commits holding `parents` in file `f`, /// itself holding `result`: written with plumbing, so that any number of /// parents, and parents the same, can be had. fn mergeOf(repo: *Repo, name: []const u8, parents: []const []const u8, result: []const u8) !void { const gpa = repo.gpa; var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); var owned: std.ArrayList([]u8) = .empty; defer { for (owned.items) |o| gpa.free(o); owned.deinit(gpa); } try args.appendSlice(gpa, &.{ "commit-tree", "-m", name }); for (parents) |text| { const id = try commitOf(repo, text); try owned.append(gpa, id); try args.appendSlice(gpa, &.{ "-p", id }); } const tree = try treeOf(repo, result); defer gpa.free(tree); try args.append(gpa, tree); const out = try repo.output(args.items); defer gpa.free(out); const ref = try std.fmt.allocPrint(gpa, "refs/heads/{s}", .{name}); defer gpa.free(ref); try repo.git(&.{ "update-ref", ref, std.mem.trimEnd(u8, out, "\n") }); } /// A tree holding `text` in file `f`, its ID in hexadecimal, which the /// caller frees. fn treeOf(repo: *Repo, text: []const u8) ![]u8 { const gpa = repo.gpa; const blob = try repo.hashObject("blob", text); defer gpa.free(blob); const line = try std.fmt.allocPrint(gpa, "100644 blob {s}\tf\n", .{blob}); defer gpa.free(line); const out = try repo.outputWithInput(&.{"mktree"}, line); defer gpa.free(out); return gpa.dupe(u8, std.mem.trimEnd(u8, out, "\n")); } /// A root commit holding `text` in file `f`, its ID in hexadecimal, /// which the caller frees. fn commitOf(repo: *Repo, text: []const u8) ![]u8 { const gpa = repo.gpa; const tree = try treeOf(repo, text); defer gpa.free(tree); const out = try repo.output(&.{ "commit-tree", "-m", "parent", tree }); defer gpa.free(out); return gpa.dupe(u8, std.mem.trimEnd(u8, out, "\n")); } test "merges: how each parent's line diff is made and combined" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commitFile("f", "start\n", "start"); // A long common tail: each parent's line diff sets it aside as a // patch without context does (`LineDiff.keptLength`), whatever the // context, which moves where the lines added here go. var tail: std.ArrayList(u8) = .empty; defer tail.deinit(gpa); for (0..78) |k| try tail.print(gpa, "t{d:0>3} zzzzzzzzzzzzzzzzzzzz\n", .{k}); const tail_texts = [_][]const u8{ "y\n }\n }\n\n a\n", "Y\n }\n\n\nx\n a\n }\n }\n\n a\n", "y\n }\n\n\nx\n a\n }\n }\n\n a\n", }; var with_tail: [3][]u8 = undefined; for (tail_texts, &with_tail) |t, *w| w.* = try std.mem.concat(gpa, u8, &.{ t, tail.items }); defer for (with_tail) |w| gpa.free(w); try mergeOf(&repo, "tail", &.{ with_tail[0], with_tail[1] }, with_tail[2]); // Parents the same as an earlier one are marked where it is, rather // than having their lost lines merged again, which could match // another line. const p = "P1\nP2\n"; const q = "Q1\nQ2\n"; try mergeOf(&repo, "same4", &.{ p ++ "a\nb\n" ++ q, p ++ "a\n" ++ q, p ++ "c\nb\na\n" ++ q, p ++ "a\n" ++ q }, p ++ q); try mergeOf(&repo, "same-first", &.{ p ++ "a\n" ++ q, p ++ "c\n" ++ q, p ++ "c\na\n" ++ q, p ++ "a\n" ++ q }, p ++ q); try mergeOf(&repo, "same-two", &.{ p ++ "a\nb\n" ++ q, p ++ "b\na\n" ++ q, p ++ "b\na\n" ++ q }, p ++ "R\n" ++ q); // Lost lines differing only in whitespace are merged under -w and // -b, the first parent's written. try mergeOf(&repo, "spaces", &.{ p ++ " x\nz\n" ++ q, p ++ "x \n\tz\n" ++ q, p ++ "y\nx\n" ++ q }, p ++ "R\n" ++ q); for ([_][]const u8{ "tail", "same4", "same-first", "same-two", "spaces" }) |name| { for ([_][]const []const u8{ &.{ "show", "-c" }, &.{ "show", "--cc" }, &.{ "show", "-c", "-U1" }, &.{ "show", "--cc", "-U0" }, &.{ "show", "-c", "-w" }, &.{ "show", "-c", "-b" }, &.{ "show", "--cc", "--ignore-space-at-eol" }, &.{ "show", "-c", "--histogram" }, &.{ "log", "--cc", "-1" }, }) |args| { var with_name: [8][]const u8 = undefined; @memcpy(with_name[0..args.len], args); with_name[args.len] = name; try same(&repo, with_name[0 .. args.len + 1]); } } } /// Checks that `zig-git ` refuses with exactly `message` on /// standard error, so that no error's bare name stands in for one. fn refusedWith(repo: *Repo, args: []const []const u8, message: []const u8) !void { const ours = try tool.runOurs(repo, args, .{}); defer ours.deinit(repo.gpa); errdefer { std.debug.print("zig-git", .{}); for (args) |a| std.debug.print(" {s}", .{a}); std.debug.print("\n", .{}); } try testing.expectEqual(128, ours.code); try testing.expectEqualStrings(message, ours.stderr); } /// a = A B C, b = C B A, `half` lines each with B of three lines and C /// of 300, which searches for a middle snake past 256 steps once `half` /// is over 32,766. fn blockSwap(gpa: std.mem.Allocator, half: usize, old: *std.ArrayList(u8), new: *std.ArrayList(u8)) !void { 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}); for ([_]*std.ArrayList(u8){ old, new }, [_][3][]const u8{ .{ a_part.items, "b0\nb1\nb2\n", c_part.items }, .{ c_part.items, "b0\nb1\nb2\n", a_part.items }, }) |out, parts| for (parts) |p| try out.appendSlice(gpa, p); } test "merges: counts against the first parent of a line diff searching too long" { // The large file is the second parent's, so the combined diff leaves // it out and is git's; the counts against the first parent compare // it, and are refused by name, as every format of a two-way diff is. const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); var old: std.ArrayList(u8) = .empty; defer old.deinit(gpa); var new: std.ArrayList(u8) = .empty; defer new.deinit(gpa); try blockSwap(gpa, 32767, &old, &new); try write(&repo, "large", old.items); try write(&repo, "small", "1\n"); try repo.git(&.{ "add", "." }); try repo.commit("base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); try write(&repo, "large", new.items); try repo.git(&.{ "commit", "-q", "-a", "-m", "side" }); try repo.git(&.{ "checkout", "-q", "main" }); try write(&repo, "small", "2\n"); try repo.git(&.{ "commit", "-q", "-a", "-m", "main" }); try repo.git(&.{ "merge", "-q", "--no-edit", "side" }); for ([_][]const []const u8{ &.{"show"}, &.{ "log", "-1", "--stat" }, &.{ "show", "-c" }, &.{ "show", "--raw" }, &.{ "show", "--name-status" }, &.{ "show", "--summary" }, &.{ "show", "--stat", "--minimal" }, &.{ "show", "-m", "--numstat", "--diff-algorithm=minimal" }, }) |args| try same(&repo, args); const show = "fatal: zig-git show: 'a line diff searching past 256 steps' is not supported\n"; const log = "fatal: zig-git log: 'a line diff searching past 256 steps' is not supported\n"; for ([_][]const []const u8{ &.{ "show", "--stat" }, &.{ "show", "--numstat" }, &.{ "show", "--shortstat" }, &.{ "show", "--compact-summary" }, &.{ "show", "--dirstat=lines" }, &.{ "show", "-c", "--stat" }, &.{ "show", "--cc", "--numstat" }, &.{ "show", "-m", "--stat" }, &.{ "show", "--first-parent" }, &.{ "show", "--diff-merges=1", "--numstat" }, &.{ "show", "HEAD^2", "--stat" }, }) |args| try refusedWith(&repo, args, show); for ([_][]const []const u8{ &.{ "log", "-1", "--stat", "HEAD^2" }, &.{ "log", "--cc", "-1", "--stat", "HEAD" }, &.{ "log", "-m", "-1", "--numstat", "HEAD" }, &.{ "log", "-G", "a1", "--oneline" }, }) |args| try refusedWith(&repo, args, log); try refusedWith(&repo, &.{ "diff-tree", "-m", "--stat", "HEAD" }, "fatal: zig-git diff-tree: 'a line diff searching past 256 steps' is not supported\n"); try refusedWith(&repo, &.{ "diff", "--numstat", "HEAD^", "HEAD" }, "fatal: zig-git diff: 'a line diff searching past 256 steps' is not supported\n"); } /// A merge whose diff against its first parent reaches a rename limit /// of 1 needing 5, of side commits that need 2 (`s1`) and 3 (`s2`), and /// a commit of the first parent's (`mainc`) that renames nothing. fn makeRenamingMerge(repo: *Repo) !void { const gpa = repo.gpa; for (1..7) |i| { const text = try renamingText(gpa, i, ""); defer gpa.free(text); var name_buffer: [8]u8 = undefined; try write(repo, try std.fmt.bufPrint(&name_buffer, "f{d}", .{i}), text); } try repo.git(&.{ "add", "." }); try repo.commit("base"); try repo.git(&.{ "checkout", "-q", "-b", "side" }); for ([_][]const usize{ &.{ 1, 2 }, &.{ 3, 4, 5 } }, [_][]const u8{ "s1", "s2" }) |files, tag| { for (files) |i| { var from_buffer: [8]u8 = undefined; var to_buffer: [8]u8 = undefined; const to = try std.fmt.bufPrint(&to_buffer, "g{d}", .{i}); try repo.git(&.{ "mv", try std.fmt.bufPrint(&from_buffer, "f{d}", .{i}), to }); // Changed a little, so that only an inexact search finds it. const text = try renamingText(gpa, i, "changed\n"); defer gpa.free(text); try write(repo, to, text); } try repo.git(&.{ "commit", "-q", "-a", "-m", tag }); const ref = try std.fmt.allocPrint(gpa, "refs/tags/{s}", .{tag}); defer gpa.free(ref); try repo.git(&.{ "update-ref", ref, "HEAD" }); } try repo.git(&.{ "checkout", "-q", "main" }); try write(repo, "f6", "changed\n"); try repo.git(&.{ "commit", "-q", "-a", "-m", "main" }); try repo.git(&.{ "update-ref", "refs/tags/mainc", "HEAD" }); try repo.git(&.{ "merge", "-q", "--no-edit", "side" }); } /// Fifty lines naming file `i`, then `extra`. fn renamingText(gpa: std.mem.Allocator, i: usize, extra: []const u8) ![]u8 { var text: std.ArrayList(u8) = .empty; defer text.deinit(gpa); for (1..51) |k| try text.print(gpa, "f{d} {d}\n", .{ i, k }); try text.appendSlice(gpa, extra); return text.toOwnedSlice(gpa); } test "merges: when the rename limit is warned of" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeRenamingMerge(&repo); const gpa = testing.allocator; for ([_][]const []const u8{ // A combined diff never warns, though its renames are cut short. &.{ "show", "--stat" }, &.{"show"}, &.{ "show", "-c", "--raw" }, &.{ "show", "--find-copies-harder" }, &.{ "log", "--cc", "--stat" }, &.{ "log", "-1", "-c", "-p", "HEAD" }, // Against a parent alone, it does. &.{ "show", "-m", "--stat" }, &.{ "show", "--first-parent" }, &.{ "show", "--diff-merges=1", "--raw" }, &.{ "log", "-m", "--stat" }, &.{ "log", "-1", "-m", "-s", "HEAD" }, // `show` warns after each commit it is given, with what that one // needed, or again with what the last needed when it needs // nothing; `log` once, with the most any commit needed. &.{ "show", "s2", "s1", "HEAD" }, &.{ "show", "s1", "s2", "HEAD" }, &.{ "show", "HEAD", "s1" }, &.{ "show", "s1", "mainc" }, &.{ "show", "s2", "mainc", "s1" }, &.{ "show", "s1", "mainc:f6" }, &.{ "show", "-s", "s1", "mainc" }, &.{ "show", "-m", "s2", "HEAD" }, &.{ "show", "--diff-filter=R", "s1", "mainc" }, &.{ "show", "-G", "x", "s1", "mainc" }, &.{ "log", "--stat", "--reverse" }, &.{ "log", "-C", "-C", "--stat" }, // One within the limit writes nothing, but once a search has // fallen back on copies from modified paths, says so alone. &.{ "show", "-C", "-C", "s1", "s2" }, &.{ "show", "-C", "-C", "s2", "s1", "HEAD" }, &.{ "show", "-C", "-C", "s2", "mainc", "s1" }, &.{ "show", "-C", "s2", "s1", "mainc" }, // --follow never warns. &.{ "log", "--follow", "--stat", "s2", "--", "g4" }, &.{ "log", "--follow", "--oneline", "s2", "--", "g1" }, }) |args| for ([_][]const u8{ "1", "2", "3", "4" }) |limit| { const setting = try std.fmt.allocPrint(gpa, "diff.renameLimit={s}", .{limit}); defer gpa.free(setting); var all: std.ArrayList([]const u8) = .empty; defer all.deinit(gpa); try all.appendSlice(gpa, &.{ "-c", setting }); try all.appendSlice(gpa, args); try same(&repo, all.items); }; }