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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Grafts and the shallow boundary: commits given parents other than the//! ones they were made with. gitrepository-layout(5) describes both files;//! the rest was found by experiment.//!//! - **`info/grafts`** (or the file `GIT_GRAFT_FILE` names) holds a line//! per commit: its object ID and then the object IDs of its new parents,//! each after one blank character. Blank space at the end of a line is//! ignored, and so are empty lines and lines beginning with `#`. Any//! other line is "bad graft data" and passed over, and a second line for//! the same commit is "duplicate graft data" and passed over too. git//! still reads the file but calls it deprecated, which `Warning` and//! `graft_file` let a caller say as git does.//! - **`shallow`** (or the file `GIT_SHALLOW_FILE` names) lists the//! commits a shallow clone stops at, one per line, each read as having//! no parents. A line must begin with an object ID written whole, and//! anything after it is ignored; any other line stops git ("bad shallow//! line"). A shallow commit is shallow even when a graft names it.//!//! Neither changes what a commit object holds: `git cat-file -p` shows the//! parents it was made with. They change the parents the walk follows and//! `%P` shows, and `<rev>^` and `<rev>~<n>`.//!//! git does not read the commit-graph when the shallow file exists, even//! empty, or when the grafts file gives at least one commit new parents;//! `commitGraphUsable` says so.//!//! Reading the files is `load`'s; `parseGrafts` and `parseShallow` work on//! bytes, with no I/O.
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 Grafts = @This();
/// Where parent lists and warnings live.arena: std.heap.ArenaAllocator,format: Format,/// Each grafted or shallow commit.map: std.AutoHashMapUnmanaged([object.max_byte_length]u8, Entry) = .empty,/// What git says of the grafts file as it reads it, in order.warnings: std.ArrayList(Warning) = .empty,/// Whether a grafts file was read at all, for git's hint that grafts are/// deprecated.graft_file: bool = false,/// Whether a shallow file was found, which makes the repository shallow/// even when it lists nothing.shallow_file: bool = false,/// How many commits the grafts file gave new parents.grafted: usize = 0,/// Whether the warnings have been written, for a caller that writes them/// once, whoever reads the grafts first.reported: bool = false,
/// What a commit's parents are taken to be.pub const Entry = struct { parents: []const Id, /// Whether it is on the shallow boundary. shallow: bool,};
/// Something wrong with a line of the grafts file, which git says and/// passes over.pub const Warning = union(enum) { /// "bad graft data: <line>", the line without its blank end. bad: []const u8, /// "duplicate graft data: <line>", the line without its blank end. duplicate: []const u8,};
/// Nothing grafted, nothing shallow.pub fn init(gpa: Allocator, format: Format) Grafts { return .{ .arena = .init(gpa), .format = format };}
/// Frees everything.pub fn deinit(g: *Grafts) void { g.arena.deinit(); g.* = undefined;}
/// What `id`'s parents are taken to be, if they are not its own.pub fn lookup(g: *const Grafts, id: Id) ?Entry { return g.map.get(id.bytes);}
/// Whether nothing is grafted or shallow.pub fn isEmpty(g: *const Grafts) bool { return g.map.count() == 0;}
/// Whether the repository is shallow: whether there is a shallow file.pub fn isShallow(g: *const Grafts) bool { return g.shallow_file;}
/// Whether git would read the commit-graph alongside these: only when/// there is no shallow file and nothing is grafted.pub fn commitGraphUsable(g: *const Grafts) bool { return !g.shallow_file and g.grafted == 0;}
/// git's hint that grafts are deprecated, which it gives whenever it reads/// a grafts file unless `advice.graftFileDeprecated` is false.pub const deprecation_hint = \\hint: Support for <GIT_DIR>/info/grafts is deprecated \\hint: and will be removed in a future Git version. \\hint: \\hint: Please use "git replace --convert-graft-file" \\hint: to convert the grafts into replace refs. \\hint: \\hint: Turn this message off by running \\hint: "git config set advice.graftFileDeprecated false" \\;
/// Writes what git says as it reads the grafts file: the deprecation hint/// when `hint` asks for it and there was a file, and each bad or duplicate/// line.pub fn writeWarnings(g: *const Grafts, w: *Io.Writer, hint: bool) Io.Writer.Error!void { if (hint and g.graft_file) try w.writeAll(deprecation_hint); for (g.warnings.items) |warning| switch (warning) { .bad => |line| try w.print("error: bad graft data: {s}\n", .{line}), .duplicate => |text| try w.print("error: duplicate graft data: {s}\n", .{text}), };}
/// Blank space as git counts it: space, tab, carriage return and line/// feed, and not the vertical tab or form feed.fn isBlank(c: u8) bool { return c == ' ' or c == '\t' or c == '\r' or c == '\n';}
/// Reads the contents of a grafts file, adding what it grafts and the/// warnings git gives.pub fn parseGrafts(g: *Grafts, bytes: []const u8) Allocator.Error!void { const a = g.arena.allocator(); const n = g.format.hexLength(); var lines = std.mem.splitScalar(u8, bytes, '\n'); while (lines.next()) |raw| { var line = raw; while (line.len != 0 and isBlank(line[line.len - 1])) line = line[0 .. line.len - 1]; if (line.len == 0 or line[0] == '#') continue; const ids = parseGraftLine(a, g.format, line, n) catch |err| switch (err) { error.OutOfMemory => |e| return e, error.Bad => { try g.warnings.append(a, .{ .bad = try a.dupe(u8, line) }); continue; }, }; const gop = try g.map.getOrPut(a, ids[0].bytes); if (gop.found_existing) { // A shallow commit stays shallow without a word. if (!gop.value_ptr.shallow) try g.warnings.append(a, .{ .duplicate = try a.dupe(u8, line) }); continue; } gop.value_ptr.* = .{ .parents = ids[1..], .shallow = false }; g.grafted += 1; }}
fn parseGraftLine(a: Allocator, format: Format, line: []const u8, n: usize) error{ Bad, OutOfMemory }![]Id { if ((line.len + 1) % (n + 1) != 0) return error.Bad; const count = (line.len + 1) / (n + 1); const ids = try a.alloc(Id, count); for (ids, 0..) |*id, i| { const at = i * (n + 1); if (i != 0 and !isBlank(line[at - 1])) return error.Bad; id.* = Id.parseHex(format, line[at..][0..n]) catch return error.Bad; } return ids;}
/// Why the shallow file could not be read:////// - `BadShallowLine`: a line that does not begin with an object ID; git's/// "bad shallow line: <line>", where the line keeps its line feed. The/// diagnostic's detail is the line so.pub const ShallowError = Allocator.Error || error{BadShallowLine};
/// Reads the contents of a shallow file, making each commit it lists/// shallow.pub fn parseShallow(g: *Grafts, bytes: []const u8, diag: ?*Diagnostics) ShallowError!void { const a = g.arena.allocator(); const n = g.format.hexLength(); g.shallow_file = true; var at: usize = 0; while (at < bytes.len) { const end = if (std.mem.findScalarPos(u8, bytes, at, '\n')) |e| e + 1 else bytes.len; const line = bytes[at..end]; at = end; const id = if (line.len >= n) Id.parseHex(g.format, line[0..n]) catch null else null; const shallow = id orelse { if (diag) |d| { d.reset(); d.setDetail("{s}", .{line}); } return error.BadShallowLine; }; const gop = try g.map.getOrPut(a, shallow.bytes); if (gop.found_existing and !gop.value_ptr.shallow) g.grafted -= 1; gop.value_ptr.* = .{ .parents = &.{}, .shallow = true }; }}
/// Where the files are, relative to the directory given to `load`; null/// for none.pub const Paths = struct { /// `GIT_GRAFT_FILE`, or `info/grafts` in the common directory. grafts: ?[]const u8, /// `GIT_SHALLOW_FILE`, or `shallow` in the common directory. shallow: ?[]const u8,};
/// Why the files could not be read: the shallow file's errors, or a file/// that is there and could not be read (`GraftsUnreadable`,/// `ShallowUnreadable`; the diagnostic's path names it).pub const LoadError = ShallowError || Io.Cancelable || error{ GraftsUnreadable, ShallowUnreadable };
/// Reads the shallow file and then the grafts file, each if it is there/// and is a file.pub fn load(gpa: Allocator, io: Io, dir: Io.Dir, format: Format, paths: Paths, diag: ?*Diagnostics) LoadError!Grafts { var g = init(gpa, format); errdefer g.deinit(); if (paths.shallow) |p| if (p.len != 0) { if (try readFile(gpa, io, dir, p, diag, error.ShallowUnreadable)) |bytes| { defer gpa.free(bytes); try g.parseShallow(bytes, diag); } }; if (paths.grafts) |p| if (p.len != 0) { if (try readFile(gpa, io, dir, p, diag, error.GraftsUnreadable)) |bytes| { defer gpa.free(bytes); g.graft_file = true; try g.parseGrafts(bytes); } }; return g;}
fn readFile(gpa: Allocator, io: Io, dir: Io.Dir, path: []const u8, diag: ?*Diagnostics, unreadable: LoadError) LoadError!?[]u8 { return dir.readFileAlloc(io, path, gpa, .unlimited) catch |err| switch (err) { error.FileNotFound, error.IsDir, error.NotDir => null, error.OutOfMemory => |e| e, error.Canceled => |e| e, else => { if (diag) |d| { d.reset(); d.setPath(&.{path}); d.setDetail("{t}", .{err}); } return unreadable; }, };}
// -- tests -----------------------------------------------------------------------
const testing = std.testing;const fuzz_input = @import("testing/fuzz.zig");
const h1 = "c7a2a6c8edb31c60021517c27fcbbb3c2f96360f";const h3 = "a227627cbba230b7b34466693eba5a4444ad4ff2";
fn graftsOf(text: []const u8) !Grafts { var g = init(testing.allocator, .sha1); errdefer g.deinit(); try g.parseGrafts(text); return g;}
test "graft lines are read as git reads them" { const id1: Id = try .parseHex(.sha1, h1); const id3: Id = try .parseHex(.sha1, h3); const good = [_][]const u8{ h1 ++ " " ++ h3 ++ "\n", h1 ++ "\t" ++ h3, h1 ++ " " ++ h3 ++ " \r\n", "# comment\n\n" ++ h1 ++ " " ++ h3 ++ "\n", "C7A2A6C8EDB31C60021517C27FCBBB3C2F96360F " ++ h3 ++ "\n", }; for (good) |text| { var g = try graftsOf(text); defer g.deinit(); try testing.expectEqual(0, g.warnings.items.len); const e = g.lookup(id1).?; try testing.expectEqual(1, e.parents.len); try testing.expect(e.parents[0].eql(id3)); try testing.expect(!g.commitGraphUsable()); } const bad = [_][]const u8{ "zz", h1 ++ " " ++ h3, " " ++ h1, h1 ++ "zz", h1 ++ "\x0b" ++ h3, h1 ++ ";" ++ h3 }; for (bad) |text| { var g = try graftsOf(text); defer g.deinit(); try testing.expectEqual(1, g.warnings.items.len); try testing.expect(g.isEmpty()); try testing.expect(g.commitGraphUsable()); } // A root, and a duplicate passed over. var g = try graftsOf(h1 ++ " " ++ h3 ++ "\n" ++ h1 ++ "\n"); defer g.deinit(); try testing.expectEqual(1, g.lookup(id1).?.parents.len); try testing.expectEqualStrings(h1, g.warnings.items[0].duplicate);}
test "shallow lines are read as git reads them" { const id1: Id = try .parseHex(.sha1, h1); for ([_][]const u8{ h1 ++ "\n", h1, h1 ++ " " ++ h3 ++ "\n", h1 ++ "zz\n", h1 ++ "\r\n", h1 ++ "\n" ++ h1 }) |text| { var g = init(testing.allocator, .sha1); defer g.deinit(); try g.parseShallow(text, null); try testing.expect(g.lookup(id1).?.shallow); try testing.expectEqual(0, g.lookup(id1).?.parents.len); } for ([_][]const u8{ "\n", "zz\n", " " ++ h1 ++ "\n", "# x\n" ++ h1, h1[0..39] }) |text| { var g = init(testing.allocator, .sha1); defer g.deinit(); var diag: Diagnostics = .{}; try testing.expectError(error.BadShallowLine, g.parseShallow(text, &diag)); } var g = init(testing.allocator, .sha1); defer g.deinit(); try g.parseShallow("", null); try testing.expect(g.isShallow()); try testing.expect(!g.commitGraphUsable()); // A shallow commit stays shallow, whatever the grafts say. try g.parseShallow(h1 ++ "\n", null); try g.parseGrafts(h1 ++ " " ++ h3 ++ "\n"); try testing.expect(g.lookup(id1).?.shallow); try testing.expectEqual(0, g.warnings.items.len);}
test "fuzz: grafts and shallow files" { try fuzz_input.fuzz({}, struct { fn one(_: void, smith: *testing.Smith) anyerror!void { var buffer: [512]u8 = undefined; const text = fuzz_input.bytes(smith, &buffer); for ([_]Format{ .sha1, .sha256 }) |f| { var g = init(testing.allocator, f); defer g.deinit(); try g.parseGrafts(text); g.parseShallow(text, null) catch |err| switch (err) { error.BadShallowLine => {}, else => |e| return e, }; var it = g.map.iterator(); while (it.next()) |e| for (e.value_ptr.parents) |p| try testing.expectEqual(f, p.object_format); } } }.one, .{ .corpus = &.{ @embedFile("fuzz/grafts"), @embedFile("fuzz/shallow") } });}