// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Revisions on the command line, compared with git: every form //! gitrevisions(7) names a single object by, asked of `rev-parse` (with //! and without `--verify`, quietly, and for the full ref name) and of //! `cat-file`, in repositories built to reach each rule and each //! failure. 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 sameIn(repo: *Repo, cwd: []const u8, args: []const []const u8) !void { try tool.expectSameAsGitHow(repo, args, .{ .cwd = cwd }); } /// Every way of asking about one revision. fn sameEveryWay(repo: *Repo, cwd: []const u8, name: []const u8) !void { errdefer std.debug.print("revision: {s}\n", .{name}); for ([_][]const []const u8{ &.{ "rev-parse", name }, &.{ "rev-parse", "--verify", name }, &.{ "rev-parse", "-q", "--verify", name }, &.{ "rev-parse", "--symbolic-full-name", name }, &.{ "cat-file", "-t", name }, }) |args| { // cat-file takes a name beginning with a dash for an option. if (std.mem.eql(u8, args[0], "cat-file") and name.len > 0 and name[0] == '-') continue; try sameIn(repo, cwd, args); } } fn sameEveryWayAll(repo: *Repo, cwd: []const u8, names: []const []const u8) !void { for (names) |n| try sameEveryWay(repo, cwd, n); } /// `rev-parse` prints a set of commits as the revisions it stands for, /// in every way of printing them. fn sameAsSet(repo: *Repo, name: []const u8) !void { errdefer std.debug.print("set: {s}\n", .{name}); for ([_][]const []const u8{ &.{ "rev-parse", name }, &.{ "rev-parse", "--symbolic", name }, &.{ "rev-parse", "--symbolic-full-name", name }, &.{ "rev-parse", "--abbrev-ref", name }, &.{ "rev-parse", "--short", name }, &.{ "rev-parse", "--verify", name }, &.{ "rev-parse", "-q", "--verify", name }, }) |args| try same(repo, args); } fn writeFile(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 line(repo: *Repo, args: []const []const u8) ![]u8 { const out = try repo.output(args); const len = std.mem.trimEnd(u8, out, "\n").len; return repo.gpa.realloc(out, len); } /// A history of `n` commits on five branches with merges and octopus /// merges, lightweight and annotated tags, and clocks that run backwards /// and stand still, made with `git fast-import`. fn makeTangle(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; const branches = [_][]const u8{ "main", "b1", "b2", "b3", "b4" }; 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 => date -= 500, else => date += 1 + random.uintLessThan(u64, 100), } 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", .{ date, date }); const message = try std.fmt.allocPrint(gpa, "commit {d:0>4} on {s}\n\nbody {d}\n", .{ mark, branches[b], mark % 7 }); 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, 4) == 0) { const others = 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 % 17 == 0) { try w.print("tag t{d}\nfrom :{d}\ntagger T {d} +0000\ndata 4\ntag\n\n", .{ mark, mark, date }); } else if (mark % 13 == 0) { try w.print("reset refs/tags/l{d}\nfrom :{d}\n\n", .{ mark, 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" }); } test "revisions: suffixes, tags and searches in a long history with merges" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try makeTangle(&repo, 7, 300); var names: std.ArrayList([]u8) = .empty; defer { for (names.items) |n| gpa.free(n); names.deinit(gpa); } for ([_][]const u8{ "main", "b1", "b2", "b3", "b4" }) |b| { for ([_][]const u8{ "", "~", "^", "^2", "^3", "^4", "~5", "~40", "~400", "^^2~3", "^{tree}", "^{commit}", "^0", "^{/body 3}", "^{/!-body}", "^{/commit 00[0-4]}", "^{/^commit 0001}", ":f3", "~2:f1" }) |s| { try names.append(gpa, try std.mem.concat(gpa, u8, &.{ b, s })); } } for ([_][]const u8{ "t17", "t34", "l13", "l26", "t17^{}", "t17^{tag}", "t17^{commit}", "t17~3", "t17^0", "t17:f3", "t17^{tree}", "l13^{tag}", ":/commit 01", ":/body 6", ":/!-on main", ":/on b[23]$" }) |n| { try names.append(gpa, try gpa.dupe(u8, n)); } for (names.items) |n| { errdefer std.debug.print("revision: {s}\n", .{n}); try same(&repo, &.{ "rev-parse", n }); try same(&repo, &.{ "cat-file", "-t", n }); } // Describe output names its abbreviation. const short = try line(&repo, &.{ "rev-parse", "--short", "b2~3" }); defer gpa.free(short); for ([_][]const u8{ "v1-5-g", "x-0-g", "-1-g", "x-g", "a-b-1x-g", "x--2-g" }) |before| { const n = try std.mem.concat(gpa, u8, &.{ before, short }); defer gpa.free(n); try sameEveryWay(&repo, ".", n); } // A long run of suffixes. try same(&repo, &.{ "rev-parse", "main~1~1~1^1^1~2^{commit}^{}" }); } test "revisions: octopus merges, tags of every kind, and peeling" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try writeFile(&repo, "README", "one\n"); try writeFile(&repo, "src/main.c", "int main;\n"); try repo.git(&.{ "add", "." }); try repo.commit("first"); try repo.git(&.{ "tag", "-a", "-m", "version one", "v1" }); try repo.git(&.{ "tag", "-a", "-m", "again", "v1-again", "v1" }); try repo.git(&.{ "tag", "-a", "-m", "and again", "v1-thrice", "v1-again" }); try repo.git(&.{ "tag", "light" }); try repo.commitFile("README", "two\n", "second"); for ([_][]const u8{ "side", "third", "fourth" }) |b| try repo.git(&.{ "branch", b }); for ([_][]const u8{ "side", "third", "fourth" }) |b| { try repo.git(&.{ "checkout", "-q", b }); const file = try std.mem.concat(gpa, u8, &.{ b, ".txt" }); defer gpa.free(file); try repo.commitFile(file, "x\n", b); } try repo.git(&.{ "checkout", "-q", "main" }); try repo.commitFile("README", "three\n", "main work"); try repo.git(&.{ "merge", "-q", "--no-edit", "-m", "octopus", "side", "third", "fourth" }); try repo.git(&.{ "tag", "-a", "-m", "a tree", "treetag", "HEAD^{tree}" }); try repo.git(&.{ "tag", "-a", "-m", "a blob", "blobtag", "HEAD:README" }); try repo.git(&.{ "tag", "lightblob", "HEAD:README" }); try repo.git(&.{ "tag", "-a", "-m", "tag of a tree tag", "treetag2", "treetag" }); try repo.commitFile("README", "four\n", "last"); try sameEveryWayAll(&repo, ".", &.{ "HEAD", "HEAD~", "HEAD^", "HEAD~1^1", "HEAD~1^2", "HEAD~1^3", "HEAD~1^4", "HEAD~1^5", "HEAD^0", "HEAD~0", "HEAD^^", "HEAD~1^4~1", "HEAD~99", "HEAD^{tree}", "HEAD^{blob}", "HEAD^{tag}", "HEAD^{object}", "HEAD^{}", "HEAD^{nonsense}", "HEAD^{tree}^{commit}", "HEAD^{tree}~1", "HEAD^{tree}^", "HEAD^{tree}^0", "v1", "v1^{}", "v1^{commit}", "v1^{tag}", "v1^{object}", "v1^{tree}", "v1~0", "v1^", "v1-again^{}", "v1-again^{tag}", "v1-thrice^{commit}", "v1-thrice~0", "light", "light^{tag}", "treetag", "treetag^{tree}", "treetag^{commit}", "treetag~1", "treetag^{}", "treetag2^{tree}", "treetag2^{blob}", "treetag2^0", "blobtag", "blobtag^{blob}", "blobtag^{tree}", "blobtag~1", "blobtag:x", "lightblob^{blob}", "lightblob^{}", "lightblob~1", "v1^{tree}x}", "HEAD:README", "HEAD:src", "HEAD:src/main.c", "v1:README", "treetag:src", "HEAD^{tree}:src/", "refs/tags/v1", "tags/v1", "refs/heads/main", "heads/side", "side~1", "fourth^{/side}", }); // Sets of commits are printed as what they stand for. for ([_][]const u8{ "main..side", "side..", "..side", "main...side", "^side", "HEAD~1^@", "HEAD~1^!", "HEAD~1^-", "HEAD~1^-3", "HEAD:..", ".." }) |n| try sameAsSet(&repo, n); // ...unless a side does not resolve, when git reads them as revisions. try sameEveryWayAll(&repo, ".", &.{ "a/../b", "nothere..main", "main..nothere", "^nothere", "nothere^@", "HEAD~1^-9x" }); } test "revisions: ref names, ambiguous and odd" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commitFile("f", "1\n", "first"); try repo.commitFile("f", "2\n", "second"); try repo.commitFile("f", "3\n", "third"); const head = try line(&repo, &.{ "rev-parse", "HEAD" }); defer gpa.free(head); const parent = try line(&repo, &.{ "rev-parse", "HEAD~1" }); defer gpa.free(parent); // The same name as a tag and a branch, and under refs/ and remotes. try repo.git(&.{ "tag", "dup", "HEAD~2" }); try repo.git(&.{ "branch", "dup", "HEAD~1" }); try repo.git(&.{ "update-ref", "refs/dup2", "HEAD" }); try repo.git(&.{ "update-ref", "refs/remotes/dup2", "HEAD~1" }); try repo.git(&.{ "update-ref", "refs/remotes/origin/HEAD", "HEAD~1" }); try repo.git(&.{ "update-ref", "refs/remotes/origin/main", "HEAD~2" }); try repo.git(&.{ "symbolic-ref", "refs/heads/sym", "refs/heads/dup" }); try repo.git(&.{ "symbolic-ref", "refs/heads/dangling", "refs/heads/nothing" }); try repo.git(&.{ "update-ref", "refs/heads/heads/x", "HEAD" }); try writeFile(&repo, ".git/refs/heads/broken", "junk\n"); try writeFile(&repo, ".git/FOO_HEAD", "junk\n"); try repo.git(&.{ "update-ref", "BAR_HEAD", "HEAD~1" }); // Hexadecimal names that are refs: short, an object's too, and whole. try repo.git(&.{ "update-ref", "refs/heads/abcd", "HEAD" }); const short_ref = try std.mem.concat(gpa, u8, &.{ "refs/heads/", parent[0..7] }); defer gpa.free(short_ref); try repo.git(&.{ "update-ref", short_ref, "HEAD" }); const whole_ref = try std.mem.concat(gpa, u8, &.{ "refs/tags/", parent }); defer gpa.free(whole_ref); try repo.git(&.{ "update-ref", whole_ref, "HEAD" }); const names = [_][]const u8{ "dup", "dup~1", "dup2", "origin", "origin/main", "sym", "dangling", "broken", "refs/heads/broken", "heads/x", "refs/heads/heads/x", "FOO_HEAD", "BAR_HEAD", "HEAD", "@", "@~1", "@^{tree}", "abcd", parent[0..7], parent, head, "logs/HEAD", "config", "nothere", "x@", "@@{0}", "refs", "main", "refs/heads/main", "heads/main", }; try sameEveryWayAll(&repo, ".", &names); for ([_][]const u8{ "--symbolic", "--abbrev-ref", "--abbrev-ref=loose", "--abbrev-ref=strict", "--abbrev-ref=bogus" }) |o| { try same(&repo, &.{ "rev-parse", o, "dup", "sym", "origin", "HEAD", "heads/x", "refs/heads/heads/x", "main~1", parent[0..7] }); } for ([_][]const u8{ "core.warnAmbiguousRefs=false", "core.warnAmbiguousRefs=bogus", "advice.objectNameWarning=false" }) |setting| { for ([_][]const u8{ "dup", parent[0..7], parent, "broken" }) |n| { try same(&repo, &.{ "-c", setting, "rev-parse", n }); try same(&repo, &.{ "-c", setting, "rev-parse", "--symbolic-full-name", n }); } } try same(&repo, &.{ "-c", "core.warnAmbiguousRefs=false", "rev-parse", "--abbrev-ref", "dup", "heads/dup" }); } /// Writes `.git/logs/` with entries `old new time zone message` /// given by index into `ids`, 0 for all zeros. fn writeReflog(repo: *Repo, ref: []const u8, ids: []const []const u8, entries: []const struct { u8, u8, i64, []const u8, []const u8 }) !void { const gpa = repo.gpa; var out: std.ArrayList(u8) = .empty; defer out.deinit(gpa); const zeros = "0000000000000000000000000000000000000000000000000000000000000000"; const n = repo.object_format.hexLength(); for (entries) |e| { const old = if (e[0] == 0) zeros[0..n] else ids[e[0] - 1]; const new = if (e[1] == 0) zeros[0..n] else ids[e[1] - 1]; const l = try std.fmt.allocPrint(gpa, "{s} {s} C {d} {s}\t{s}\n", .{ old, new, e[2], e[3], e[4] }); defer gpa.free(l); try out.appendSlice(gpa, l); } const path = try std.mem.concat(gpa, u8, &.{ ".git/logs/", ref }); defer gpa.free(path); try writeFile(repo, path, out.items); } test "revisions: reflog entries by count and by date, and checkouts" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); for ([_][]const u8{ "1", "2", "3", "4" }) |c| try repo.commitFile("f", c, c); try repo.git(&.{ "branch", "topic", "HEAD~2" }); // A series of checkouts, one of them detached. for ([_][]const u8{ "topic", "main", "HEAD~1", "topic", "main" }) |b| try repo.git(&.{ "checkout", "-q", b }); var ids: [4][]u8 = undefined; for (&ids, 0..) |*id, i| { const rev = try std.fmt.allocPrint(gpa, "HEAD~{d}", .{3 - i}); defer gpa.free(rev); id.* = try line(&repo, &.{ "rev-parse", rev }); } defer for (ids) |id| gpa.free(id); const now_text = "1790000500"; try repo.env.put("GIT_TEST_DATE_NOW", now_text); var names: std.ArrayList([]u8) = .empty; defer { for (names.items) |n| gpa.free(n); names.deinit(gpa); } const selectors = [_][]const u8{ "0", "1", "2", "3", "4", "5", "9", "0001", "99999999", "100000000", "1790000120", "1790000119", "now", "yesterday", "1 minute ago", "3 minutes ago", "10 years ago", "2026-09-21 14:15", "2026-09-21 14:14:59", "2026-09-21 14:16:00", "2030-01-01", "garbage", "", "-1", "+1", " 1", "0x1", "18446744073709551616", }; for ([_][]const u8{ "main", "HEAD", "topic", "", "refs/heads/main", "heads/main", "@", "nothere", "@{-1}", "@{-2}" }) |ref| { for (selectors) |s| try names.append(gpa, try std.fmt.allocPrint(gpa, "{s}@{{{s}}}", .{ ref, s })); } for (1..8) |k| try names.append(gpa, try std.fmt.allocPrint(gpa, "@{{-{d}}}", .{k})); for ([_][]const u8{ "@{-0}", "main@{-1}", "@{-1}@{-1}", "@{-1}~1", "@{-3}^{tree}", "main@{1}@{1}", "HEAD~1@{1}", "@{1}~1", "@{1}:f" }) |n| try names.append(gpa, try gpa.dupe(u8, n)); for (names.items) |n| try sameEveryWay(&repo, ".", n); // Logs written by hand: gaps, an unexpected end, entries out of order, // a ref created where it is, and one that was not. const Entry = struct { u8, u8, i64, []const u8, []const u8 }; const logs = [_][]const Entry{ &.{ .{ 1, 2, 1790000100, "+0130", "a" }, .{ 3, 4, 1790000300, "-0700", "b" } }, &.{ .{ 0, 1, 1790000100, "+0000", "a" }, .{ 1, 2, 1790000200, "+0000", "b" } }, &.{.{ 3, 2, 1790000200, "+0000", "b" }}, &.{ .{ 1, 3, 1790000400, "+0000", "new first" }, .{ 3, 2, 1790000200, "+0000", "old second" } }, &.{ .{ 2, 2, 1790000100, "+0000", "same" }, .{ 2, 4, 1790000100, "+0000", "same time" } }, &.{}, }; const log_selectors = [_][]const u8{ "0", "1", "2", "3", "4", "2026-09-21 14:16:40", "2026-09-21 14:16:39", "2026-09-21 14:16:41", "2026-09-21 14:15", "2020-01-01", "2030-01-01", "now" }; for (logs) |entries| { try writeReflog(&repo, "refs/heads/topic", &ids, entries); for (log_selectors) |s| { const n = try std.fmt.allocPrint(gpa, "topic@{{{s}}}", .{s}); defer gpa.free(n); try sameEveryWay(&repo, ".", n); } } // No log at all. try repo.tmp.dir.deleteFile(repo.io, ".git/logs/refs/heads/topic"); for ([_][]const u8{ "topic@{0}", "topic@{1}", "topic@{now}" }) |n| try sameEveryWay(&repo, ".", n); } test "revisions: reflogs in a reftable" { var repo = try Repo.initWith(testing.allocator, testing.io, .{ .ref_format = "reftable" }); defer repo.deinit(); for ([_][]const u8{ "1", "2", "3" }) |c| try repo.commitFile("f", c, c); try repo.git(&.{ "checkout", "-q", "-b", "topic", "HEAD~1" }); try repo.git(&.{ "checkout", "-q", "main" }); try repo.env.put("GIT_TEST_DATE_NOW", "1790000500"); try sameEveryWayAll(&repo, ".", &.{ "main@{0}", "main@{1}", "main@{2}", "main@{3}", "main@{9}", "@{-1}", "@{-2}", "HEAD@{2}", "main@{1 minute ago}", "main@{2020-01-01}", "topic@{1}" }); } test "revisions: upstream and push branches in every workflow" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commitFile("f", "1\n", "first"); try repo.commitFile("f", "2\n", "second"); for ([_][]const u8{ "refs/remotes/origin/main", "refs/remotes/origin/topic", "refs/remotes/origin/other", "refs/remotes/fork/topic", "refs/remotes/fork/main", "refs/remotes/fork/pushed", "refs/remotes/origin/pushed" }) |r| { try repo.git(&.{ "update-ref", r, "HEAD~1" }); } try repo.git(&.{ "branch", "topic" }); try repo.git(&.{ "branch", "lonely" }); try repo.git(&.{ "tag", "v1" }); const settings = [_][2][]const u8{ .{ "remote.origin.url", "/x/origin" }, .{ "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*" }, .{ "remote.fork.url", "/x/fork" }, .{ "remote.fork.fetch", "+refs/heads/*:refs/remotes/fork/*" }, .{ "branch.topic.remote", "origin" }, .{ "branch.topic.merge", "refs/heads/other" }, .{ "branch.main.remote", "origin" }, .{ "branch.main.merge", "refs/heads/main" }, }; for (settings) |s| try repo.git(&.{ "config", s[0], s[1] }); try repo.git(&.{ "checkout", "-q", "topic" }); const asked = [_][]const u8{ "@{u}", "@{upstream}", "@{U}", "@{UPSTREAM}", "@{push}", "@{PUSH}", "topic@{u}", "topic@{push}", "main@{u}", "main@{push}", "lonely@{u}", "lonely@{push}", "nob@{u}", "nob@{push}", "HEAD@{u}", "@{u}~1", "@{u}^{tree}", "@{push}:f", "topic@{u}@{0}", "main@{u}@{u}", "main@{0}@{u}", "@{-1}@{u}", "@{ u}", "@{upstrea}", }; const Config = []const [2]?[]const u8; const configs = [_]Config{ &.{}, &.{.{ "push.default", "nothing" }}, &.{.{ "push.default", "current" }}, &.{.{ "push.default", "upstream" }}, &.{.{ "push.default", "tracking" }}, &.{.{ "push.default", "simple" }}, &.{.{ "push.default", "matching" }}, &.{.{ "push.default", "bogus" }}, // Triangular: pull from origin, push to the fork. &.{ .{ "remote.pushDefault", "fork" }, .{ "push.default", "current" } }, &.{ .{ "remote.pushDefault", "fork" }, .{ "push.default", "simple" } }, &.{ .{ "remote.pushDefault", "fork" }, .{ "push.default", "upstream" } }, &.{ .{ "branch.topic.pushRemote", "fork" }, .{ "push.default", "current" } }, &.{ .{ "branch.topic.pushRemote", "/x/fork" }, .{ "push.default", "current" } }, &.{ .{ "branch.topic.pushRemote", "/some/url" }, .{ "push.default", "current" } }, &.{ .{ "branch.topic.merge", "refs/heads/topic" }, .{ "push.default", "simple" } }, &.{.{ "remote.origin.push", "refs/heads/topic:refs/heads/pushed" }}, &.{.{ "remote.origin.push", "refs/heads/*:refs/heads/up/*" }}, &.{.{ "remote.origin.push", "refs/heads/main:refs/heads/x" }}, &.{.{ "remote.origin.push", "+refs/heads/topic:refs/for/topic" }}, &.{.{ "remote.origin.push", ":" }}, // Upstreams in this repository, and ones no refspec maps. &.{ .{ "branch.topic.remote", "." }, .{ "branch.topic.merge", "main" } }, &.{ .{ "branch.topic.remote", "." }, .{ "branch.topic.merge", "heads/main" } }, &.{ .{ "branch.topic.remote", "." }, .{ "branch.topic.merge", "nothere" } }, &.{ .{ "branch.topic.remote", "." }, .{ "branch.topic.merge", "v1" } }, &.{ .{ "branch.topic.remote", "." }, .{ "branch.topic.merge", "HEAD" } }, &.{.{ "branch.topic.merge", "refs/tags/v1" }}, &.{.{ "branch.topic.merge", "refs/heads/nothere" }}, &.{.{ "branch.topic.merge", null }}, &.{.{ "branch.topic.remote", null }}, &.{.{ "branch.topic.remote", "nosuch" }}, &.{.{ "remote.origin.fetch", "refs/heads/main:refs/remotes/origin/main" }}, &.{.{ "remote.origin.fetch", "refs/heads/*:refs/remotes/origin/x" }}, &.{.{ "remote.fork.fetch", "refs/heads/**:refs/remotes/fork/**" }}, }; for (configs) |config| { for (config) |s| { if (s[1]) |v| try repo.git(&.{ "config", "--replace-all", s[0].?, v }) else try repo.git(&.{ "config", "--unset-all", s[0].? }); } errdefer for (config) |s| std.debug.print("config: {s}={?s}\n", .{ s[0].?, s[1] }); for (asked) |n| try sameEveryWay(&repo, ".", n); // Put it back. for (config) |s| { const original = for (settings) |o| { if (std.mem.eql(u8, o[0], s[0].?)) break o[1]; } else null; if (original) |v| try repo.git(&.{ "config", "--replace-all", s[0].?, v }) else try repo.git(&.{ "config", "--unset-all", s[0].? }); } } // A detached HEAD has no upstream, and @{-1} is a commit. try repo.git(&.{ "checkout", "-q", "--detach" }); try sameEveryWayAll(&repo, ".", &.{ "@{u}", "@{push}", "@{1}", "@{-1}", "@{-1}@{u}" }); // Remotes in the files git read before the configuration are refused. try writeFile(&repo, ".git/remotes/origin", "URL: /x/elsewhere\nPull: refs/heads/main:refs/heads/origin\n"); const ours = try tool.runOurs(&repo, &.{ "rev-parse", "main@{u}" }, .{}); defer ours.deinit(gpa); try testing.expectEqual(128, ours.code); try testing.expect(std.mem.find(u8, ours.stderr, "remotes/") != null); } test "revisions: paths in trees and the index, from anywhere" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try writeFile(&repo, "a/b/f", "x\n"); try writeFile(&repo, "a/g", "y\n"); try writeFile(&repo, "sp ace/h i", "z\n"); try writeFile(&repo, "caf\xc3\xa9", "u\n"); try writeFile(&repo, "top", "top\n"); try writeFile(&repo, "dir with ..", "dots\n"); try writeFile(&repo, "conflict", "base\n"); try repo.git(&.{ "add", "." }); try repo.commit("one"); // A conflict, for the index's stages. try repo.git(&.{ "checkout", "-q", "-b", "fight" }); try repo.commitFile("conflict", "theirs\n", "fight"); try repo.git(&.{ "checkout", "-q", "main" }); try repo.commitFile("conflict", "ours\n", "peace"); if (repo.output(&.{ "merge", "-q", "fight" })) |out| { gpa.free(out); return error.MergeDidNotConflict; } else |_| {} try writeFile(&repo, "a/newfile", "new\n"); const paths = [_][]const u8{ "HEAD:", "HEAD:a", "HEAD:a/", "HEAD:a//b", "HEAD:./g", "HEAD:./", "HEAD:.", "HEAD:../top", "HEAD:../", "HEAD:../..", "HEAD:../../x", "HEAD:./b/../g", "HEAD:b/../g", "HEAD:/a", "HEAD:a/./b", "HEAD:nothere", "HEAD:g", "HEAD:b/f", "HEAD:../caf\xc3\xa9", "HEAD:caf\xc3\xa9", "HEAD:../sp ace/h i", "HEAD:sp ace/h i", "HEAD:dir with ..", "HEAD:../dir with ..", ":g", ":./g", ":a/g", ":0:a/g", ":1:a/g", ":4:a/g", ":./b/f", ":../top", ":nothere", ":0:nothere", ":", ":0:", "HEAD^{tree}:a", "HEAD:a/b/f:x", "HEAD:a:b", "HEAD:./g/x", "HEAD:a/g/", "HEAD:a/b/", "HEAD:./newfile", ":./newfile", "HEAD:a/newfile", "HEAD~5:x", "nothere:x", "HEAD:a^{commit}", ":conflict", ":0:conflict", ":1:conflict", ":2:conflict", ":3:conflict", ":../conflict", ":1:../conflict", ":2:nothere", "fight:conflict", "HEAD^{/fight}:conflict", ":0:../conflict", ":./nothere", ":0:./nothere", ":4:../conflict", }; for ([_][]const u8{ "a", ".", "a/b", "sp ace" }) |cwd| { for (paths) |p| try sameEveryWay(&repo, cwd, p); } // Revisions and paths on one command line, and the paths after them. for ([_][]const []const u8{ &.{ "rev-parse", "HEAD", "g", "b" }, &.{ "rev-parse", "g", "HEAD" }, &.{ "rev-parse", "HEAD", "--", "g", "nothere" }, &.{ "rev-parse", "HEAD:g", "g" }, &.{ "rev-parse", "*.c" }, &.{ "rev-parse", ":/" }, &.{ "rev-parse", ":/top" }, &.{ "rev-parse", ":/nothere" }, &.{ "rev-parse", ":(top)nothere" }, &.{ "rev-parse", ":!nothere" }, &.{ "rev-parse", "HEAD^{/[}" }, &.{ "rev-parse", "../top", "HEAD" }, }) |args| try sameIn(&repo, "a", args); // A bare repository has no working tree to be relative to. try repo.git(&.{ "clone", "-q", "--bare", ".", "bare.git" }); for ([_][]const u8{ "HEAD:a/g", "HEAD:./a", ":./a", ":a/g", "HEAD:nothere" }) |p| try sameEveryWay(&repo, "bare.git", p); } test "revisions: message searches" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try repo.commit("base"); try repo.commit("alpha one\nsecond line Beta\n\nwith a: colon and x}y"); try repo.git(&.{ "checkout", "-q", "-b", "side", "HEAD~1" }); try repo.commit("alpha side"); try repo.git(&.{ "checkout", "-q", "main" }); try repo.commit("alpha newer-main"); try repo.git(&.{ "tag", "-a", "-m", "alpha in a tag message", "tg", "HEAD~2" }); // Commits of one date, on branches of their own. for ([_][]const u8{ "b1", "b2", "b3", "a4", "b5" }, 0..) |b, i| { try repo.git(&.{ "checkout", "-q", "--orphan", b }); try repo.env.put("GIT_COMMITTER_DATE", "@1700000000 +0000"); try repo.env.put("GIT_AUTHOR_DATE", "@1700000000 +0000"); const m = try std.fmt.allocPrint(gpa, "tie {d}", .{i + 1}); defer gpa.free(m); try repo.git(&.{ "commit", "-q", "--allow-empty", "-m", m }); } try repo.git(&.{ "checkout", "-q", "main" }); const searches = [_][]const u8{ ":/alpha", ":/^second", ":/Beta$", ":/one.second", ":/one$", ":/beta", ":/committer", ":/^alpha one$", ":/[[:upper:]]eta", ":/a|zzz", ":/(alp)+ha", ":/a{2}", ":/x\\", ":/!-alpha", ":/!!x", ":/!x", ":/!-", ":/a:", ":/colon and x}y", ":/in a tag", ":/tie", ":/tie [^5]", ":/tie [^54]", ":/tie [123]", "main^{/side}", "side^{/newer}", "main^{/}", "main^{/!-alpha}", "main^{/!!x}", "main^{/!x}", "HEAD^{/a: colon}", "HEAD^{/x}y}", "HEAD^{/x}y}:", "tg^{/base}", ":/alpha~1", ":/a{2", }; try sameEveryWayAll(&repo, ".", &searches); // HEAD is a starting point of its own, put in last. try repo.git(&.{ "checkout", "-q", "b2" }); try sameEveryWayAll(&repo, ".", &.{ ":/tie", ":/tie [^2]" }); try repo.git(&.{ "checkout", "-q", "--detach", "b3" }); try sameEveryWayAll(&repo, ".", &.{":/tie"}); // GNU's extensions are refused by name. const ours = try tool.runOurs(&repo, &.{ "rev-parse", ":/\\w" }, .{}); defer ours.deinit(gpa); try testing.expectEqual(128, ours.code); try testing.expect(std.mem.find(u8, ours.stderr, "not supported") != null); } test "rev-parse: options, paths and listing refs" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try writeFile(&repo, "top", "x\n"); try writeFile(&repo, "main", "a file named like a branch\n"); try repo.git(&.{ "add", "." }); try repo.commit("one"); try repo.commit("two"); try repo.git(&.{ "branch", "b1" }); try repo.git(&.{ "branch", "feat/x" }); try repo.git(&.{ "tag", "v1" }); try repo.git(&.{ "tag", "-a", "-m", "m", "v2" }); try repo.git(&.{ "update-ref", "refs/remotes/origin/main", "HEAD" }); try repo.git(&.{ "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main" }); try repo.git(&.{ "update-ref", "refs/notes/n", "HEAD~1" }); const cases = [_][]const []const u8{ &.{ "rev-parse", "main" }, &.{ "rev-parse", "--verify", "main" }, &.{ "rev-parse", "top" }, &.{ "rev-parse", "top", "HEAD" }, &.{ "rev-parse", "HEAD", "top" }, &.{ "rev-parse", "HEAD", "top", "nothere" }, &.{ "rev-parse", "HEAD", "--", "top", "nothere" }, &.{ "rev-parse", "--", "HEAD" }, &.{ "rev-parse", "HEAD", "--" }, &.{ "rev-parse", "--foo", "HEAD", "-x" }, &.{ "rev-parse", "--default", "HEAD" }, &.{ "rev-parse", "--default", "HEAD", "top" }, &.{ "rev-parse", "--default", "nothere" }, &.{ "rev-parse", "--default", "HEAD~1", "--verify" }, &.{ "rev-parse", "--verify", "--default", "HEAD" }, &.{ "rev-parse", "--default" }, &.{ "rev-parse", "--default", "HEAD", "--", "top" }, &.{ "rev-parse", "top", "--default", "HEAD" }, &.{ "rev-parse", "--default", "HEAD~1", "--default", "HEAD", "v1" }, &.{ "rev-parse", "--end-of-options", "--foo" }, &.{ "rev-parse", "--end-of-options", "HEAD", "--verify" }, &.{ "rev-parse", "--verify", "HEAD", "HEAD" }, &.{ "rev-parse", "--verify", "HEAD", "--", "top" }, &.{ "rev-parse", "--verify", "--", "HEAD" }, &.{ "rev-parse", "--verify", "--foo", "HEAD" }, &.{ "rev-parse", "-q", "HEAD", "nothere" }, &.{ "rev-parse", "--verify", "--end-of-options", "-q" }, &.{ "rev-parse", "--short", "HEAD", "v2" }, &.{ "rev-parse", "--short=12", "v2^{}" }, &.{ "rev-parse", "--verify", "--symbolic", "v2" }, &.{ "rev-parse", "--verify", "--symbolic-full-name", "v2" }, &.{ "rev-parse", "--symbolic", "HEAD", "--symbolic-full-name", "HEAD", "--abbrev-ref", "HEAD" }, &.{ "rev-parse", "--exclude=refs/heads/b*", "--all" }, &.{ "rev-parse", "--exclude=b*", "--branches" }, &.{ "rev-parse", "--exclude=refs/heads/b*", "--branches", "--branches" }, &.{ "rev-parse", "--verify", "--all" }, &.{ "rev-parse", "--short", "--branches" }, &.{ "rev-parse", "--all", "HEAD", "--tags" }, }; for (cases) |args| try same(&repo, args); for ([_][]const u8{ "--all", "--branches", "--branches=feat", "--branches=f*", "--branches=nothere", "--tags", "--tags=v[2]", "--remotes", "--remotes=origin", "--glob=heads", "--glob=refs/heads/*", "--glob=notes" }) |a| { for ([_][]const u8{ "", "--symbolic", "--symbolic-full-name", "--abbrev-ref", "--abbrev-ref=loose" }) |mode| { if (mode.len == 0) try same(&repo, &.{ "rev-parse", a }) else try same(&repo, &.{ "rev-parse", mode, a }); } } } test "rev-parse: --revs-only, --not and -n" { const gpa = testing.allocator; var repo = try Repo.init(gpa, testing.io); defer repo.deinit(); try writeFile(&repo, "top", "x\n"); try repo.git(&.{ "add", "." }); try repo.commit("one"); try repo.git(&.{ "branch", "side" }); try repo.commit("two"); try repo.git(&.{ "tag", "-a", "-m", "m", "v1" }); try repo.commit("three"); // Every option git-rev-list(1) and git-log(1) name that --revs-only // was seen to keep, some it drops, and the revisions and paths // around them. const args = [_][]const []const u8{ &.{}, &.{"--all"}, &.{"--date-order"}, &.{"--topo-order"}, &.{"--first-parent"}, &.{ "-n", "3" }, &.{"-n3"}, &.{"--reverse"}, &.{"--no-merges"}, &.{ "--", "top" }, &.{"top"}, &.{"nothere"}, &.{"HEAD~1..HEAD"}, &.{"HEAD...side"}, &.{ "^side", "HEAD" }, &.{"--branches"}, &.{"--tags=v*"}, &.{"--max-count=2"}, &.{"--foo"}, &.{"-x"}, &.{"--glob=refs/heads/*"}, &.{ "--exclude=main", "--branches" }, &.{ "top", "nothere" }, &.{"HEAD:nothere"}, &.{ "--default", "HEAD" }, &.{ "--verify", "HEAD" }, &.{ "--symbolic", "HEAD" }, &.{ "--end-of-options", "HEAD" }, &.{ "--short", "HEAD" }, &.{"--parents"}, &.{"3"}, &.{"HEAD^@"}, &.{"HEAD^!"}, &.{"side^-1"}, &.{"--show-toplevel"}, &.{ "-q", "nothere" }, &.{"-3"}, &.{"-nx"}, &.{"--max-count=x"}, &.{"--topo-order=1"}, &.{ "-n", "-3" }, &.{ "-n", "--all" }, &.{ "--all", "-n" }, &.{"-n"}, &.{ "--", "--topo-order" }, &.{ "--end-of-options", "--topo-order" }, &.{ "--end-of-options", "-n", "3" }, &.{ "--verify", "--topo-order" }, &.{ "--verify", "-n", "3", "HEAD" }, &.{ "--objects", "HEAD" }, &.{"--pretty"}, &.{"--pretty=x"}, &.{ "nothere", "HEAD" }, &.{ "top", "HEAD" }, &.{ "HEAD", "top", "HEAD~1" }, &.{ "--", "HEAD" }, &.{ "--verify", "nothere" }, &.{ "--symbolic-full-name", "main" }, &.{ "HEAD", "--topo-order", "nothere", "--date-order" }, &.{"main^{tree}"}, &.{"v1^{}"}, &.{ "-n", "3", "--default", "main" }, &.{"nothere@{1}"}, &.{ "HEAD", "--revs-only", "--topo-order" }, &.{ "--dense", "--sparse", "--header", "--ignore-missing", "--objects-edge", "--unpacked", "--no-min-parents", "--no-max-parents" }, &.{ "--author-date-order", "--simplify-merges", "--boundary", "--left-right", "--min-parents=2", "--max-age=5" }, &.{ "--not", "HEAD" }, &.{ "--not", "^HEAD" }, &.{ "--not", "HEAD~1..HEAD" }, &.{ "--not", "--all" }, &.{ "--not", "--not", "HEAD" }, &.{ "HEAD", "--not", "side", "main" }, &.{ "--not", "--symbolic", "HEAD", "^side" }, &.{ "--not", "HEAD^!" }, &.{ "--not", "HEAD...side" }, &.{ "--not", "--branches" }, &.{ "--verify", "--not", "HEAD" }, &.{ "--verify", "--not", "^HEAD" }, &.{ "--not", "HEAD^@" }, &.{ "--not", "--", "top" }, &.{ "--not", "--default", "HEAD" }, &.{ "--not", "--symbolic-full-name", "main" }, &.{ "--not", "--short", "HEAD" }, &.{ "--not", "--abbrev-ref", "HEAD" }, &.{ "--not", "--symbolic", "--branches" }, }; var argv: std.ArrayList([]const u8) = .empty; defer argv.deinit(gpa); for (args) |a| for ([_]bool{ false, true }) |revs_only| { argv.clearRetainingCapacity(); try argv.append(gpa, "rev-parse"); if (revs_only) try argv.append(gpa, "--revs-only"); try argv.appendSlice(gpa, a); try same(&repo, argv.items); }; }