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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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^-<n>` — 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, /// 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 rev-list: --parents with paths and --first-parent, or --full-history with --reverse or --skip, is not supported without exclusions or an ordering", .{}), error.NotATree, error.TruncatedTree, error.MalformedMode, error.EmptyName => return tool.fatalMessage(stderr, "zig-git: cannot read a tree: {t}", .{err}), else => return tool.fatalMessage(stderr, "zig-git: cannot read a commit: {t}", .{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: {t}", .{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 `=<pattern>`, 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: {t}", .{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();}