// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Walking history on the command line, compared with git: `rev-list` //! with every ordering and limiting option, singly and combined, over //! long generated histories — merges, octopus merges, criss-cross merges, //! equal timestamps, commits dated before their parents, author dates //! that disagree with commit dates — without a commit-graph, with one, and //! with a split chain of them; `merge-base` in all its modes; and corrupt //! commit-graphs, whose complaints are git's. Every test runs under SHA-1 //! and SHA-256. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const git = @import("git"); const tool = @import("zig_git.zig"); const Repo = tool.Repo; const testing = std.testing; fn same(repo: *Repo, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{}); } fn sameOutput(repo: *Repo, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{ .compare = .output }); } /// The same as git, or, walking `--author-date-order` by generation /// number, refused where git orders by an author date it has not read. fn sameOrAuthorDateRefused(repo: *Repo, args: []const []const u8) !void { const by_author_date = for (args) |a| { if (std.mem.eql(u8, a, "--author-date-order")) break true; } else false; if (by_author_date) { const ours = try tool.runOurs(repo, args, .{}); defer ours.deinit(repo.gpa); if (ours.code == 128 and std.mem.find(u8, ours.stderr, "--author-date-order is not supported") != null) { try testing.expectEqualStrings("", ours.stdout); return; } } try same(repo, args); } /// `zig-git ` refuses by name what git would answer. 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("zig-git", .{}); for (args) |a| std.debug.print(" {s}", .{a}); std.debug.print("\n{s}", .{ours.stderr}); } try testing.expectEqual(128, ours.code); try testing.expectEqualStrings("", ours.stdout); try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null); } const branches = [_][]const u8{ "main", "b1", "b2", "b3", "b4" }; /// A history of `n` commits on five branches, made with `git /// fast-import`: merges of one to three other branches, criss-cross /// merges, clocks that stand still and run backwards, author dates apart /// from commit dates, annotated and lightweight tags and remote-tracking /// refs. pub fn makeHistory(repo: *Repo, seed: u64, n: usize) !void { const gpa = repo.gpa; var prng: std.Random.DefaultPrng = .init(seed); const random = prng.random(); var stream: Io.Writer.Allocating = .init(gpa); defer stream.deinit(); const w = &stream.writer; var tips: [branches.len]?usize = @splat(null); var date: u64 = 1_790_000_000; for (1..n + 1) |mark| { const b = random.uintLessThan(usize, branches.len); switch (random.uintLessThan(u8, 10)) { 0, 1 => {}, 2, 3 => date -= 1 + random.uintLessThan(u64, 300), else => date += 1 + random.uintLessThan(u64, 100), } const author = date + random.uintLessThan(u64, 600) - 300; try w.print("commit refs/heads/{s}\nmark :{d}\n", .{ branches[b], mark }); try w.print("author A {d} +0000\ncommitter C {d} +0000\n", .{ author, date }); const message = try std.fmt.allocPrint(gpa, "commit {d} on {s}\n", .{ mark, branches[b] }); defer gpa.free(message); try w.print("data {d}\n{s}", .{ message.len, message }); if (tips[b]) |t| try w.print("from :{d}\n", .{t}); if (tips[b] != null and random.uintLessThan(u8, 10) < 3) { const others: usize = if (random.uintLessThan(u8, 10) < 7) 1 else 1 + random.uintLessThan(usize, 3); var merged: usize = 0; for (0..branches.len) |o| { if (merged == others) break; const other = (b + 1 + o) % branches.len; const t = tips[other] orelse continue; if (t == tips[b].?) continue; try w.print("merge :{d}\n", .{t}); merged += 1; } } try w.print("M 100644 inline f{d}\ndata 2\n{d}\n\n", .{ mark % 7, mark % 10 }); tips[b] = mark; if (mark % 23 == 0) { try w.print("tag t{d}\nfrom :{d}\ntagger T {d} +0000\ndata 4\ntag\n\n", .{ mark, mark, date }); } else if (mark % 19 == 0) { try w.print("reset refs/tags/l{d}\nfrom :{d}\n\n", .{ mark, mark }); } if (mark % 31 == 0) try w.print("reset refs/remotes/origin/{s}\nfrom :{d}\n\n", .{ branches[b], mark }); } const imported = try repo.runIn(".", &.{ "fast-import", "--quiet" }, stream.written()); defer imported.deinit(gpa); try testing.expectEqual(0, imported.code); try repo.git(&.{ "symbolic-ref", "HEAD", "refs/heads/main" }); } const ranges = [_][]const []const u8{ &.{"--all"}, &.{"main"}, &.{ "main", "b2" }, &.{"b1..main"}, &.{"main..b1"}, &.{"b1...main"}, &.{"b2...b4"}, &.{ "^b2", "main", "b3" }, &.{ "main", "--not", "b2", "b3" }, &.{ "--not", "b4", "--not", "main" }, &.{"main~5..main"}, &.{"main^@"}, &.{"main^!"}, &.{"b1^-"}, &.{ "--branches=b*", "^main" }, &.{"--tags"}, &.{ "--remotes", "^b3" }, &.{ "--exclude=b2", "--branches" }, &.{ "--exclude=refs/heads/main", "--all" }, &.{"--glob=heads/b[12]"}, }; const extras = [_][]const []const u8{ &.{}, &.{"--reverse"}, &.{ "-n", "7" }, &.{ "--skip=3", "--max-count=4" }, &.{"-3"}, &.{"--merges"}, &.{"--no-merges"}, &.{"--min-parents=3"}, &.{"--max-parents=0"}, &.{"--first-parent"}, &.{ "--first-parent", "--parents" }, &.{"--boundary"}, &.{ "--boundary", "-n", "5" }, &.{ "--boundary", "--reverse" }, &.{ "--boundary", "--max-parents=1" }, &.{"--parents"}, &.{"--children"}, &.{ "--children", "--boundary" }, &.{"--timestamp"}, &.{"--count"}, &.{ "--count", "--left-right" }, &.{"--left-right"}, &.{ "--left-right", "--boundary" }, &.{"--left-only"}, &.{"--right-only"}, &.{"--abbrev-commit"}, &.{ "--abbrev-commit", "--abbrev=10", "--parents" }, &.{"--format=%H"}, &.{ "--format=%H", "--no-commit-header" }, &.{"--max-age=1790000300"}, &.{ "--max-age=1790000300", "--boundary" }, &.{"--min-age=1790000300"}, &.{"--since-as-filter=@1790000300"}, }; /// Copies the repository to where `ZIG_GIT_KEEP` says, for looking at a /// failure by hand. fn keep(repo: *Repo) !void { const dest = testing.environ.getAlloc(repo.gpa, "ZIG_GIT_KEEP") catch return; defer repo.gpa.free(dest); const r = try std.process.run(repo.gpa, repo.io, .{ .argv = &.{ "cp", "-a", repo.path, dest } }); repo.gpa.free(r.stdout); repo.gpa.free(r.stderr); } const orders = [_][]const []const u8{ &.{}, &.{"--topo-order"}, &.{"--date-order"}, &.{"--author-date-order"} }; /// Every extra option with every range in the default order, and a third /// of them, a different third for each range, in each of the others. fn walkEverything(repo: *Repo, graph: bool) !void { const gpa = repo.gpa; var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); for (ranges, 0..) |range, r| for (orders, 0..) |order, o| for (extras, 0..) |extra, e| { if (o != 0 and (r + o + e) % 3 != 0) continue; args.clearRetainingCapacity(); try args.append(gpa, "rev-list"); try args.appendSlice(gpa, order); try args.appendSlice(gpa, extra); try args.appendSlice(gpa, range); // With a commit-graph git walks these by generation number, as // the library does, except where --children, --left-only or // --right-only make it order the whole list instead: there, these // are refused. const dated = for (extra) |x| { if (std.mem.eql(u8, x, "--first-parent") or std.mem.startsWith(u8, x, "--max-age") or std.mem.startsWith(u8, x, "--min-age") or std.mem.startsWith(u8, x, "--since-as-filter")) break true; } else false; var has_children = false; var has_boundary = false; var whole_list = false; for (extra) |x| { if (std.mem.eql(u8, x, "--children")) has_children = true; if (std.mem.eql(u8, x, "--boundary")) has_boundary = true; if (std.mem.eql(u8, x, "--children") or std.mem.eql(u8, x, "--left-only") or std.mem.eql(u8, x, "--right-only")) whole_list = true; } const graph_order = graph and o != 0 and dated and whole_list; const children_boundary_symmetric = has_children and has_boundary and std.mem.find(u8, range[0], "...") != null; if (graph_order or children_boundary_symmetric) { try refused(repo, args.items); continue; } if (graph) try sameOrAuthorDateRefused(repo, args.items) else try same(repo, args.items); }; } test "rev-list walks a long history as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 1, 300); try walkEverything(&repo, false); } test "rev-list walks a history with a commit-graph as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 2, 300); try repo.git(&.{ "commit-graph", "write", "--reachable", "--changed-paths" }); try walkEverything(&repo, true); } test "rev-list walks a history with a split commit-graph chain as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 3, 150); try repo.git(&.{ "commit-graph", "write", "--reachable", "--split" }); // More history on top, in a second layer. for (0..20) |_| try repo.commit("on top"); try repo.git(&.{ "commit-graph", "write", "--reachable", "--split=no-merge" }); try keep(&repo); try walkEverything(&repo, true); } /// What the walk by generation number was found by: orderings with /// --first-parent, the dates and exclusions, over clocks that run /// backwards, octopus merges and commits past the end of the graph. fn walkByGeneration(repo: *Repo) !void { const gpa = repo.gpa; var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); const limits = [_][]const []const u8{ &.{"--first-parent"}, &.{ "--first-parent", "--parents" }, &.{ "--first-parent", "--boundary" }, &.{ "--first-parent", "-n", "9" }, &.{"--max-age=1790000300"}, &.{ "--max-age=1790000300", "--first-parent" }, &.{ "--min-age=1790000300", "--first-parent" }, &.{ "--since-as-filter=@1790000300", "--first-parent" }, &.{ "--first-parent", "--left-right" }, }; for (ranges[0..10]) |range| for (orders[1..]) |order| for (limits) |limit| { args.clearRetainingCapacity(); try args.append(gpa, "rev-list"); try args.appendSlice(gpa, order); try args.appendSlice(gpa, limit); try args.appendSlice(gpa, range); try sameOrAuthorDateRefused(repo, args.items); }; } test "rev-list walks by generation number as git does, with or without generation data" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 8, 200); try repo.git(&.{ "-c", "commitGraph.generationVersion=1", "commit-graph", "write", "--reachable" }); try walkByGeneration(&repo); try repo.git(&.{ "commit-graph", "write", "--reachable" }); try walkByGeneration(&repo); // Commits the graph does not list, whose generation is infinite. for (0..15) |_| try repo.commit("past the graph"); try walkByGeneration(&repo); } test "rev-list matches git in thousands of commits and many seeds" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 4, 2500); for (ranges) |range| for (orders) |order| { var args: std.ArrayList([]const u8) = .empty; defer args.deinit(repo.gpa); try args.append(repo.gpa, "rev-list"); try args.appendSlice(repo.gpa, order); try args.appendSlice(repo.gpa, range); try same(&repo, args.items); }; for ([_][]const []const u8{ &.{ "rev-list", "--boundary", "b1...b3" }, &.{ "rev-list", "--count", "--left-right", "b1...b3" }, &.{ "rev-list", "--children", "--topo-order", "b2..main" }, }) |a| try same(&repo, a); } test "rev-list says what git says of its arguments" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 5, 60); for ([_][]const []const u8{ &.{ "rev-list", "nothing" }, &.{ "rev-list", "main", "nothing" }, &.{ "rev-list", "-n" }, &.{ "rev-list", "-n", "x", "main" }, &.{ "rev-list", "--max-count=x", "main" }, &.{ "rev-list", "--max-parents=x", "main" }, &.{ "rev-list", "-n", "-1", "main" }, &.{ "rev-list", "--skip=-1", "main" }, &.{ "rev-list", "main", "--" }, &.{ "rev-list", "main", "^" }, &.{ "rev-list", "main^{tree}" }, &.{ "rev-list", "--ancestry-path", "main" }, &.{ "rev-list", "--since=nonsense", "main" }, &.{ "rev-list", "--reverse", "--reverse", "main" }, &.{ "rev-list", "--branches=nomatch" }, &.{ "rev-list", "--simplify-merges", "main" }, &.{ "rev-list", "main", "--", "f1" }, }) |a| try same(&repo, a); // Usage errors, in git's words, which are its own. for ([_][]const []const u8{ &.{"rev-list"}, &.{ "rev-list", "--count" }, &.{ "rev-list", "--no-such-option", "main" }, }) |a| try sameOutput(&repo, a); for ([_][]const []const u8{ &.{ "rev-list", "--cherry-pick", "main...b1" }, &.{ "rev-list", "--cherry-mark", "main...b1" }, &.{ "rev-list", "--cherry", "main...b1" }, &.{ "rev-list", "--objects", "main" }, &.{ "rev-list", "--simplify-by-decoration", "b1..main" }, &.{ "rev-list", "--follow", "main", "--", "f1" }, &.{ "rev-list", "--ancestry-path=main~2", "main~5..main" }, }) |a| try refused(&repo, a); } test "rev-list reads dates as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 6, 80); try repo.env.put("GIT_TEST_DATE_NOW", "1790003600"); try repo.env.put("TZ", "EST5EDT,M3.2.0,M11.1.0"); for ([_][]const []const u8{ &.{ "rev-list", "--since=2026-09-21 10:10", "main" }, &.{ "rev-list", "--until=2026-09-21 10:20", "main" }, &.{ "rev-list", "--after=1 hour ago", "--before=40 minutes ago", "--all" }, &.{ "rev-list", "--since-as-filter=2026-09-21 10:10", "main" }, &.{ "rev-list", "--since=2026-09-21 14:10 +0000", "--topo-order", "main" }, &.{ "rev-list", "--since=2026-09-21 10:10", "--boundary", "b1..main" }, }) |a| try same(&repo, a); } test "rev-parse prints sets of commits as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 7, 60); for ([_][]const u8{ "b1..main", "b1...main", "^b2", "main^@", "main^!", "main^-", "main^-2", "..b3", "b3..", "b3...", "...b3" }) |set| { for ([_][]const []const u8{ &.{ "rev-parse", set }, &.{ "rev-parse", "--symbolic", set }, &.{ "rev-parse", "--symbolic-full-name", set }, &.{ "rev-parse", "--abbrev-ref", set }, &.{ "rev-parse", "--short", set }, }) |a| try same(&repo, a); } } test "merge-base finds what git finds" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 8, 200); const tips = [_][]const u8{ "main", "b1", "b2", "b3", "b4", "t23", "l19", "main~7", "b2^2", "b1~3" }; for (tips) |a| for (tips) |b| { try same(&repo, &.{ "merge-base", a, b }); try same(&repo, &.{ "merge-base", "--all", a, b }); try same(&repo, &.{ "merge-base", "--is-ancestor", a, b }); }; for ([_][]const []const u8{ &.{ "merge-base", "--all", "main", "b1", "b2", "b3" }, &.{ "merge-base", "main", "b1", "b2" }, &.{ "merge-base", "--octopus", "main", "b1", "b2", "b3" }, &.{ "merge-base", "--octopus", "--all", "main", "b1", "b2", "b3", "b4" }, &.{ "merge-base", "--independent", "main", "b1", "b2", "b3", "b4", "main~3", "b1~2", "main" }, &.{ "merge-base", "--independent", "b2", "main~3", "b2", "b1" }, &.{ "merge-base", "--octopus" }, &.{ "merge-base", "--independent" }, &.{ "merge-base", "nothing", "main" }, &.{ "merge-base", "main^{tree}", "main" }, &.{ "merge-base", "--all", "--is-ancestor", "main", "b1" }, &.{ "merge-base", "--is-ancestor", "main", "b1", "b2" }, }) |a| try same(&repo, a); for ([_][]const []const u8{ &.{"merge-base"}, &.{ "merge-base", "main" }, &.{ "merge-base", "--is-ancestor", "main" }, &.{ "merge-base", "--octopus", "--independent", "main", "b1" }, &.{ "merge-base", "--fork-point" }, &.{ "merge-base", "--no-such-option", "main", "b1" }, }) |a| try sameOutput(&repo, a); // With a commit-graph, the same. try repo.git(&.{ "commit-graph", "write", "--reachable" }); for (tips) |a| for (tips[0..5]) |b| { try same(&repo, &.{ "merge-base", "--all", a, b }); try same(&repo, &.{ "merge-base", "--is-ancestor", a, b }); }; } test "merge-base orders the bases of criss-cross merges as git does" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); // Two branches merging into each other over and over, some of the // merges at the same second. var stream: Io.Writer.Allocating = .init(gpa); defer stream.deinit(); const w = &stream.writer; var mark: usize = 1; try w.print("commit refs/heads/a\nmark :1\ncommitter C 1790000000 +0000\ndata 2\nr\nM 100644 inline f\ndata 2\n0\n\n", .{}); try w.print("reset refs/heads/b\nfrom :1\n\n", .{}); var last_a: usize = 1; var last_b: usize = 1; var date: u64 = 1_790_000_000; for (0..12) |round| { for ([_]u8{ 'a', 'b' }) |side| { mark += 1; if (round % 3 != 0) date += 10; try w.print("commit refs/heads/{c}\nmark :{d}\ncommitter C {d} +0000\ndata 2\n{d}\n", .{ side, mark, date, mark % 10 }); const mine = if (side == 'a') last_a else last_b; const theirs = if (side == 'a') last_b else last_a; try w.print("from :{d}\nmerge :{d}\nM 100644 inline f{c}\ndata 2\n{d}\n\n", .{ mine, theirs, side, mark % 10 }); if (side == 'a') last_a = mark else last_b = mark; } } const imported = try repo.runIn(".", &.{ "fast-import", "--quiet" }, stream.written()); defer imported.deinit(gpa); try testing.expectEqual(0, imported.code); for ([_][]const u8{ "a", "a~1", "a~2", "a^2", "a~3^2" }) |x| for ([_][]const u8{ "b", "b~1", "b^2", "b~2^2" }) |y| { try same(&repo, &.{ "merge-base", "--all", x, y }); try same(&repo, &.{ "merge-base", x, y }); const sym = try std.fmt.allocPrint(gpa, "{s}...{s}", .{ x, y }); defer gpa.free(sym); try same(&repo, &.{ "rev-list", "--boundary", "--left-right", sym }); }; } test "merge-base --fork-point reads the reflog as git does" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commit("o1"); try repo.commit("o2"); try repo.commit("B0"); try repo.git(&.{ "branch", "topic" }); try repo.git(&.{ "update-ref", "refs/remotes/origin/main", "HEAD" }); try repo.git(&.{ "checkout", "-q", "topic" }); try repo.commit("D0"); try repo.commit("D1"); try repo.git(&.{ "checkout", "-q", "main" }); try repo.git(&.{ "reset", "-q", "--hard", "HEAD~1" }); try repo.commit("B1"); try repo.git(&.{ "update-ref", "refs/remotes/origin/main", "HEAD" }); try repo.commit("B2"); try repo.git(&.{ "update-ref", "refs/remotes/origin/main", "HEAD" }); try repo.git(&.{ "branch", "late", "HEAD~1" }); for ([_][]const []const u8{ &.{ "merge-base", "--fork-point", "origin/main", "topic" }, &.{ "merge-base", "--fork-point", "origin/main" }, &.{ "merge-base", "--fork-point", "refs/remotes/origin/main", "topic" }, &.{ "merge-base", "--fork-point", "main", "topic" }, &.{ "merge-base", "--fork-point", "origin/main", "late" }, &.{ "merge-base", "--fork-point", "origin/main", "main~2" }, &.{ "merge-base", "--fork-point", "nothing", "topic" }, &.{ "merge-base", "--fork-point", "origin/main", "nothing" }, &.{ "merge-base", "--fork-point", "HEAD~1", "topic" }, }) |a| try same(&repo, a); } test "rev-list reads a corrupt commit-graph as git does" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try makeHistory(&repo, 9, 40); try repo.git(&.{ "commit-graph", "write", "--reachable" }); try keep(&repo); const path = ".git/objects/info/commit-graph"; const good = try repo.tmp.dir.readFileAlloc(repo.io, path, gpa, .unlimited); defer gpa.free(good); const bytes = try gpa.dupe(u8, good); defer gpa.free(bytes); const h = repo.object_format.byteLength(); const header = 8 + 12 * @as(usize, good[6] + 1); for ([_]struct { at: usize, value: u8 }{ .{ .at = 0, .value = 0x44 }, .{ .at = 4, .value = 2 }, .{ .at = 5, .value = 9 }, .{ .at = 6, .value = 2 }, .{ .at = 6, .value = 200 }, .{ .at = 8, .value = 'X' }, // The first commit's first parent, out of range. .{ .at = header + 1024 + h * countOf(good, h) + h, .value = 0x40 }, }) |c| { @memcpy(bytes, good); bytes[c.at] = c.value; try repo.tmp.dir.deleteFile(repo.io, path); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = bytes }); try same(&repo, &.{ "rev-list", "--parents", "--all" }); // git can find merge bases without reading a commit whose parent // positions are corrupt, where zig-git reads it and stops. if (c.at < header) try same(&repo, &.{ "merge-base", "--all", "main", "b1" }); } try repo.tmp.dir.deleteFile(repo.io, path); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = path, .data = good[0..100] }); try same(&repo, &.{ "rev-list", "--all" }); // core.commitGraph=false reads the objects, and so would git. try same(&repo, &.{ "-c", "core.commitGraph=false", "rev-list", "--parents", "--all" }); } /// The number of commits a commit-graph lists, from its fanout. fn countOf(bytes: []const u8, h: usize) usize { _ = h; const toc = 8; var i: usize = 0; while (i < bytes[6]) : (i += 1) { const row = bytes[toc + 12 * i ..][0..12]; if (std.mem.eql(u8, row[0..4], "OIDF")) { const at: usize = @intCast(std.mem.readInt(u64, row[4..12], .big)); return std.mem.readInt(u32, bytes[at + 255 * 4 ..][0..4], .big); } } return 0; }