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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A repository, opened: where its git directory, common directory and//! working tree are, its format, its configuration from every source, and//! its object database and its ref store. The entry point to the library//! for reading one.//!//! It is opened from what `discover` found, with everything git would//! take from the environment passed in through `Options`. The//! configuration is read as git reads it in that repository: the system//! and global files, the common directory's `config`, `config.worktree`//! in the git directory when `extensions.worktreeConfig` is on, and the//! command scope, with `includeIf` conditions answered by the repository//! itself.//!//! Paths are as git uses them after it has moved to the directory it//! works from (`Location.work_dir`): the local configuration file is the//! common directory's `config` as git names it in `--show-origin`, and//! the objects directory is `GIT_OBJECT_DIRECTORY` or the common//! directory's `objects`, each relative to that directory when relative,//! as git reads them.//!//! The ref store is the files backend or the reftable backend, as//! `extensions.refStorage` says, for the worktree the repository was//! opened in. `onbranch:` is answered from it: the branch `HEAD` names,//! read before the configuration is.
const std = @import("std");const Allocator = std.mem.Allocator;const Io = std.Io;
const Config = @import("Config.zig");const Diagnostics = @import("Diagnostics.zig");const DiskDatabase = @import("DiskDatabase.zig");const RepositoryFormat = @import("RepositoryFormat.zig");const discover = @import("discover.zig");const object = @import("object.zig");const value = @import("config/value.zig");const RefStore = @import("RefStore.zig");const FilesBackend = @import("refs/Files.zig");const Index = @import("Index.zig");const ReftableBackend = @import("refs/Reftable.zig");const ObjectDatabase = @import("ObjectDatabase.zig");const Replacements = @import("Replacements.zig");const Grafts = @import("Grafts.zig");
const Repository = @This();
gpa: Allocator,/// Where everything is; owned.location: discover.Location,/// The directory git works from, open.work_dir: Io.Dir,/// Every variable from every source, in git's order.config: Config,/// Where the objects are.database: DiskDatabase,/// The local configuration file as git names it.config_path: []u8,/// The worktree configuration file as git names it, when it is read.worktree_config_path: ?[]u8,/// The objects directory, as given to the database.objects_path: []u8,/// `onbranch:`'s answer, read from `HEAD`.head: Head,/// The refs, in whichever storage the repository keeps them.ref_backend: RefBackend,/// The git directory made absolute without resolving links, for/// `gitdir:`.git_dir_absolute: []u8,/// The replace refs every object is read through, or none when git would/// not use them.replacements: Replacements,/// Grafts and the shallow boundary, read the first time they are asked/// for.grafts_loaded: ?Grafts = null,/// Where the grafts file and the shallow file are, relative to/// `work_dir`; owned.graft_paths: struct { grafts: ?[]u8 = null, shallow: ?[]u8 = null } = .{},
/// What `HEAD` was found to name when the repository was opened, for/// `onbranch:`.pub const Head = union(enum) { /// A branch, without `refs/heads/`, born or not; owned. branch: []u8, /// Detached, naming something other than a branch, or unreadable. other,};
/// The backend behind `refs`.pub const RefBackend = union(enum) { files: FilesBackend, reftable: ReftableBackend,
fn deinit(b: *RefBackend) void { switch (b.*) { inline else => |*x| x.deinit(), } }
fn store(b: *RefBackend) RefStore { return switch (b.*) { inline else => |*x| x.store(), }; }};
/// What opening a repository needs besides where it is.pub const Options = struct { /// The environment git would run with: the configuration's /// `GIT_CONFIG_*`, `HOME` and `XDG_CONFIG_HOME`, and /// `GIT_ALTERNATE_OBJECT_DIRECTORIES`. environment: ?*const std.process.Environ.Map = null, /// The system configuration file when `GIT_CONFIG_SYSTEM` is unset. system_config: []const u8 = "/etc/gitconfig", /// `-c` settings, after everything else. command_line: []const []const u8 = &.{}, /// What `~` and `%(prefix)` mean in configuration paths. path: value.PathContext = .{}, /// Whether replace refs may be used: false for `git /// --no-replace-objects`. They are not used either when the /// environment has `GIT_NO_REPLACE_OBJECTS` or `core.useReplaceRefs` /// is false. replace_objects: bool = true, /// The most bytes an object may claim when it is read whole, or null /// for no limit, as `DiskDatabase.Options.max_object_size` describes: /// a gigabyte by default, which git does not have. A server reading /// repositories it does not trust should set it lower. max_object_size: ?u64 = DiskDatabase.default_max_object_size,};
/// Why a repository could not be opened.pub const OpenError = Config.LoadError || DiskDatabase.OpenError || error{ /// `GIT_CONFIG_NOSYSTEM` is not a boolean. BadNoSystem, /// The directory git works from could not be opened. WorkDirUnreadable, /// `core.multiPackIndex` is not a boolean. BadMultiPackIndexSetting, /// `core.useReplaceRefs` is not a boolean. BadReplaceRefsSetting, /// `GIT_REPLACE_REF_BASE` does not end in `/`. UnsupportedReplaceRefBase,};
/// Opens the repository `location` describes, taking ownership of it/// whether or not this succeeds.pub fn open(gpa: Allocator, io: Io, location: discover.Location, options: Options, diag: ?*Diagnostics) OpenError!Repository { var loc = location; errdefer loc.deinit(gpa); var work_dir = Io.Dir.cwd().openDir(io, loc.work_dir, .{}) catch |err| switch (err) { error.Canceled => |e| return e, else => { if (diag) |d| { d.reset(); d.setPath(&.{loc.work_dir}); d.setDetail("{s}", .{Config.reason(err)}); } return error.WorkDirUnreadable; }, }; errdefer work_dir.close(io);
const config_path = try joinNormal(gpa, loc.common_dir, "config"); errdefer gpa.free(config_path); const worktree_config_path: ?[]u8 = if (loc.format.version >= 0 and loc.format.worktree_config) try joinNormal(gpa, loc.git_dir, "config.worktree") else null; errdefer if (worktree_config_path) |p| gpa.free(p); const git_dir_absolute = try discover.normalize(gpa, loc.work_dir, loc.git_dir); errdefer gpa.free(git_dir_absolute); const backend_options: FilesBackend.Options = .{ .format = loc.format.object_format, .base = work_dir, .git_dir = loc.git_dir, .common_dir = loc.common_dir, .linked = loc.isLinkedWorktree(), }; var ref_backend: RefBackend = switch (loc.format.ref_storage) { .files => .{ .files = try .init(gpa, backend_options) }, .reftable => .{ .reftable = try .init(gpa, backend_options) }, }; errdefer ref_backend.deinit(); const head = try readHead(gpa, io, ref_backend.store()); errdefer if (head == .branch) gpa.free(head.branch);
var r: Repository = .{ .gpa = gpa, .location = loc, .work_dir = work_dir, .config = undefined, .database = undefined, .config_path = config_path, .worktree_config_path = worktree_config_path, .objects_path = undefined, .head = head, .ref_backend = ref_backend, .git_dir_absolute = git_dir_absolute, .replacements = undefined, };
const empty: std.process.Environ.Map = .init(gpa); const env = options.environment orelse ∅ var sources = Config.Sources.fromEnvironment(gpa, env, .{ .system = options.system_config }) catch |err| switch (err) { error.BadNoSystem => { if (diag) |d| { d.reset(); d.setDetail("{s}", .{env.get("GIT_CONFIG_NOSYSTEM").?}); } return error.BadNoSystem; }, else => |e| return e, }; defer sources.deinit(gpa); sources.local = config_path; sources.worktree = worktree_config_path; sources.command_line = options.command_line; r.config = try Config.load(gpa, io, work_dir, sources, .{ .path = options.path, .repository = r.conditionAnswers(), }, diag); errdefer r.config.deinit();
r.objects_path = if (loc.object_directory) |o| try gpa.dupe(u8, o) else try joinNormal(gpa, loc.common_dir, "objects"); errdefer gpa.free(r.objects_path); const use_midx = blk: { const e = (r.config.get("core.multiPackIndex") catch null) orelse break :blk true; break :blk value.parseBool(e.value) orelse { if (diag) |d| { d.reset(); d.setDetail("{s}", .{e.value.?}); } return error.BadMultiPackIndexSetting; }; }; r.database = try DiskDatabase.open(gpa, io, .{ .objects_path = r.objects_path, .base = work_dir, .format = loc.format.object_format, .alternate_list = env.get("GIT_ALTERNATE_OBJECT_DIRECTORIES"), .use_multi_pack_index = use_midx, .max_object_size = options.max_object_size, }, diag); errdefer r.database.close(io);
// Replace refs, as git reads them when it reads the configuration. const use_replace = blk: { const e = (r.config.get("core.useReplaceRefs") catch null) orelse break :blk true; break :blk value.parseBool(e.value) orelse { if (diag) |d| { d.reset(); d.setDetail("{s}", .{e.value orelse ""}); } return error.BadReplaceRefsSetting; }; }; r.replacements = .none(gpa, r.database.database()); if (options.replace_objects and use_replace and env.get("GIT_NO_REPLACE_OBJECTS") == null) { // The database has not moved since it was opened, but `r` will. // Refs that cannot be listed replace nothing here; whoever reads // the refs says what is wrong with them. r.replacements = Replacements.load(gpa, io, r.ref_backend.store(), env.get("GIT_REPLACE_REF_BASE") orelse Replacements.default_base, loc.format.object_format, r.database.database(), diag) catch |err| switch (err) { error.OutOfMemory, error.Canceled, error.UnsupportedReplaceRefBase => |e| return e, else => .none(gpa, r.database.database()), }; } errdefer r.replacements.deinit();
if (env.get("GIT_GRAFT_FILE")) |g| { r.graft_paths.grafts = try gpa.dupe(u8, g); } else r.graft_paths.grafts = try std.fs.path.join(gpa, &.{ loc.common_dir, "info", "grafts" }); errdefer gpa.free(r.graft_paths.grafts.?); r.graft_paths.shallow = if (env.get("GIT_SHALLOW_FILE")) |f| try gpa.dupe(u8, f) else try std.fs.path.join(gpa, &.{ loc.common_dir, "shallow" }); return r;}
/// The object database as every command reads it: through the replace/// refs, when git would use them. It reads `r.database`, which must not/// move while it is in use; nor may `r`.pub fn objects(r: *Repository) ObjectDatabase { r.replacements.inner = r.database.database(); return r.replacements.database();}
/// Whether git would read the commit-graph: not when objects are/// replaced, commits grafted or the repository shallow. Reads the grafts/// and the shallow file if they have not been read.pub fn commitGraphUsable(r: *Repository, io: Io, diag: ?*Diagnostics) Grafts.LoadError!bool { if (!r.replacements.isEmpty()) return false; return (try r.grafts(io, diag)).commitGraphUsable();}
/// The grafts and the shallow boundary, from `GIT_GRAFT_FILE` or/// `info/grafts` and from `GIT_SHALLOW_FILE` or `shallow`, read the first/// time they are asked for, as git reads them the first time it needs a/// commit's parents.pub fn grafts(r: *Repository, io: Io, diag: ?*Diagnostics) Grafts.LoadError!*Grafts { if (r.grafts_loaded) |*g| return g; r.grafts_loaded = try Grafts.load(r.gpa, io, r.work_dir, r.objectFormat(), .{ .grafts = r.graft_paths.grafts, .shallow = r.graft_paths.shallow, }, diag); return &r.grafts_loaded.?;}
/// Closes everything.pub fn close(r: *Repository, io: Io) void { const gpa = r.gpa; r.replacements.deinit(); if (r.grafts_loaded) |*g| g.deinit(); if (r.graft_paths.grafts) |p| gpa.free(p); if (r.graft_paths.shallow) |p| gpa.free(p); r.database.close(io); r.config.deinit(); gpa.free(r.objects_path); gpa.free(r.config_path); if (r.worktree_config_path) |p| gpa.free(p); gpa.free(r.git_dir_absolute); if (r.head == .branch) gpa.free(r.head.branch); r.ref_backend.deinit(); r.work_dir.close(io); r.location.deinit(gpa); r.* = undefined;}
/// Discovers a repository and opens it.pub fn discoverAndOpen(gpa: Allocator, io: Io, discover_options: discover.Options, options: Options, diag: ?*Diagnostics) (discover.Error || OpenError)!Repository { const loc = try discover.discover(gpa, io, discover_options, diag); return open(gpa, io, loc, options, diag);}
/// The repository's object format.pub fn objectFormat(r: *const Repository) object.Format { return r.location.format.object_format;}
/// How the repository keeps its refs.pub fn refStorage(r: *const Repository) RepositoryFormat.RefStorage { return r.location.format.ref_storage;}
/// The branch `HEAD` named when the repository was opened, without/// `refs/heads/`; null when detached.pub fn branch(r: *const Repository) ?[]const u8 { return switch (r.head) { .branch => |b| b, .other => null, };}
/// The repository's refs. The store reads its storage the first time it/// is asked, and answers from what it read after.pub fn refs(r: *Repository) RefStore { return r.ref_backend.store();}
/// How to read the index.pub const IndexOptions = struct { /// `GIT_INDEX_FILE`, which git reads relative to the top of the /// working tree (the directory it works from); null for the git /// directory's `index`. index_file: ?[]const u8 = null, /// How to read it. read: Index.Options = .{},};
/// The index file's name as git names it: `GIT_INDEX_FILE` as given, or/// the git directory's `index`; the caller frees it.pub fn indexPath(r: *const Repository, gpa: Allocator, options: IndexOptions) Allocator.Error![]u8 { if (options.index_file) |f| return gpa.dupe(u8, f); return joinNormal(gpa, r.location.git_dir, "index");}
/// Reads the index, from the git directory's `index` or where/// `GIT_INDEX_FILE` says. A repository without one has an empty index.pub fn readIndex(r: *const Repository, io: Io, options: IndexOptions, diag: ?*Diagnostics) Index.LoadError!Index { const path = try r.indexPath(r.gpa, options); defer r.gpa.free(path); var read = options.read; read.git_dir = r.location.git_dir; return Index.load(r.gpa, io, r.work_dir, path, r.objectFormat(), read, diag);}
/// Why a boolean setting could not be read: a value that is/// not a boolean, git's "bad boolean config value '<value>' for '<key>'",/// the diagnostic's detail the key, `=` and the value.pub const SettingError = error{BadBoolSetting};
/// Whether the index may keep sparse directories: `core.sparseCheckout`,/// `core.sparseCheckoutCone` and `index.sparse` all true. When they are/// not, git expands a sparse index as soon as it reads it.pub fn sparseIndexAllowed(r: *const Repository, diag: ?*Diagnostics) SettingError!bool { for ([_][]const u8{ "core.sparseCheckout", "core.sparseCheckoutCone", "index.sparse" }) |key| { const on = try r.boolSetting(key, diag) orelse false; if (!on) return false; } return true;}
/// A boolean setting, null when unset.pub fn boolSetting(r: *const Repository, key: []const u8, diag: ?*Diagnostics) SettingError!?bool { const e = (r.config.get(key) catch null) orelse return null; return value.parseBool(e.value) orelse { if (diag) |d| { d.reset(); d.setDetail("{s}={s}", .{ e.key, e.value.? }); } return error.BadBoolSetting; };}
/// The repository as `Config` asks about it for `includeIf`.pub fn conditionAnswers(r: *const Repository) Config.Repository { return .{ .context = r, .vtable = &condition_vtable };}
const condition_vtable: Config.Repository.VTable = .{ .gitDir = struct { fn f(c: *const anyopaque) ?[]const u8 { return @as(*const Repository, @ptrCast(@alignCast(c))).git_dir_absolute; } }.f, .realGitDir = struct { fn f(c: *const anyopaque) ?[]const u8 { return @as(*const Repository, @ptrCast(@alignCast(c))).location.git_dir_real; } }.f, .branch = struct { fn f(c: *const anyopaque) Config.Repository.BranchError!?[]const u8 { return @as(*const Repository, @ptrCast(@alignCast(c))).branch(); } }.f, .workTree = struct { fn f(c: *const anyopaque) ?[]const u8 { return @as(*const Repository, @ptrCast(@alignCast(c))).location.work_tree; } }.f,};
/// `dir` and `name` joined as git joins them for a file's name: `./`/// dropped, as `config` rather than `./config`.fn joinNormal(gpa: Allocator, dir: []const u8, name: []const u8) Allocator.Error![]u8 { var d = dir; while (std.mem.startsWith(u8, d, "./")) d = std.mem.trimStart(u8, d[2..], "/"); if (std.mem.eql(u8, d, ".") or d.len == 0) return gpa.dupe(u8, name); d = std.mem.trimEnd(u8, d, "/"); if (d.len == 0) return std.mem.concat(gpa, u8, &.{ "/", name }); return std.mem.concat(gpa, u8, &.{ d, "/", name });}
/// The branch `HEAD` names at the end of its chain of symbolic refs, as/// git finds the current branch. A `HEAD` that cannot be read names none.fn readHead(gpa: Allocator, io: Io, store: RefStore) (Allocator.Error || Io.Cancelable)!Head { var resolved = store.resolve(io, gpa, "HEAD", null) catch |err| switch (err) { error.OutOfMemory, error.Canceled => |e| return e, else => return .other, }; defer resolved.deinit(gpa); if (!resolved.isSymbolic()) return .other; switch (resolved.outcome) { .direct, .missing => {}, else => return .other, } const prefix = "refs/heads/"; const target = resolved.last(); if (!std.mem.startsWith(u8, target, prefix)) return .other; return .{ .branch = try gpa.dupe(u8, target[prefix.len..]) };}
// -- tests -----------------------------------------------------------------------
const testing = std.testing;const fixture = @import("testing/fixture.zig");
fn openIn(repo: *fixture.Repo, sub: []const u8) !Repository { const start = try std.fs.path.join(testing.allocator, &.{ repo.path, sub }); defer testing.allocator.free(start); return discoverAndOpen(testing.allocator, testing.io, .{ .start = start, .ownership = if (discover.effectiveUid()) |uid| .{ .uid = uid } else null, }, .{ .environment = &repo.env }, null);}
test "a repository opens from a subdirectory with its configuration and objects" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.tmp.dir.createDirPath(testing.io, "a/b"); try repo.commitFile("a/b/f", "hello\n", "first"); try repo.git(&.{ "config", "zig.test", "yes" }); var r = try openIn(&repo, "a/b"); defer r.close(testing.io); try testing.expectEqual(repo.object_format, r.objectFormat()); try testing.expectEqualStrings("main", r.branch().?); try testing.expectEqualStrings("yes", (try r.config.getString("zig.test")).?); try testing.expectEqualStrings(".git/config", r.config_path); try testing.expectEqualStrings("a/b/", r.location.prefix.?); const head = try repo.output(&.{ "rev-parse", "HEAD" }); defer testing.allocator.free(head); const id = try object.Id.parseHex(r.objectFormat(), std.mem.trimEnd(u8, head, "\n")); try testing.expect(try r.database.contains(testing.io, id, null));}
test "includeIf conditions are answered by the repository" { var repo = try fixture.Repo.init(testing.allocator, testing.io); defer repo.deinit(); try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "inc-branch", .data = "[zig]\n\tbranch = yes\n" }); try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "inc-gitdir", .data = "[zig]\n\tgitdir = yes\n" }); try repo.tmp.dir.writeFile(testing.io, .{ .sub_path = "inc-worktree", .data = "[zig]\n\tworktree = yes\n" }); const inc_branch = try std.fs.path.join(testing.allocator, &.{ repo.path, "inc-branch" }); defer testing.allocator.free(inc_branch); const inc_gitdir = try std.fs.path.join(testing.allocator, &.{ repo.path, "inc-gitdir" }); defer testing.allocator.free(inc_gitdir); const inc_worktree = try std.fs.path.join(testing.allocator, &.{ repo.path, "inc-worktree" }); defer testing.allocator.free(inc_worktree); try repo.git(&.{ "config", "includeIf.onbranch:ma*.path", inc_branch }); try repo.git(&.{ "config", "includeIf.gitdir:**/.git.path", inc_gitdir }); const wt_pattern = try std.fmt.allocPrint(testing.allocator, "includeIf.worktree:{s}.path", .{repo.path}); defer testing.allocator.free(wt_pattern); try repo.git(&.{ "config", wt_pattern, inc_worktree }); var r = try openIn(&repo, "."); defer r.close(testing.io); for ([_][]const u8{ "zig.branch", "zig.gitdir", "zig.worktree" }) |key| { const out = try repo.output(&.{ "config", key }); defer testing.allocator.free(out); try testing.expectEqualStrings(std.mem.trimEnd(u8, out, "\n"), (try r.config.getString(key)).?); }}
test "names are joined as git names the configuration file" { const gpa = testing.allocator; for ([_][3][]const u8{ .{ ".", "config", "config" }, .{ "./.git", "config", ".git/config" }, .{ ".git/", "config", ".git/config" }, .{ "/a/b", "objects", "/a/b/objects" }, }) |c| { const got = try joinNormal(gpa, c[0], c[1]); defer gpa.free(got); try testing.expectEqualStrings(c[2], got); }}