// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Replace refs, as git-replace(1) describes them: a ref under //! `refs/replace/` (or `GIT_REPLACE_REF_BASE`) named for an object makes //! that object read as the one the ref points to, everywhere objects are //! read. The object keeps its object ID; only its kind and contents //! change, so `git log` shows a replaced commit under its own ID with the //! replacement's parents, message and tree. //! //! `database` wraps an object database so that every read goes through //! the replacements, which is how every consumer of a `Repository` gets //! them. What git does, found by experiment: //! //! - **The name** is the part of the ref's name after the base. Its last //! component must begin with an object ID written whole, in digits of //! either case; whatever follows the digits is ignored, and so are the //! directories before it (`refs/replace/x/` replaces ``). Any //! other name is passed over with git's "bad replace ref name" warning. //! - **Chains** are followed: a replacement that is itself replaced reads //! as its own replacement, up to four replacements deep. A fifth, which //! a chain that loops always reaches, is "replace depth too high". //! - **A replacement that is not there** is an error naming both objects, //! rather than the original read instead. //! - **Whether an object exists** (`contains`, as `git cat-file -e` asks) //! is asked of the original, which is all a replace ref needs; the kind, //! size and contents are the replacement's. //! - Replacements are not used at all when `core.useReplaceRefs` is false, //! when `GIT_NO_REPLACE_OBJECTS` is set to anything, or with //! `git --no-replace-objects`; that is the caller's to decide, by not //! loading them. //! //! When there is at least one replacement, git does not read the //! commit-graph, whose parents might disagree with them; `isEmpty` is what //! a caller asks to do the same. const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; const object = @import("object.zig"); const Id = object.Id; const Format = object.Format; const Diagnostics = @import("Diagnostics.zig"); const ObjectDatabase = @import("ObjectDatabase.zig"); const RefStore = @import("RefStore.zig"); const Replacements = @This(); gpa: Allocator, /// The database read through. inner: ObjectDatabase, /// Each replaced object's replacement. map: std.AutoHashMapUnmanaged([object.max_byte_length]u8, Id) = .empty, /// The names, after the base, of the refs passed over, in the order /// listed, for git's "bad replace ref name" warning. bad_names: std.ArrayList([]u8) = .empty, /// Whether the warnings have been written, for a caller that writes them /// once, as git does when it first reads an object. reported: bool = false, /// How many replacements a chain may have before it is "replace depth too /// high". pub const max_depth = 4; /// The base git uses when `GIT_REPLACE_REF_BASE` is unset. pub const default_base = "refs/replace/"; /// No replacements, reading `inner` as it is. pub fn none(gpa: Allocator, inner: ObjectDatabase) Replacements { return .{ .gpa = gpa, .inner = inner }; } /// Why the replacements could not be loaded: /// /// - `UnsupportedReplaceRefBase`: a `GIT_REPLACE_REF_BASE` that does not /// end in `/`, which git stops at with a bug of its own. pub const LoadError = RefStore.ReadError || error{UnsupportedReplaceRefBase}; /// Loads the replacements from the refs under `base` in `refs`, for /// objects of `format`, reading through to `inner`. pub fn load(gpa: Allocator, io: Io, refs: RefStore, base: []const u8, format: Format, inner: ObjectDatabase, diag: ?*Diagnostics) LoadError!Replacements { if (base.len != 0 and base[base.len - 1] != '/') { if (diag) |d| { d.reset(); d.setDetail("{s}", .{base}); } return error.UnsupportedReplaceRefBase; } var r = none(gpa, inner); errdefer r.deinit(); var list = try refs.list(io, gpa, base, diag); defer list.deinit(); for (list.entries) |e| { const rest = e.name[base.len..]; const original = parseName(format, rest) orelse { try r.bad_names.append(gpa, try gpa.dupe(u8, rest)); continue; }; const replacement = switch (e.value) { .direct => |d| d.id, .symbolic => blk: { var resolved = refs.resolve(io, gpa, e.name, null) catch continue; defer resolved.deinit(gpa); break :blk switch (resolved.outcome) { .direct => |d| d.id, else => continue, }; }, .broken => continue, }; const gop = try r.map.getOrPut(gpa, original.bytes); if (!gop.found_existing) gop.value_ptr.* = replacement; } return r; } /// Frees everything. pub fn deinit(r: *Replacements) void { r.map.deinit(r.gpa); for (r.bad_names.items) |n| r.gpa.free(n); r.bad_names.deinit(r.gpa); r.* = undefined; } /// Writes git's warning for each replace ref passed over. pub fn writeWarnings(r: *const Replacements, w: *Io.Writer) Io.Writer.Error!void { for (r.bad_names.items) |n| try w.print("warning: bad replace ref name: {s}\n", .{n}); } /// Whether nothing is replaced. pub fn isEmpty(r: *const Replacements) bool { return r.map.count() == 0; } /// The object a replace ref named `name` (after the base) replaces, or /// null when git passes over the name: the last component must begin /// with an object ID written whole. pub fn parseName(format: Format, name: []const u8) ?Id { const last = name[if (std.mem.findScalarLast(u8, name, '/')) |s| s + 1 else 0..]; const n = format.hexLength(); if (last.len < n) return null; return Id.parseHex(format, last[0..n]) catch null; } /// Why following replacements failed: /// /// - `ReplaceDepthTooHigh`: more than `max_depth` replacements in a row; /// git's "replace depth too high for object ". The diagnostic's /// object ID is the original. pub const ResolveError = error{ReplaceDepthTooHigh}; /// What `id` reads as: itself, or the end of its chain of replacements. pub fn resolve(r: *const Replacements, id: Id, diag: ?*Diagnostics) ResolveError!Id { var current = id; var depth: usize = 0; while (r.map.get(current.bytes)) |next| { depth += 1; if (depth > max_depth) { if (diag) |d| { d.reset(); d.object_id = id; } return error.ReplaceDepthTooHigh; } current = next; } return current; } /// The object database `r` reads through `inner` with the replacements; /// `r` must outlive it. pub fn database(r: *Replacements) ObjectDatabase { if (r.isEmpty()) return r.inner; return .{ .ptr = r, .vtable = &vtable }; } const vtable: ObjectDatabase.VTable = .{ .contains = vtableContains, .readHeader = vtableReadHeader, .read = vtableRead, .stream = vtableStream, }; fn vtableContains(ptr: *anyopaque, io: Io, id: Id, diag: ?*Diagnostics) ObjectDatabase.ContainsError!bool { const r: *Replacements = @ptrCast(@alignCast(ptr)); return r.inner.contains(io, id, diag); } /// The object to read in place of `id`. fn target(r: *Replacements, id: Id, diag: ?*Diagnostics) ObjectDatabase.ReadError!Id { return r.resolve(id, diag) catch |err| switch (err) { error.ReplaceDepthTooHigh => error.ReplaceDepthTooHigh, }; } /// A replacement that is not there is an error of its own, naming both. fn missing(err: anytype, id: Id, replacement: Id, diag: ?*Diagnostics) @TypeOf(err) { if (err == error.ObjectNotFound and !id.eql(replacement)) { if (diag) |d| { d.reset(); d.object_id = id; d.setDetail("{f}", .{replacement}); } return error.ReplacementNotFound; } return err; } fn vtableReadHeader(ptr: *anyopaque, io: Io, id: Id, diag: ?*Diagnostics) ObjectDatabase.ReadError!object.Header { const r: *Replacements = @ptrCast(@alignCast(ptr)); const t = try r.target(id, diag); return r.inner.readHeader(io, t, diag) catch |err| missing(err, id, t, diag); } fn vtableRead(ptr: *anyopaque, io: Io, gpa: Allocator, id: Id, diag: ?*Diagnostics) ObjectDatabase.ReadError!ObjectDatabase.Object { const r: *Replacements = @ptrCast(@alignCast(ptr)); const t = try r.target(id, diag); return r.inner.read(io, gpa, t, diag) catch |err| missing(err, id, t, diag); } fn vtableStream(ptr: *anyopaque, io: Io, gpa: Allocator, id: Id, out: *Io.Writer, diag: ?*Diagnostics) ObjectDatabase.StreamError!object.Header { const r: *Replacements = @ptrCast(@alignCast(ptr)); const t = try r.target(id, diag); return r.inner.stream(io, gpa, t, out, diag) catch |err| missing(err, id, t, diag); } // -- tests ----------------------------------------------------------------------- const testing = std.testing; const fixture = @import("testing/fixture.zig"); const Repository = @import("Repository.zig"); const discover = @import("discover.zig"); test "replace ref names are read as git reads them" { const f: Format = .sha1; const hex = "78981922613b2afb6025042ff6bd878ac1994e85"; const id = try Id.parseHex(f, hex); try testing.expect(parseName(f, hex).?.eql(id)); try testing.expect(parseName(f, "x/" ++ hex).?.eql(id)); try testing.expect(parseName(f, "x/y/" ++ hex).?.eql(id)); try testing.expect(parseName(f, hex ++ "zz").?.eql(id)); try testing.expect(parseName(f, "78981922613B2AFB6025042FF6BD878AC1994E85").?.eql(id)); try testing.expectEqual(null, parseName(f, "xy" ++ hex)); try testing.expectEqual(null, parseName(f, hex[0..39])); try testing.expectEqual(null, parseName(f, "78/" ++ hex[2..])); try testing.expectEqual(null, parseName(f, "foo")); try testing.expectEqual(null, parseName(.sha256, hex)); } fn openRepo(repo: *fixture.Repo) !Repository { return Repository.discoverAndOpen(testing.allocator, testing.io, .{ .start = repo.path, .ownership = if (discover.effectiveUid()) |uid| .{ .uid = uid } else null, }, .{ .environment = &repo.env }, null); } test "objects read through replacements as git reads them" { const gpa = testing.allocator; var repo = try fixture.Repo.init(gpa, testing.io); defer repo.deinit(); const a = try repo.hashObject("blob", "a\n"); defer gpa.free(a); const b = try repo.hashObject("blob", "b\n"); defer gpa.free(b); const c = try repo.hashObject("blob", "c\n"); defer gpa.free(c); try repo.git(&.{ "replace", a, b }); try repo.git(&.{ "replace", b, c }); { var r = try openRepo(&repo); defer r.close(testing.io); var o = try r.objects().read(testing.io, gpa, try .parseHex(repo.object_format, a), null); defer o.deinit(gpa); const theirs = try repo.output(&.{ "cat-file", "-p", a }); defer gpa.free(theirs); try testing.expectEqualStrings(theirs, o.data); try testing.expectEqualStrings("c\n", o.data); } // A loop is too deep. const name = try std.fmt.allocPrint(gpa, "refs/replace/{s}", .{c}); defer gpa.free(name); try repo.git(&.{ "update-ref", name, a }); var r = try openRepo(&repo); defer r.close(testing.io); var diag: Diagnostics = .{}; const id: Id = try .parseHex(repo.object_format, a); try testing.expectError(error.ReplaceDepthTooHigh, r.objects().read(testing.io, gpa, id, &diag)); try testing.expect(diag.object_id.?.eql(id)); }