// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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 '' for ''", /// 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); } }