// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The files backend: refs kept as loose refs under `refs/` and the git //! directory, and in `packed-refs`, with reflogs under `logs/`, as //! gitrepository-layout(5) lays them out. //! //! A loose ref overrides a packed one of the same name. Shared refs are //! in the common directory; per-worktree refs (`HEAD` and the other root //! refs, `refs/bisect/`, `refs/worktree/`, `refs/rewritten/`) in the git //! directory of the worktree they belong to, which for the main worktree //! is the common directory and for a linked one is under its //! `worktrees/`. Only the main worktree's per-worktree refs are looked //! for in `packed-refs`. //! //! What git can be seen to do when listing loose refs: a file or //! directory whose name begins with `.` or ends in `.lock` is passed over //! without a word; a file whose name is not a well-formed ref name is a //! broken name; a symbolic link whose target begins `refs/` is a symbolic //! ref to that target, and any other is followed and read as a file. A //! directory where a ref is looked for is no ref at all. //! //! `packed-refs` is read the first time it is wanted and kept; so is its //! absence. Nothing is cached of loose refs, which are read when asked. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const object = @import("../object.zig"); const Diagnostics = @import("../Diagnostics.zig"); const RefStore = @import("../RefStore.zig"); const Config = @import("../Config.zig"); const name_rules = @import("name.zig"); const loose = @import("loose.zig"); const packed_refs = @import("packed.zig"); const reflog_format = @import("reflog.zig"); const Files = @This(); gpa: Allocator, format: object.Format, /// What the paths below are relative to, when relative. base: Io.Dir, /// The current worktree's git directory, as git names it. git_dir: []u8, /// The common directory, as git names it. common_dir: []u8, /// Whether the git directory is a linked worktree's. linked: bool, /// `packed-refs`, once read. packed_state: PackedState = .unread, const PackedState = union(enum) { unread, absent, read: struct { contents: []u8, refs: packed_refs.PackedRefs }, }; /// Where the refs are. pub const Options = struct { format: object.Format, /// What relative paths are relative to. base: Io.Dir, /// The git directory of the worktree whose refs these are. git_dir: []const u8, /// The common directory. common_dir: []const u8, /// Whether `git_dir` is a linked worktree's rather than the common /// directory itself. linked: bool, }; /// A files backend for the refs `options` locates. Nothing is read yet. pub fn init(gpa: Allocator, options: Options) Allocator.Error!Files { const git_dir = try gpa.dupe(u8, options.git_dir); errdefer gpa.free(git_dir); return .{ .gpa = gpa, .format = options.format, .base = options.base, .git_dir = git_dir, .common_dir = try gpa.dupe(u8, options.common_dir), .linked = options.linked, }; } /// Frees everything. pub fn deinit(f: *Files) void { switch (f.packed_state) { .read => |*r| { r.refs.deinit(f.gpa); f.gpa.free(r.contents); }, else => {}, } f.gpa.free(f.git_dir); f.gpa.free(f.common_dir); f.* = undefined; } /// The backend as a ref store. pub fn store(f: *Files) RefStore { return .{ .ptr = f, .vtable = &vtable }; } const vtable: RefStore.VTable = .{ .read = struct { fn read(ptr: *anyopaque, io: Io, gpa: Allocator, n: []const u8, target: *std.ArrayList(u8), diag: ?*Diagnostics) RefStore.ReadError!?RefStore.Value { return @as(*Files, @ptrCast(@alignCast(ptr))).read(io, gpa, n, target, diag); } }.read, .list = struct { fn list(ptr: *anyopaque, io: Io, gpa: Allocator, prefix: []const u8, diag: ?*Diagnostics) RefStore.ReadError!RefStore.List { return @as(*Files, @ptrCast(@alignCast(ptr))).list(io, gpa, prefix, diag); } }.list, .reflog = struct { fn reflog(ptr: *anyopaque, io: Io, gpa: Allocator, n: []const u8, diag: ?*Diagnostics) RefStore.ReadError!?RefStore.Reflog { return @as(*Files, @ptrCast(@alignCast(ptr))).reflog(io, gpa, n, diag); } }.reflog, }; /// `dir` and `name` joined, `./` dropped, as git names files in /// messages. pub fn joinPath(gpa: Allocator, dir: []const u8, file: []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, file); d = std.mem.trimEnd(u8, d, "/"); if (d.len == 0) return std.mem.concat(gpa, u8, &.{ "/", file }); return std.mem.concat(gpa, u8, &.{ d, "/", file }); } /// The git directory holding the per-worktree refs of the worktree a /// name reaches, which the caller frees. fn perWorktreeDir(f: *const Files, gpa: Allocator, worktree: name_rules.Scoped.Worktree) Allocator.Error![]u8 { return switch (worktree) { .current => gpa.dupe(u8, f.git_dir), .main => gpa.dupe(u8, f.common_dir), .linked => |w| blk: { const wt = try joinPath(gpa, f.common_dir, "worktrees"); defer gpa.free(wt); break :blk joinPath(gpa, wt, w); }, }; } /// Whether a name reaching `worktree` reads `packed-refs` for its /// per-worktree refs: only the main worktree's are ever packed. fn packsPerWorktree(f: *const Files, worktree: name_rules.Scoped.Worktree) bool { return switch (worktree) { .current => !f.linked, .main => true, .linked => false, }; } pub fn read(f: *Files, io: Io, gpa: Allocator, full: []const u8, target: *std.ArrayList(u8), diag: ?*Diagnostics) RefStore.ReadError!?RefStore.Value { const scoped = name_rules.Scoped.parse(full); const n = scoped.name; // A name that is not a clean path names no file, and no packed ref // git would read. if (!name_rules.isValid(n, .{ .allow_onelevel = true })) return null; const per_worktree = name_rules.isPerWorktree(n); const dir = if (per_worktree) try f.perWorktreeDir(gpa, scoped.worktree) else try gpa.dupe(u8, f.common_dir); defer gpa.free(dir); if (try f.readLoose(io, gpa, dir, n, target, diag)) |v| return v; if (per_worktree and !f.packsPerWorktree(scoped.worktree)) return null; const p = try f.packedRefs(io, diag) orelse return null; const e = p.find(n) orelse return null; return .{ .direct = .{ .id = e.id, .peeled = e.peeled } }; } /// Reads the loose ref `n` in `dir`; null if there is none. fn readLoose(f: *Files, io: Io, gpa: Allocator, dir: []const u8, n: []const u8, target: *std.ArrayList(u8), diag: ?*Diagnostics) RefStore.ReadError!?RefStore.Value { const path = try joinPath(gpa, dir, n); defer gpa.free(path); return switch (try f.readLoosePath(io, gpa, path, target, diag)) { .missing => null, // A malformed loose ref hides a packed one of the same name. .malformed => error.MalformedRef, .value => |v| v, }; } const LooseResult = union(enum) { missing, malformed, value: RefStore.Value, }; fn readLoosePath(f: *Files, io: Io, gpa: Allocator, path: []const u8, target: *std.ArrayList(u8), diag: ?*Diagnostics) RefStore.ReadError!LooseResult { const st = f.base.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.Canceled => |e| return e, error.FileNotFound, error.NotDir => return .missing, else => return unreadable(diag, path, err), }; switch (st.kind) { .directory => return .missing, .sym_link => { var buffer: [std.fs.max_path_bytes]u8 = undefined; const len = f.base.readLink(io, path, &buffer) catch |err| switch (err) { error.Canceled => |e| return e, error.FileNotFound => return .missing, else => return unreadable(diag, path, err), }; if (std.mem.startsWith(u8, buffer[0..len], "refs/")) { target.clearRetainingCapacity(); try target.appendSlice(gpa, buffer[0..len]); return .{ .value = .{ .symbolic = target.items } }; } }, else => {}, } const contents = f.base.readFileAlloc(io, path, gpa, .limited(1 << 20)) catch |err| switch (err) { error.Canceled => |e| return e, error.OutOfMemory => |e| return e, error.FileNotFound, error.NotDir, error.IsDir => return .missing, else => return unreadable(diag, path, err), }; defer gpa.free(contents); const v = loose.parse(f.format, contents) catch return .malformed; return switch (v) { .direct => |id| .{ .value = .{ .direct = .{ .id = id } } }, .symbolic => |t| blk: { target.clearRetainingCapacity(); try target.appendSlice(gpa, t); break :blk .{ .value = .{ .symbolic = target.items } }; }, }; } fn unreadable(diag: ?*Diagnostics, path: []const u8, err: anyerror) error{RefUnreadable} { if (diag) |d| { d.setPath(&.{path}); d.setDetail("{s}", .{Config.reason(err)}); } return error.RefUnreadable; } /// `packed-refs`, read the first time it is wanted; null if there is /// none. pub fn packedRefs(f: *Files, io: Io, diag: ?*Diagnostics) RefStore.ReadError!?*const packed_refs.PackedRefs { switch (f.packed_state) { .read => |*r| return &r.refs, .absent => return null, .unread => {}, } const path = try joinPath(f.gpa, f.common_dir, "packed-refs"); defer f.gpa.free(path); const contents = f.base.readFileAlloc(io, path, f.gpa, .unlimited) catch |err| switch (err) { error.Canceled => |e| return e, error.OutOfMemory => |e| return e, error.FileNotFound => { f.packed_state = .absent; return null; }, else => return unreadable(diag, path, err), }; errdefer f.gpa.free(contents); const refs = packed_refs.parse(f.gpa, f.format, contents, diag) catch |err| { if (diag) |d| d.setPath(&.{path}); return err; }; f.packed_state = .{ .read = .{ .contents = contents, .refs = refs } }; return &f.packed_state.read.refs; } pub fn list(f: *Files, io: Io, gpa: Allocator, prefix: []const u8, diag: ?*Diagnostics) RefStore.ReadError!RefStore.List { var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); var loose_entries: std.ArrayList(RefStore.Entry) = .empty; // Shared refs from the common directory; in a linked worktree, its // own per-worktree refs from its git directory in place of the main // worktree's. try f.walk(io, a, f.common_dir, "refs", prefix, if (f.linked) .shared else .all, &loose_entries, diag); if (f.linked) { for (name_rules.per_worktree_prefixes) |p| { const top = p[0 .. p.len - 1]; if (!overlaps(prefix, p)) continue; try f.walk(io, a, f.git_dir, top, prefix, .all, &loose_entries, diag); } } std.sort.block(RefStore.Entry, loose_entries.items, {}, entryLessThan); var entries: std.ArrayList(RefStore.Entry) = .empty; const p = try f.packedRefs(io, diag); const packed_entries = if (p) |pr| pr.entries else &.{}; var i: usize = 0; var j: usize = 0; while (true) { // Packed entries outside the prefix, and in a linked worktree the // main worktree's per-worktree refs, are not listed. A root ref // that has found its way into `packed-refs` is, as git lists it // for `show-ref` (and `for-each-ref` leaves it out). while (j < packed_entries.len) : (j += 1) { const e = packed_entries[j]; if (!std.mem.startsWith(u8, e.name, prefix)) continue; if (f.linked and name_rules.isPerWorktree(e.name)) continue; break; } const have_loose = i < loose_entries.items.len; const have_packed = j < packed_entries.len; if (!have_loose and !have_packed) break; const order: std.math.Order = if (!have_packed) .lt else if (!have_loose) .gt else std.mem.order(u8, loose_entries.items[i].name, packed_entries[j].name); switch (order) { .lt => { try entries.append(a, loose_entries.items[i]); i += 1; }, .eq => { try entries.append(a, loose_entries.items[i]); i += 1; j += 1; }, .gt => { const e = packed_entries[j]; j += 1; const value: RefStore.EntryValue = if (!name_rules.isSafe(e.name)) .{ .broken = .dangerous_name } else if (!name_rules.isValid(e.name, .{ .allow_onelevel = true })) .{ .broken = .bad_name } else .{ .direct = .{ .id = e.id, .peeled = e.peeled } }; try entries.append(a, .{ .name = try a.dupe(u8, e.name), .value = value }); }, } } return .{ .entries = try entries.toOwnedSlice(a), .arena = arena }; } fn entryLessThan(_: void, x: RefStore.Entry, y: RefStore.Entry) bool { return std.mem.lessThan(u8, x.name, y.name); } /// Whether some name under `dir_prefix` (a directory, ending in `/`) can /// begin with `prefix`. fn overlaps(prefix: []const u8, dir_prefix: []const u8) bool { return std.mem.startsWith(u8, prefix, dir_prefix) or std.mem.startsWith(u8, dir_prefix, prefix); } const Which = enum { all, shared }; /// Adds the loose refs under `top` in `dir` whose names begin with /// `prefix` to `out`. With `.shared`, the per-worktree directories are /// left out. fn walk(f: *Files, io: Io, a: Allocator, dir: []const u8, top: []const u8, prefix: []const u8, which: Which, out: *std.ArrayList(RefStore.Entry), diag: ?*Diagnostics) RefStore.ReadError!void { // Start as deep as the prefix allows: its whole directories. var start: []const u8 = top; if (std.mem.startsWith(u8, prefix, top) and prefix.len > top.len and prefix[top.len] == '/') { if (std.mem.findScalarLast(u8, prefix, '/')) |slash| start = prefix[0..slash]; } else if (!std.mem.startsWith(u8, top, prefix) and !std.mem.startsWith(u8, prefix, top)) return; var name_buf: std.ArrayList(u8) = .empty; try name_buf.appendSlice(a, start); try f.walkDir(io, a, dir, &name_buf, prefix, which, out, diag); } fn walkDir(f: *Files, io: Io, a: Allocator, dir: []const u8, ref_name: *std.ArrayList(u8), prefix: []const u8, which: Which, out: *std.ArrayList(RefStore.Entry), diag: ?*Diagnostics) RefStore.ReadError!void { const path = try joinPath(a, dir, ref_name.items); var d = f.base.openDir(io, path, .{ .iterate = true }) catch |err| switch (err) { error.Canceled => |e| return e, error.FileNotFound, error.NotDir => return, else => return unreadable(diag, path, err), }; defer d.close(io); var names: std.ArrayList([]u8) = .empty; var it = d.iterate(); while (it.next(io) catch |err| switch (err) { error.Canceled => |e| return e, else => return unreadable(diag, path, err), }) |entry| { if (entry.name.len == 0 or entry.name[0] == '.' or std.mem.endsWith(u8, entry.name, ".lock")) continue; try names.append(a, try a.dupe(u8, entry.name)); } const len = ref_name.items.len; var target: std.ArrayList(u8) = .empty; for (names.items) |entry_name| { ref_name.shrinkRetainingCapacity(len); try ref_name.append(a, '/'); try ref_name.appendSlice(a, entry_name); const full = ref_name.items; if (which == .shared and name_rules.isPerWorktree(full)) continue; const entry_path = try joinPath(a, dir, full); const st = f.base.statFile(io, entry_path, .{}) catch |err| switch (err) { error.Canceled => |e| return e, error.FileNotFound, error.NotDir => { // A symbolic link to nowhere may still name a ref. try f.addLoose(io, a, entry_path, full, prefix, &target, out, diag); continue; }, else => return unreadable(diag, entry_path, err), }; if (st.kind == .directory) { // A directory is worth entering only if names in it can begin // with the prefix. if (std.mem.startsWith(u8, full, prefix) or std.mem.startsWith(u8, prefix, full)) try f.walkDir(io, a, dir, ref_name, prefix, which, out, diag); continue; } try f.addLoose(io, a, entry_path, full, prefix, &target, out, diag); } ref_name.shrinkRetainingCapacity(len); } fn addLoose(f: *Files, io: Io, a: Allocator, path: []const u8, full: []const u8, prefix: []const u8, target: *std.ArrayList(u8), out: *std.ArrayList(RefStore.Entry), diag: ?*Diagnostics) RefStore.ReadError!void { if (!std.mem.startsWith(u8, full, prefix)) return; const owned = try a.dupe(u8, full); if (!name_rules.isValid(full, .{})) { try out.append(a, .{ .name = owned, .value = .{ .broken = .bad_name } }); return; } const value: RefStore.EntryValue = switch (try f.readLoosePath(io, a, path, target, diag)) { .missing => return, .malformed => .{ .broken = .malformed }, .value => |v| switch (v) { .direct => |d| .{ .direct = d }, .symbolic => |t| .{ .symbolic = try a.dupe(u8, t) }, }, }; try out.append(a, .{ .name = owned, .value = value }); } pub fn reflog(f: *Files, io: Io, gpa: Allocator, full: []const u8, diag: ?*Diagnostics) RefStore.ReadError!?RefStore.Reflog { const scoped = name_rules.Scoped.parse(full); const n = scoped.name; if (!name_rules.isValid(n, .{ .allow_onelevel = true })) return null; const dir = if (name_rules.isPerWorktree(n)) try f.perWorktreeDir(gpa, scoped.worktree) else try gpa.dupe(u8, f.common_dir); defer gpa.free(dir); const logs = try joinPath(gpa, dir, "logs"); defer gpa.free(logs); const path = try joinPath(gpa, logs, n); defer gpa.free(path); var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); const contents = f.base.readFileAlloc(io, path, a, .unlimited) catch |err| switch (err) { error.Canceled => |e| return e, error.OutOfMemory => |e| return e, error.FileNotFound, error.NotDir, error.IsDir => { arena.deinit(); return null; }, else => return unreadable(diag, path, err), }; var entries: std.ArrayList(RefStore.ReflogEntry) = .empty; try reflog_format.parseFile(a, f.format, contents, &entries); return .{ .entries = try entries.toOwnedSlice(a), .arena = arena }; }