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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! History limited to paths, compared with git: `rev-list` with//! pathspecs, in every simplification mode, singly and with the//! orderings, limits, ranges, `--parents`, `--children`, `--boundary` and//! the sides of a symmetric difference, over generated histories whose//! commits change many paths — merges that take one side, both or//! neither, evil merges, changes undone, deletions and re-adds, mode//! changes, gitlinks and deep directories — without a commit-graph, with//! one, with changed-path Bloom filters and with a split chain whose//! layers differ in having them. Every test runs under SHA-1 and SHA-256.
const std = @import("std");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 sameIn(repo: *Repo, cwd: []const u8, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{ .cwd = cwd });}
/// `zig-git <args>` 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" };const paths = [_][]const u8{ "a", "b", "d/x", "d/y", "d/e/f", "deep/1/2/3/z", "sub", "m" };
const File = struct { mode: []const u8, value: u8 };
/// A history of `n` commits on four branches, made with `git/// fast-import`, whose commits change paths: one to three at a time,/// deleting, re-adding, changing modes and gitlinks; and merges that take/// one side whole, or each path from either side, or something of their/// own. Commits share dates and run backwards in time as the walk tests'/// histories do.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); const Tree = [paths.len]?File; const trees = try gpa.alloc(Tree, n + 1); defer gpa.free(trees); var date: u64 = 1_790_000_000; const hex_len = repo.object_format.hexLength(); for (1..n + 1) |mark| { const b = random.uintLessThan(usize, branches.len); switch (random.uintLessThan(u8, 10)) { 0, 1 => {}, 2 => date -= 1 + random.uintLessThan(u64, 300), else => date += 1 + random.uintLessThan(u64, 100), } var parents: [3]usize = undefined; var np: usize = 0; if (tips[b]) |t| if (random.uintLessThan(u8, 100) >= 3) { parents[0] = t; np = 1; }; if (np == 1 and random.uintLessThan(u8, 100) < 35) { const others: usize = if (random.uintLessThan(u8, 10) < 8) 1 else 2; for (0..branches.len) |o| { if (np - 1 == others) break; const other = (b + 1 + o) % branches.len; const t = tips[other] orelse continue; if (t == parents[0]) continue; parents[np] = t; np += 1; } } var tree: Tree = if (np != 0) trees[parents[0]] else @splat(null); if (np > 1) { const how = random.uintLessThan(u8, 4); for (&tree, 0..) |*slot, i| { const first = trees[parents[0]][i]; const last = trees[parents[np - 1]][i]; const pick = random.uintLessThan(u8, 10); slot.* = switch (how) { 0 => first, 1 => last, else => if (pick < 4) first else if (pick < 8) trees[parents[random.uintLessThan(usize, np)]][i] else if (pick < 9) (if (random.boolean()) null else File{ .mode = "100644", .value = random.uintLessThan(u8, 4) }) else first, }; } } else { const counts = [_]usize{ 0, 1, 1, 1, 2, 3 }; const k = counts[random.uintLessThan(usize, counts.len)]; for (0..k) |_| { const i = random.uintLessThan(usize, paths.len); const x = random.uintLessThan(u8, 100); if (std.mem.eql(u8, paths[i], "sub")) { tree[i] = .{ .mode = "160000", .value = random.uintLessThan(u8, 3) }; } else if (x < 15) { tree[i] = null; } else if (x < 25 and tree[i] != null) { const old = tree[i].?; tree[i] = .{ .mode = if (std.mem.eql(u8, old.mode, "100644")) "100755" else "100644", .value = old.value }; } else { tree[i] = .{ .mode = "100644", .value = random.uintLessThan(u8, 4) }; } } } trees[mark] = tree; try w.print("commit refs/heads/{s}\nmark :{d}\n", .{ branches[b], mark }); try w.print("author A <a@example.com> {d} +0000\ncommitter C <c@example.com> {d} +0000\n", .{ date, date }); try w.print("data {d}\nc{d} {s}\n", .{ std.fmt.count("c{d} {s}\n", .{ mark, branches[b] }), mark, branches[b] }); if (np != 0) { try w.print("from :{d}\n", .{parents[0]}); for (parents[1..np]) |p| try w.print("merge :{d}\n", .{p}); } try w.writeAll("deleteall\n"); for (tree, 0..) |slot, i| { const f = slot orelse continue; if (std.mem.eql(u8, f.mode, "160000")) { try w.writeAll("M 160000 "); for (0..hex_len - 1) |_| try w.writeByte('0'); try w.print("{d} {s}\n", .{ f.value + 1, paths[i] }); } else { try w.print("M {s} inline {s}\ndata 2\n{d}\n", .{ f.mode, paths[i], f.value }); } } try w.writeAll("\n"); tips[b] = mark; if (mark % 17 == 0) try w.print("reset refs/tags/t{d}\nfrom :{d}\n\n", .{ mark, mark }); } const imported = try repo.runIn(".", &.{ "fast-import", "--quiet" }, stream.written()); defer imported.deinit(gpa); if (imported.code != 0) std.debug.print("{s}", .{imported.stderr}); try testing.expectEqual(0, imported.code); try repo.git(&.{ "symbolic-ref", "HEAD", "refs/heads/main" });}
const specs = [_][]const []const u8{ &.{"a"}, &.{"d"}, &.{"sub"}, &.{ "m", "b" }, &.{"d/e/f"}, &.{ "*.x", "deep" },};
const ranges = [_][]const []const u8{ &.{"main"}, &.{"b1..main"}, &.{"b1...main"}, &.{"--all"},};
const modes = [_][]const []const u8{ &.{}, &.{"--full-history"}, &.{"--sparse"}, &.{ "--full-history", "--sparse" }, &.{"--show-pulls"}, &.{"--remove-empty"}, &.{"--simplify-merges"}, &.{"--dense"},};
const outputs = [_][]const []const u8{ &.{}, &.{"--parents"}, &.{"--children"}, &.{"--boundary"}, &.{ "--boundary", "--parents" }, &.{ "--topo-order", "--parents" }, &.{"--date-order"}, &.{ "-n", "5", "--parents" }, &.{ "--skip=2", "--parents" }, &.{ "--reverse", "--parents" }, &.{"--first-parent"}, &.{ "--left-right", "--count" }, &.{ "--max-parents=1", "--parents" }, &.{ "--timestamp", "--left-right" }, &.{ "--topo-order", "--first-parent", "--parents" }, &.{ "--date-order", "--first-parent", "--boundary" },};
/// Whether `zig-git` refuses the combination, as the module refuses it.fn isRefused(mode: []const []const u8, output: []const []const u8, range: []const []const u8, graph: bool, bloom: bool) bool { const has = struct { fn f(list: []const []const u8, word: []const u8) bool { for (list) |x| if (std.mem.eql(u8, x, word)) return true; return false; } }.f; const excluding = range[0][0] == 'b'; const ordered = has(output, "--topo-order") or has(output, "--date-order"); const parents = has(output, "--parents"); if (has(mode, "--simplify-merges")) { if (has(output, "--children")) return false; if (excluding or has(output, "--first-parent") or has(output, "--boundary") or has(output, "--max-parents=1")) return true; if (graph) return true; } // Walked by generation number, nothing below but the filters with // --remove-empty is refused. const by_generation = graph and ordered and !has(output, "--children") and !has(mode, "--simplify-merges"); if (!by_generation) { if (has(output, "--first-parent") and (parents or has(output, "--children") or has(output, "--boundary") or ordered or has(mode, "--remove-empty"))) return true; if (has(mode, "--remove-empty") and (has(output, "--boundary") or (has(output, "--reverse") and parents))) return true; } const limited = excluding or ordered or has(output, "--children"); if (has(mode, "--full-history") and !has(mode, "--sparse") and parents and !limited and (has(output, "--reverse") or has(output, "--skip=2"))) return true; if (graph and ordered and excluding and !by_generation) return true; if (bloom and has(mode, "--remove-empty")) return true; return false;}
/// Every mode and output with every pathspec and range, a part of them/// chosen by `part` so that each test stays quick.fn walkEverything(repo: *Repo, graph: bool, bloom: bool, part: usize, parts: usize) !void { const gpa = repo.gpa; var args: std.ArrayList([]const u8) = .empty; defer args.deinit(gpa); var n: usize = 0; for (specs) |spec| for (ranges) |range| for (modes) |mode| for (outputs) |output| { n += 1; if (n % parts != part) continue; args.clearRetainingCapacity(); try args.append(gpa, "rev-list"); try args.appendSlice(gpa, mode); try args.appendSlice(gpa, output); try args.appendSlice(gpa, range); try args.append(gpa, "--"); try args.appendSlice(gpa, spec); // The filters are asked only for pathspecs without wildcards. const literal = for (spec) |x| { if (std.mem.findAny(u8, x, "*?[") != null) break false; } else true; if (isRefused(mode, output, range, graph, bloom and literal)) { try refused(repo, args.items); continue; } try same(repo, args.items); };}
test "rev-list limited to paths simplifies history as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 1, 120); try walkEverything(&repo, false, false, 0, 2); var repo2 = try Repo.init(testing.allocator, testing.io); defer repo2.deinit(); try makeHistory(&repo2, 6, 150); try walkEverything(&repo2, false, false, 1, 2);}
test "rev-list limited to paths reads through a commit-graph and its Bloom filters as git does" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 2, 120); try repo.git(&.{ "commit-graph", "write", "--reachable", "--changed-paths" }); try walkEverything(&repo, true, true, 0, 3); // Filters of another version than the one asked for are not read. try repo.git(&.{ "config", "commitGraph.changedPathsVersion", "2" }); try walkEverything(&repo, true, false, 1, 3); try repo.git(&.{ "config", "--unset", "commitGraph.changedPathsVersion" }); try repo.git(&.{ "config", "commitGraph.readChangedPaths", "false" }); try walkEverything(&repo, true, false, 2, 3);}
test "rev-list limited to paths reads a split chain whose layers differ in having filters" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 3, 100); try repo.git(&.{ "commit-graph", "write", "--reachable", "--split" }); try repo.git(&.{ "checkout", "-q", "-f", "main" }); try repo.tmp.dir.createDirPath(repo.io, "d"); try repo.commitFile("d/x", "on top\n", "on top"); try repo.commitFile("a", "on top\n", "on top 2"); try repo.git(&.{ "commit-graph", "write", "--reachable", "--split=no-merge", "--changed-paths" }); try walkEverything(&repo, true, true, 0, 4);}
test "rev-list reads pathspecs as git does, from any directory" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 5, 60); try repo.git(&.{ "checkout", "-q", "-f", "main" }); // Make d/ exist whatever main holds, for running from inside it. try repo.tmp.dir.createDirPath(repo.io, "d/e"); for ([_][]const []const u8{ &.{ "rev-list", "main", "--", ":(glob)**/f", "d/x" }, &.{ "rev-list", "main", "--", ":!d", "deep" }, &.{ "rev-list", "main", "--", ":(exclude)a" }, &.{ "rev-list", "main", "--", ":(icase)D" }, &.{ "rev-list", "main", "--", ":(top)a", ":/b" }, &.{ "rev-list", "main", "--", "d/" }, &.{ "rev-list", "main", "--", "sub/" }, &.{ "rev-list", "main", "--", "su*" }, &.{ "rev-list", "main", "--", ":(literal)su*" }, &.{ "rev-list", "main", "--", "" }, &.{ "rev-list", "main", "--", ":(foo)a" }, &.{ "rev-list", "main", "--", "../x" }, &.{ "rev-list", "main", "*.c" }, &.{ "rev-list", "main", "nothing" }, &.{ "rev-list", "main", "--", "nothing" }, &.{ "rev-list", "--parents", "main", "--", "--parents" }, &.{ "rev-list", "--parents", "--children", "main", "--", "a" }, &.{ "rev-list", "--simplify-merges", "--children", "main", "--", "a" }, }) |a| try same(&repo, a); for ([_][]const []const u8{ &.{ "rev-list", "main", "--", "x" }, &.{ "rev-list", "main", "--", ":/a" }, &.{ "rev-list", "main", "--", ".." }, &.{ "rev-list", "main", "--", "../a", ":!f" }, &.{ "rev-list", "main", "--", "../../x" }, &.{ "rev-list", "main", "e" }, }) |a| try sameIn(&repo, "d", a); // A path that is there needs no `--`, and then neither revisions nor // options may follow it. try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "here", .data = "x\n" }); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = "main", .data = "x\n" }); for ([_][]const []const u8{ &.{ "rev-list", "HEAD", "here" }, &.{ "rev-list", "HEAD", "here", "nothing" }, &.{ "rev-list", "HEAD", "here", "HEAD~1" }, &.{ "rev-list", "HEAD", "here", "--parents" }, &.{ "rev-list", "main" }, &.{ "rev-list", "HEAD", "main" }, &.{ "rev-list", "main", "--", "a" }, }) |a| try same(&repo, a); // Paths alone are no revisions: git prints its usage. try tool.expectSameAsGitHow(&repo, &.{ "rev-list", "here" }, .{ .compare = .output }); const Case = struct { name: []const u8, value: []const u8, args: []const []const u8 }; for ([_]Case{ .{ .name = "GIT_LITERAL_PATHSPECS", .value = "1", .args = &.{ "rev-list", "main", "--", "*", "a" } }, .{ .name = "GIT_GLOB_PATHSPECS", .value = "1", .args = &.{ "rev-list", "main", "--", "*" } }, .{ .name = "GIT_NOGLOB_PATHSPECS", .value = "1", .args = &.{ "rev-list", "main", "--", "d*" } }, .{ .name = "GIT_ICASE_PATHSPECS", .value = "1", .args = &.{ "rev-list", "main", "--", "A" } }, .{ .name = "GIT_GLOB_PATHSPECS", .value = "bogus", .args = &.{ "rev-list", "main", "--", "a" } }, .{ .name = "GIT_GLOB_PATHSPECS", .value = "bogus", .args = &.{ "rev-list", "main" } }, }) |case| { try repo.env.put(case.name, case.value); defer _ = repo.env.orderedRemove(case.name); try same(&repo, case.args); }}
test "rev-list refuses by name what it does not do with paths" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 7, 40); for ([_][]const []const u8{ &.{ "rev-list", "--simplify-by-decoration", "main", "--", "a" }, &.{ "rev-list", "--follow", "main", "--", "a" }, &.{ "rev-list", "--ancestry-path", "b1..main", "--", "a" }, &.{ "rev-list", "--simplify-merges", "b1..main", "--", "a" }, &.{ "rev-list", "--first-parent", "--parents", "main", "--", "a" }, &.{ "rev-list", "main", "--", ":(attr:x)a" }, }) |a| try refused(&repo, a);}
test "the walk asks the Bloom filters, and answers the same without them" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try makeHistory(&repo, 8, 80); try repo.git(&.{ "commit-graph", "write", "--reachable", "--changed-paths" }); var objects = try repo.tmp.dir.openDir(repo.io, ".git/objects", .{}); defer objects.close(repo.io); var db = try git.DiskDatabase.open(gpa, repo.io, .{ .objects_path = ".git/objects", .base = repo.tmp.dir, .format = repo.object_format }, null); defer db.close(repo.io); var graph = try git.CommitGraph.open(gpa, repo.io, repo.object_format, &.{.{ .dir = objects, .path = ".git/objects" }}, .{}); defer graph.close(repo.io); const out = try repo.output(&.{ "rev-parse", "main" }); defer gpa.free(out); const tip = try git.ObjectId.parseHex(repo.object_format, std.mem.trimEnd(u8, out, "\n")); var ps = try git.Pathspec.parse(gpa, &.{"d/e/f"}, .{}, null); defer ps.deinit(gpa); var lists: [2]std.ArrayList(git.Commits.Index) = .{ .empty, .empty }; defer for (&lists) |*l| l.deinit(gpa); var stats: [2]git.RevWalk.BloomStatistics = undefined; for ([_]bool{ true, false }, 0..) |use, k| { var store = git.Commits.init(gpa, repo.io, db.database(), &graph, repo.object_format, .{}); defer store.deinit(); var w = git.RevWalk.init(gpa, &store, .{ .simplify = .{ .pathspec = &ps, .bloom = use } }); defer w.deinit(); try w.addStart(.{ .commit = try store.name(tip) }); while (try w.next(null)) |e| try lists[k].append(gpa, e.commit); stats[k] = w.bloom_statistics; } try testing.expectEqualSlices(git.Commits.Index, lists[1].items, lists[0].items); try testing.expect(stats[0].definitely_not > 0); try testing.expectEqual(0, stats[1].definitely_not + stats[1].maybe); // git's own count of what its filters said, from trace2. const trace_path = try std.fs.path.join(gpa, &.{ repo.path, "trace" }); defer gpa.free(trace_path); try repo.env.put("GIT_TRACE2_PERF", trace_path); try repo.git(&.{ "rev-list", "main", "--", "d/e/f" }); _ = repo.env.orderedRemove("GIT_TRACE2_PERF"); const trace = try repo.tmp.dir.readFileAlloc(repo.io, "trace", gpa, .unlimited); defer gpa.free(trace); const want = try std.fmt.allocPrint(gpa, "\"maybe\":{d},\"definitely_not\":{d},\"false_positive\":{d}", .{ stats[0].maybe, stats[0].definitely_not, stats[0].false_positive }); defer gpa.free(want); if (std.mem.find(u8, trace, want) == null) { const at = std.mem.find(u8, trace, "\"maybe\"") orelse 0; std.debug.print("want {s} in git's trace: {s}\n", .{ want, trace[at..@min(trace.len, at + 80)] }); return error.TestUnexpectedResult; }}
test "a corrupt or missing changed-path filter falls back to comparing trees" { var repo = try Repo.init(testing.allocator, testing.io); defer repo.deinit(); try makeHistory(&repo, 9, 60); try repo.git(&.{ "commit-graph", "write", "--reachable", "--changed-paths" }); // Every filter emptied: an empty filter says nothing, as git reads it. const graph_path = ".git/objects/info/commit-graph"; const bytes = try repo.tmp.dir.readFileAlloc(repo.io, graph_path, repo.gpa, .unlimited); defer repo.gpa.free(bytes); // The chunk table follows the eight-byte header, twelve bytes an // entry. const table = bytes[8..][0 .. 12 * (@as(usize, bytes[6]) + 1)]; const chunk = struct { fn at(t: []const u8, id: []const u8) ?u64 { var i: usize = 0; while (i + 12 <= t.len) : (i += 12) { if (std.mem.eql(u8, t[i..][0..4], id)) return std.mem.readInt(u64, t[i + 4 ..][0..8], .big); } return null; } }; const offset = chunk.at(table, "BIDX") orelse return error.SkipZigTest; const fanout_at = chunk.at(table, "OIDF") orelse return error.SkipZigTest; const n = std.mem.readInt(u32, bytes[@intCast(fanout_at + 4 * 255)..][0..4], .big); const changed = try repo.gpa.dupe(u8, bytes); defer repo.gpa.free(changed); @memset(changed[@intCast(offset)..][0 .. 4 * n], 0); try repo.tmp.dir.deleteFile(repo.io, graph_path); try repo.tmp.dir.writeFile(repo.io, .{ .sub_path = graph_path, .data = changed }); for ([_][]const []const u8{ &.{ "rev-list", "main", "--", "a" }, &.{ "rev-list", "--full-history", "--parents", "main", "--", "d" }, }) |a| try same(&repo, a);}