// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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 `^` and `~`. //! //! 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: ", the line without its blank end. bad: []const u8, /// "duplicate graft data: ", 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 /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: ", 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") } }); }