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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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/<id>` replaces `<id>`). 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 <id>". 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));}