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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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" }, }) |args| try same(&repo, args);}