// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! History on `zig-git`'s command lines: the commit-graph opened as git's //! settings say and its complaints said in git's words, the commits read //! through it, the refs `--all`, `--branches`, `--tags`, `--remotes` and //! `--glob` choose, and the sets of commits gitrevisions(7) names — //! `A..B`, `A...B`, `^A`, `A^@`, `A^!`, `A^-` — taken apart into the //! commits they start from and exclude, for `rev-list` to walk and //! `rev-parse` to print. //! //! Replace refs, grafts and the shallow boundary are the library's: the //! commits are read through them, and the commit-graph left unread when //! any is there, as git leaves it. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const git = @import("git"); const ObjectId = git.ObjectId; const Diagnostics = git.Diagnostics; const tool = @import("zig_git.zig"); const Context = tool.Context; const revisions = @import("revisions.zig"); /// The commit-graph and the commits read through it. pub const History = struct { gpa: Allocator, io: Io, graph: *git.CommitGraph, store: git.Commits, graph_reported: bool = false, warnings_reported: usize = 0, /// The command run, which a refusal names. command: []const u8 = "rev-list", /// Whether changed-path Bloom filters are read, as /// `commitGraph.changedPathsVersion` and /// `commitGraph.readChangedPaths` say. changed_paths: bool = true, /// Opens `repo`'s commit-graph as `core.commitGraph`, /// `commitGraph.generationVersion` and /// `commitGraph.changedPathsVersion` say, and a store reading through /// it. pub fn open(ctx: Context, repo: *git.Repository, stderr: *Io.Writer) !History { try reportReplacements(repo, stderr); const grafts = try loadGrafts(ctx, repo, stderr); const gpa = ctx.gpa; const graph = try gpa.create(git.CommitGraph); errdefer gpa.destroy(graph); graph.* = .empty; const use = repo.boolSetting("core.commitGraph", null) catch true; // An unset changedPathsVersion follows readChangedPaths, an older // spelling of whether to read them at all. const read_changed = (repo.boolSetting("commitGraph.readChangedPaths", null) catch true) orelse true; const changed = (repo.config.getInt("commitGraph.changedPathsVersion") catch null) orelse @as(i64, if (read_changed) -1 else 0); if ((use orelse true) and repo.replacements.isEmpty() and grafts.commitGraphUsable()) { const generation = (repo.config.getInt("commitGraph.generationVersion") catch null) orelse 2; var sources: std.ArrayList(git.CommitGraph.Source) = .empty; defer sources.deinit(gpa); for (repo.database.directories) |d| try sources.append(gpa, .{ .dir = d.loose.dir, .path = d.path }); graph.* = try git.CommitGraph.open(gpa, ctx.io, repo.objectFormat(), sources.items, .{ .generation_version = generation, .changed_paths_version = changed, }); } const paranoid = if (ctx.env.get("GIT_COMMIT_GRAPH_PARANOIA")) |v| git.Config.value.parseBool(v) orelse false else false; return .{ .gpa = gpa, .io = ctx.io, .graph = graph, .store = .init(gpa, ctx.io, repo.objects(), graph, repo.objectFormat(), .{ .verify_graph = paranoid, .grafts = if (grafts.isEmpty()) null else grafts }), .changed_paths = changed != 0, }; } pub fn close(h: *History) void { h.store.deinit(); h.graph.close(h.io); h.gpa.destroy(h.graph); } /// Says what git said of the commit-graph when it read it, and of the /// commits since, once each. pub fn report(h: *History, stderr: *Io.Writer) !void { if (!h.graph_reported) for (h.graph.problems) |p| try stderr.writeAll(p.text); h.graph_reported = true; while (h.warnings_reported < h.store.warnings.items.len) : (h.warnings_reported += 1) try stderr.writeAll(h.store.warnings.items[h.warnings_reported]); } /// Says what git says when reading a commit failed, and fails. pub fn fail(h: *History, err: anyerror, diag: *const Diagnostics, child: ?git.Commits.Index, stderr: *Io.Writer) error{Fatal} { const id = diag.object_id; switch (err) { error.CommitNotFound => { if (id) |i| stderr.print("error: Could not read {f}\n", .{i}) catch {}; }, error.CommitGraphCommitMissing => { if (id) |i| stderr.print("error: commit {f} exists in commit-graph but not in the object database\n", .{i}) catch {}; }, error.NotACommit => { if (id) |i| stderr.print("error: object {f} is a {s}, not a commit\n", .{ i, diag.detail() }) catch {}; }, error.ReplaceDepthTooHigh => return tool.fatalMessage(stderr, "replace depth too high for object {f}", .{id.?}), error.ReplacementNotFound => return tool.fatalMessage(stderr, "replacement {s} not found for {f}", .{ diag.detail(), id.? }), error.InvalidParentPosition => return tool.fatalMessage(stderr, "invalid parent position {s}", .{diag.detail()}), error.MissingGenerationOverflow => return tool.fatalMessage(stderr, "commit-graph requires overflow generation data but has none", .{}), error.GenerationOverflowTooSmall => return tool.fatalMessage(stderr, "commit-graph overflow generation data is too small", .{}), error.UnsupportedGraphOrdering => return tool.fatalMessage(stderr, "zig-git: an ordering with --first-parent, --since, --until or --since-as-filter, and with --children, --left-only, --right-only, --ancestry-path, --simplify-merges or --simplify-by-decoration, is not supported in a repository with a commit-graph", .{}), error.UnsupportedAuthorDateOrdering => return tool.fatalMessage(stderr, "zig-git: --author-date-order is not supported here in a repository with a commit-graph, where git orders a commit whose author date it has not read", .{}), error.UnsupportedParentRewrite => return tool.fatalMessage(stderr, "zig-git {s}: --parents with paths and --first-parent, or --full-history with --reverse or --skip, is not supported without exclusions or an ordering", .{h.command}), error.NotATree, error.TruncatedTree, error.MalformedMode, error.EmptyName => return tool.fatalMessage(stderr, "zig-git: cannot read a tree: {f}", .{tool.errorWords(err)}), else => return tool.fatalMessage(stderr, "zig-git: cannot read a commit: {f}", .{tool.errorWords(err)}), } if (child) |c| return tool.fatalMessage(stderr, "Failed to traverse parents of commit {f}", .{h.store.id(c)}); return tool.fatalMessage(stderr, "zig-git: cannot read a commit: {f}", .{tool.errorWords(err)}); } }; /// The grafts and the shallow boundary, read as git reads them when it /// first needs a commit's parents, with what git says of them said once; /// a bad shallow file stops git. pub fn loadGrafts(ctx: Context, repo: *git.Repository, stderr: *Io.Writer) !*const git.Grafts { var diag: Diagnostics = .{}; const g = repo.grafts(ctx.io, &diag) catch |err| switch (err) { error.BadShallowLine => return tool.fatalMessage(stderr, "bad shallow line: {s}", .{diag.detail()}), else => return tool.fatalMessage(stderr, "zig-git: cannot read {s}: {s}", .{ diag.path() orelse "", diag.detail() }), }; if (!g.reported) { g.reported = true; const hint = (repo.boolSetting("advice.graftFileDeprecated", null) catch null) orelse true; try g.writeWarnings(stderr, hint); } return g; } /// Says what git said of the replace refs it passed over, as it does when /// it first reads one. pub fn reportReplacements(repo: *git.Repository, stderr: *Io.Writer) !void { if (repo.replacements.reported) return; repo.replacements.reported = true; try repo.replacements.writeWarnings(stderr); } /// The refs `--all`, `--branches`, `--tags`, `--remotes` and `--glob` /// choose. pub const Listing = enum { all, branches, tags, remotes, glob }; /// The refs `listing` chooses, with `value` its `=`, less those /// `excludes` matches, in the ref store's order, as names and object IDs; /// `--all` adds `HEAD`. The caller frees the names with `arena`. pub fn listRefs(ctx: Context, repo: *git.Repository, arena: Allocator, listing: Listing, value: ?[]const u8, excludes: []const []const u8, out: *std.ArrayList(Ref), stderr: *Io.Writer) !void { const gpa = ctx.gpa; const store = repo.refs(); const strip: []const u8 = switch (listing) { .branches => "refs/heads/", .tags => "refs/tags/", .remotes => "refs/remotes/", else => "", }; var pattern: ?[]u8 = null; defer if (pattern) |p| gpa.free(p); if (listing == .glob or value != null) { const v = value.?; const base = if (listing == .glob) (if (std.mem.startsWith(u8, v, "refs/")) "" else "refs/") else strip; const globbing = std.mem.findAny(u8, v, "*?[") != null; pattern = try std.mem.concat(gpa, u8, &.{ base, v, if (globbing) "" else "/*" }); } var diag: Diagnostics = .{}; var refs = store.list(ctx.io, gpa, if (strip.len > 0) strip else "refs/", &diag) catch |err| return tool.fatalMessage(stderr, "zig-git: cannot list refs: {f}", .{tool.errorWords(err)}); defer refs.deinit(); for (refs.entries) |e| { if (pattern) |p| if (!git.wildmatch.match(p, e.name, .{})) continue; const excluded = for (excludes) |x| { if (git.wildmatch.match(x, e.name[strip.len..], .{})) break true; } else false; if (excluded) continue; var res = store.resolve(ctx.io, gpa, e.name, &diag) catch continue; defer res.deinit(gpa); const id = switch (res.outcome) { .direct => |d| d.id, else => continue, }; try out.append(gpa, .{ .name = try arena.dupe(u8, e.name), .id = id }); } if (listing == .all) { var res = store.resolve(ctx.io, gpa, "HEAD", &diag) catch return; defer res.deinit(gpa); switch (res.outcome) { .direct => |d| try out.append(gpa, .{ .name = "HEAD", .id = d.id }), else => {}, } } } /// A ref chosen by a listing. pub const Ref = struct { name: []const u8, id: ObjectId }; /// One commit a set of commits starts from or excludes. pub const Part = struct { id: ObjectId, excluded: bool, side: git.RevWalk.Side = .none, /// How `rev-parse --symbolic` names it, without its `^`. symbolic: []const u8, /// Whether it is a merge base `A...B` excluded, which `rev-parse` /// names by object ID whatever it was asked. merge_base: bool = false, /// Whether it was found from what was written rather than written: a /// parent, or a merge base. derived: bool = false, }; /// The commits the set `text` names, if it is one whose revisions all /// resolve, as git takes it, in the order the walk takes them (for /// `A...B`, `A`, `B`, then the merge bases); null if git takes `text` for /// a single revision. `--symbolic` names live in `arena`. pub fn expandSet( revs: *revisions.Revisions, history: *History, arena: Allocator, text: []const u8, out: *std.ArrayList(Part), diag: *Diagnostics, stderr: *Io.Writer, ) !?void { const gpa = revs.ctx.gpa; const set = git.revision.parseSet(text) orelse return null; const r = &revs.resolver; const saved = r.quiet; r.quiet = true; defer r.quiet = saved; const Side = struct { fn resolve(rr: *git.Resolver, t: []const u8, d: *Diagnostics) ?ObjectId { const name = if (t.len == 0) "HEAD" else t; const found = rr.resolve(name, d) catch return null; return found.id; } }; switch (set) { .range => |x| { // `..` alone is not a range: git takes it for a path. if (x.from.len == 0 and x.to.len == 0) return null; const from = Side.resolve(r, x.from, diag) orelse return null; const to = Side.resolve(r, x.to, diag) orelse return null; try out.append(gpa, .{ .id = to, .excluded = false, .symbolic = if (x.to.len == 0) "HEAD" else x.to }); try out.append(gpa, .{ .id = from, .excluded = true, .symbolic = if (x.from.len == 0) "HEAD" else x.from }); }, .symmetric => |x| { if (x.left.len == 0 and x.right.len == 0) return null; const left = Side.resolve(r, x.left, diag) orelse return null; const right = Side.resolve(r, x.right, diag) orelse return null; try out.append(gpa, .{ .id = left, .excluded = false, .side = .left, .symbolic = if (x.left.len == 0) "HEAD" else x.left }); try out.append(gpa, .{ .id = right, .excluded = false, .side = .right, .symbolic = if (x.right.len == 0) "HEAD" else x.right }); const lc = try r.peelToCommit(left) orelse return null; const rc = try r.peelToCommit(right) orelse return null; const s = &history.store; const li = try s.name(lc); const ri = try s.name(rc); const found = git.merge_base.bases(gpa, s, li, &.{ri}, true, diag) catch |err| return history.fail(err, diag, null, stderr); defer gpa.free(found); for (found) |b| try out.append(gpa, .{ .id = s.id(b), .excluded = true, .symbolic = try std.fmt.allocPrint(arena, "{f}", .{s.id(b)}), .merge_base = true, .derived = true }); }, .exclude => |x| { const id = Side.resolve(r, x, diag) orelse return null; try out.append(gpa, .{ .id = id, .excluded = true, .symbolic = x }); }, .parents => |x| { const id = Side.resolve(r, x, diag) orelse return null; const c = try r.peelToCommit(id) orelse return null; const s = &history.store; const ci = try s.name(c); s.load(ci, diag) catch |err| return history.fail(err, diag, null, stderr); for (s.parents(ci), 1..) |p, n| try out.append(gpa, .{ .id = s.id(p), .excluded = false, .symbolic = try std.fmt.allocPrint(arena, "{s}^{d}", .{ x, n }), .derived = true }); }, .exclude_parents => |x| { const id = Side.resolve(r, x, diag) orelse return null; const c = try r.peelToCommit(id) orelse return null; const s = &history.store; const ci = try s.name(c); s.load(ci, diag) catch |err| return history.fail(err, diag, null, stderr); try out.append(gpa, .{ .id = id, .excluded = false, .symbolic = x }); for (s.parents(ci), 1..) |p, n| try out.append(gpa, .{ .id = s.id(p), .excluded = true, .symbolic = try std.fmt.allocPrint(arena, "{s}^{d}", .{ x, n }), .derived = true }); }, .parent_range => |x| { const id = Side.resolve(r, x.rev, diag) orelse return null; const c = try r.peelToCommit(id) orelse return null; const s = &history.store; const ci = try s.name(c); s.load(ci, diag) catch |err| return history.fail(err, diag, null, stderr); const ps = s.parents(ci); if (x.n > ps.len) return null; try out.append(gpa, .{ .id = id, .excluded = false, .symbolic = x.rev }); try out.append(gpa, .{ .id = s.id(ps[@intCast(x.n - 1)]), .excluded = true, .symbolic = try std.fmt.allocPrint(arena, "{s}^{d}", .{ x.rev, x.n }), .derived = true }); }, } r.clearMessages(); }