//! One file's annotation, as `zit annotate [--partial]` attributes it: the
//! version chain over the target's ancestry (following renames unless told
//! not to), each line credited to the check-in that introduced it. The CLI
//! renders the answer as text and the daemon's `annotate` as JSON; both call
//! `annotate` here, so the attribution cannot differ between them. What the
//! CLI prints on a refusal is the caller's: this returns which refusal.
const std = @import("std");
const artifact = @import("../artifact.zig");
const logstore = @import("../logstore.zig");
const repostate = @import("../repostate.zig");
const repoview = @import("../repoview.zig");
const repocache = @import("../repocache.zig");
const thinned = @import("../thinned.zig");
const merge3 = @import("../merge3.zig");
const engine = @import("../annotate.zig");
const Repository = @import("../repository.zig").Repository;
const filelog = @import("filelog.zig");
const NameId = filelog.NameId;
const NamedRid = filelog.NamedRid;
const NameTable = filelog.NameTable;
/// Which of PATH's ancestors actually CHANGED it, read from the derived
/// cache's record body in one sequential pass. Fossil answers this
/// from its mlink table; the equivalent here is the changed-file list
/// every record carries. A record's list is a pure function of
/// content-addressed artifacts — the diff of a manifest against its primary
/// parent over their shared baseline — so even a cache older than the log is
/// CORRECT for the check-ins it covers; coverage, not freshness, is the only
/// question, and `covered` answers it. Null when there is no usable cache:
/// the caller then reads a manifest per ancestor, exactly as before. Annotate
/// asks over PATH's ancestry; grep asks over the whole store.
pub const AnnotateTouched = struct {
/// Check-ins with a record whose file facts are known.
covered: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
/// The rows of covered records for the names asked about, by check-in
/// and name.
touched: std.AutoHashMapUnmanaged(NamedRid, TouchedRow) = .empty,
/// The names whose rows were kept. A name outside it (an old name a
/// rename at an uncovered check-in reached) has no rows here even where
/// the record changed it, so it must be read from the manifest.
names: std.AutoHashMapUnmanaged(NameId, void) = .empty,
/// Whether the record body answers `name` at check-in `rid`: absence of
/// a row then means "unchanged against the primary parent".
pub fn answers(self: *const AnnotateTouched, rid: repostate.Rid, name: NameId) bool {
return self.covered.contains(rid) and self.names.contains(name);
}
pub fn deinit(self: *AnnotateTouched, gpa: std.mem.Allocator) void {
self.covered.deinit(gpa);
self.names.deinit(gpa);
var it = self.touched.valueIterator();
while (it.next()) |row| if (row.hash) |b| gpa.free(b);
self.touched.deinit(gpa);
}
};
/// One record row: the hash the check-in gives the file (null when it
/// deletes it) and, on a rename, the name it renamed the file from.
const TouchedRow = struct { hash: ?[]u8, prior: ?NameId };
/// `AnnotateTouched` for `path` and, when `follow`, every name a rename
/// chain reaches from it: the record body's rename rows are read first and
/// closed over from `path`, so the rows of every older name are kept in the
/// same pass. The closure is by name, which may keep a few rows no walk asks
/// for; the walk asks by check-in and name, so an unrelated file that once
/// held one of these names is never mistaken for this one.
pub fn annotateTouchedFromCache(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
names: *NameTable,
path: []const u8,
follow: bool,
) ?AnnotateTouched {
var raw = repocache.loadForExtension(io, repo.root_dir) orelse return null;
defer raw.deinit();
var wanted: std.StringHashMapUnmanaged(void) = .empty;
defer wanted.deinit(gpa);
wanted.put(gpa, path, {}) catch return null;
if (follow) {
var edges: std.ArrayListUnmanaged([2][]const u8) = .empty;
defer edges.deinit(gpa);
var it = repoview.RecordIter.init(raw.body);
while (true) {
var rec = (it.next(gpa) catch break) orelse break;
defer rec.deinit(gpa);
if (rec.kind != .manifest or !rec.files_known) continue;
for (rec.files.items) |f| {
if (f.prior) |old| edges.append(gpa, .{ f.path, old }) catch return null;
}
}
// Each pass adds at least one name or ends, and there are no more
// names than edges.
var grew = true;
while (grew) {
grew = false;
for (edges.items) |e| {
if (wanted.contains(e[0]) and !wanted.contains(e[1])) {
wanted.put(gpa, e[1], {}) catch return null;
grew = true;
}
}
}
}
var out: AnnotateTouched = .{};
errdefer out.deinit(gpa);
var wanted_it = wanted.keyIterator();
while (wanted_it.next()) |w| {
const id = names.intern(gpa, w.*) catch return null;
out.names.put(gpa, id, {}) catch return null;
}
var it = repoview.RecordIter.init(raw.body);
while (true) {
var rec = (it.next(gpa) catch {
// A short or undecodable tail: keep what decoded — every kept
// record is still individually correct — and let the uncovered
// rids take the manifest fallback.
break;
}) orelse break;
defer rec.deinit(gpa);
if (rec.kind != .manifest or !rec.files_known) continue;
const rid = repo.lookup(rec.hash) orelse continue;
out.covered.put(gpa, rid, {}) catch return null;
for (rec.files.items) |f| {
if (!wanted.contains(f.path)) continue;
const name = names.intern(gpa, f.path) catch return null;
const prior: ?NameId = if (follow and f.prior != null) (names.intern(gpa, f.prior.?) catch return null) else null;
const hash: ?[]u8 = if (f.hash) |h| (gpa.dupe(u8, h) catch return null) else null;
out.touched.put(gpa, .{ .rid = rid, .name = name }, .{ .hash = hash, .prior = prior }) catch {
if (hash) |h| gpa.free(h);
return null;
};
}
}
return out;
}
/// The narrow read-failure set the annotate content path collapses the
/// `Repository` file/blob errors into, so the renderer switches once.
pub const ReadError = error{ OutOfMemory, Storage, Corrupt };
/// PATH's effective content in the check-in `rid` (artifact §8 via
/// `Repository.fileAt` + `blob`), duplicated into `arena`, or null when PATH is
/// absent there (never added, or a delta delete). Transient `gpa` allocations
/// (the F card, the verified blob) are freed before returning; only the arena
/// copy survives, owned by the caller's arena.
fn annotateContentAt(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
cache: *logstore.PlaintextCache,
memo: *?*Repository.BaselineMemo,
rid: repostate.Rid,
path: []const u8,
absent_hash: *?[]const u8,
) ReadError!?[]const u8 {
const maybe_card = repo.fileAtMemo(gpa, rid, path, memo, cache) catch |e| return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.CannotRead => error.Storage,
// NotARepo/Corrupt/NotManifest/BaselineUnresolvable all
// mean this reachable check-in cannot be reconstructed — corrupt.
else => error.Corrupt,
};
const card = maybe_card orelse return null;
defer Repository.freeFileCard(gpa, card);
// The effective-set iterator suppresses delete markers, so a returned card
// always carries a hash (§4.7.6).
const content = try annotateContentByHash(gpa, arena, repo, cache, card.hash.?);
// PATH is in this check-in but its blob is not here: say which blob, so the
// caller can tell an absence from "not added yet".
if (content == null) absent_hash.* = try arena.dupe(u8, card.hash.?);
return content;
}
/// The blob `hash` names, duplicated into `arena`, or null when the store
/// lacks it. Through the plaintext memo: adjacent versions of PATH are deltas
/// over shared bases, and without the memo every version re-walks its whole
/// chain -- on SQLite's shell.c.in that walk, not the diff, was most of the
/// remaining time once the diff core went O(ND).
fn annotateContentByHash(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
cache: *logstore.PlaintextCache,
hash: []const u8,
) ReadError!?[]const u8 {
const bytes = (repo.blobCached(gpa, hash, cache) catch |e| return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.CannotRead, error.ImportPending => error.Storage,
error.NotARepo, error.Corrupt => error.Corrupt,
}) orelse return null;
defer gpa.free(bytes);
return try arena.dupe(u8, bytes);
}
/// One check-in in PATH's ancestry, in the processing order the repostate
/// topological linearization fixes (parent before child; B-annotate-5). `hash`
/// borrows the view's identity space (stable for the view's lifetime).
const AnnotateEntry = struct {
rid: repostate.Rid,
mtime: repostate.Mtime,
hash: []const u8,
};
pub const Options = struct {
/// Look back at most this many check-ins (`-n`): the window is the tail
/// of the linearization, and a parent outside it reads as the origin.
limit: ?usize = null,
follow: bool = true,
/// A version purged or shunned is a gap the annotation reads through,
/// rather than a refusal; lines claimed across it are approximate.
partial: bool = false,
};
/// How a blob the store names but lacks sits against its cone: `elided`
/// when the cone or horizon leaves it out, `horizon` when the store has a
/// horizon at all. Null from the hook when the store has no cone.
pub const Elision = struct { elided: bool, horizon: bool };
/// The cone classifier lives with the sync machinery; the caller supplies it.
pub const ElisionHook = struct {
ctx: ?*anyopaque = null,
of: *const fn (ctx: ?*anyopaque, gpa: std.mem.Allocator, hash: []const u8) ?Elision,
};
/// Why there is no annotation. Each names what the CLI's message needs.
pub const Refusal = union(enum) {
/// PATH is not in the target check-in.
not_tracked,
/// The target's content is binary: provenance is undefined over it.
binary,
/// A version's content was purged or shunned, and `partial` was not
/// asked (or it is the target itself). Exit 8.
absent: struct { why: enum { purged, shunned }, name: []const u8, rid: repostate.Rid },
/// A version's content is gone for no recorded reason. Exit 1.
missing: struct { name: []const u8, rid: repostate.Rid },
/// The cone or horizon elides `name`'s blob `hash`. Exit 8.
elided: struct { name: []const u8, hash: []const u8 },
/// A rename crosses this store's horizon: `name` (the old name) lies
/// below it, and blob `hash` is the renamed file's. Exit 8.
elided_below: struct { name: []const u8, hash: []const u8 },
};
pub const Origin = struct {
rid: repostate.Rid,
/// The origin's event time, milliseconds since the epoch.
mtime: repostate.Mtime,
/// Claimed across a purged or shunned version (`--partial`'s `~`).
approx: bool,
};
pub const Annotation = struct {
/// The target's content; `lines` index into it.
target: []const u8,
lines: []const merge3.Line,
/// One per line.
origins: []const Origin,
/// Some version was read through as a gap.
any_gap: bool,
/// Line `i`'s bytes, its line break included.
pub fn text(self: *const Annotation, i: usize) []const u8 {
return merge3.lineBytes(self.target, self.lines[i]);
}
};
pub const Result = union(enum) {
ok: Annotation,
refused: Refusal,
};
/// Annotate `path` as of check-in `cur`, whose ancestry `all_rids` is
/// (`Repository.ancestryLinearized`: oldest first, `cur` last). Everything
/// returned lives on `arena`; `gpa` carries the repository's own reads.
///
/// Following renames, a node is a check-in under a NAME: below the check-in
/// whose card renamed the file, its parents are read under the old name, so
/// lines from before the rename keep the check-ins that wrote them. The same
/// check-in can be a node under two names (a rename on a branch merged back
/// reaches the fork point under both); a parent that lacks the old name
/// holds nothing under it and so attributes nothing, which is Fossil's
/// "treat as new" on that side.
///
/// With `partial`, a version purged or shunned is a gap rather than a
/// refusal: its children read through it to its surviving parents, and a
/// line a child claims across a gap is marked approximate, since the absent
/// version may have been the one that wrote it.
pub fn annotate(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
io: std.Io,
repo: *Repository,
path: []const u8,
cur: repostate.Rid,
all_rids: []const repostate.Rid,
opts: Options,
hook: ElisionHook,
) ReadError!Result {
const follow = opts.follow;
var plaintext: logstore.PlaintextCache = .{};
defer plaintext.deinit(gpa);
// Consecutive versions of PATH sit in delta check-ins over one baseline;
// the memo parses that baseline once instead of once per version.
var baseline: ?*Repository.BaselineMemo = null;
defer if (baseline) |b| b.deinit(gpa);
var names = NameTable.init(gpa);
defer names.deinit(gpa);
const path_name = try names.intern(gpa, path);
// -n N: look back N check-ins. The linearization is oldest first
// with the target last, so the window is its tail; a parent outside it
// reads as the file's origin.
const ordered_rids = if (opts.limit) |n| all_rids[all_rids.len - @min(n, all_rids.len) ..] else all_rids;
var entries: std.ArrayList(AnnotateEntry) = .empty;
for (ordered_rids) |rid| {
try entries.append(arena, .{
.rid = rid,
.mtime = repo.state().mtimeOf(rid) orelse 0,
.hash = repo.hashOf(rid),
});
}
// rid → position in processing order, so parent links resolve to indices.
var index_of: std.AutoHashMapUnmanaged(repostate.Rid, u32) = .empty;
for (entries.items, 0..) |e, i| try index_of.put(arena, e.rid, @intCast(i));
// Which ancestors changed PATH, from the cache's record body.
// Reading a manifest per ancestor made annotate proportional to the
// store: SQLite's history is ~36k check-ins of which ~200 touch a given
// file, and fossil (whose mlink table is the same precomputation) answers
// in milliseconds where this took minutes. With the set in hand, an
// ancestor that did not change PATH inherits its primary parent's
// content outright — the record's diff IS against the primary parent, so
// "not listed" means "byte-equal there" — a changed ancestor reads the blob
// the record names, and only an uncovered one opens its manifest.
var touched_opt = annotateTouchedFromCache(gpa, io, repo, &names, path, follow);
defer if (touched_opt) |*t| t.deinit(gpa);
// The names each position is read under, from the target down: a
// child's name passes to its parents, switched to the old name at the
// check-in that renamed the file. Children follow their parents in the
// linearization, so walking it backwards meets every child of a
// position before the position itself.
const names_at = try arena.alloc(std.ArrayListUnmanaged(NameId), entries.items.len);
for (names_at) |*l| l.* = .empty;
var parent_name_of: std.AutoHashMapUnmanaged(NamedRid, NameId) = .empty;
// Nodes a rename reached: an absence there is the old name's, and if the
// store elided its content the honest answer is to say so.
var crossed: std.AutoHashMapUnmanaged(NamedRid, void) = .empty;
const cur_pos = index_of.get(cur).?; // cur is self-inclusive in its ancestry
try names_at[cur_pos].append(arena, path_name);
var k = entries.items.len;
while (k > 0) {
k -= 1;
const rid = entries.items[k].rid;
for (names_at[k].items) |n| {
const prior: ?NameId = if (!follow) null else blk: {
if (touched_opt) |*t| if (t.answers(rid, n)) {
// A rename is always a row, so no row means no rename.
break :blk if (t.touched.get(.{ .rid = rid, .name = n })) |row| row.prior else null;
};
const card = (repo.fileAtMemo(gpa, rid, names.get(n), &baseline, &plaintext) catch |e| return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.CannotRead => error.Storage,
else => error.Corrupt,
}) orelse break :blk null;
defer Repository.freeFileCard(gpa, card);
break :blk if (card.old_name) |old| try names.intern(gpa, old) else null;
};
const pn = prior orelse n;
try parent_name_of.put(arena, .{ .rid = rid, .name = n }, pn);
var it = repo.state().parentsOf(rid);
while (it.next()) |p| {
// A parent the store does not hold, across a rename, is
// history below its horizon: the old name's lines cannot be
// read, and attributing them to the rename would be wrong. A
// parent the -n window leaves out is the file's origin by
// request.
const held = repo.state().mtimeOf(p) != null;
const in_window = index_of.get(p);
if (prior != null and (!held or (in_window == null and std.mem.indexOfScalar(repostate.Rid, all_rids, p) == null))) {
if (try elidedBelow(gpa, arena, repo, hook, rid, names.get(n))) |hash|
return .{ .refused = .{ .elided_below = .{ .name = try arena.dupe(u8, names.get(pn)), .hash = hash } } };
}
const pi = in_window orelse continue;
if (std.mem.indexOfScalar(NameId, names_at[pi].items, pn) == null) {
try names_at[pi].append(arena, pn);
}
if (prior != null) try crossed.put(arena, .{ .rid = p, .name = pn }, {});
}
}
}
// One engine node per (position, name), in position order so parents
// still precede children.
var node_of: std.AutoHashMapUnmanaged(NamedRid, u32) = .empty;
var node_keys: std.ArrayList(NamedRid) = .empty;
var node_pos: std.ArrayList(u32) = .empty;
for (names_at, 0..) |l, pos| {
for (l.items) |n| {
const key: NamedRid = .{ .rid = entries.items[pos].rid, .name = n };
try node_of.put(arena, key, @intCast(node_keys.items.len));
try node_keys.append(arena, key);
try node_pos.append(arena, @intCast(pos));
}
}
// Build the pure engine's node view: content + §3.2 primary/merge parents.
const nodes = try arena.alloc(engine.Node, node_keys.items.len);
// Whether this store carries a cone or horizon, asked once and only if
// some version's content turns out to be absent.
var has_cone: ?bool = null;
// partial: the blob of each node absent by policy (null otherwise), and
// whether a node's parents were read through such a gap.
const gap = try arena.alloc(?[]const u8, node_keys.items.len);
@memset(gap, null);
const bridged = try arena.alloc(bool, node_keys.items.len);
@memset(bridged, false);
var any_gap = false;
for (node_keys.items, 0..) |key, i| {
const pn = parent_name_of.get(key).?;
const primary_rid = repo.state().primaryParentOf(key.rid);
const primary: ?u32 = if (primary_rid) |pr| node_of.get(.{ .rid = pr, .name = pn }) else null;
const known: ?struct { c: ?[]const u8, hash: ?[]const u8 } = blk: {
const t = &(touched_opt orelse break :blk null);
if (!t.answers(key.rid, key.name)) break :blk null; // no record for it: open the manifest
if (t.touched.get(key)) |row| {
// Changed here: the record names the blob (a delete names none).
const h = row.hash orelse break :blk .{ .c = null, .hash = null };
const c = try annotateContentByHash(gpa, arena, repo, &plaintext, h);
break :blk .{ .c = c, .hash = h };
}
// Unchanged here, and a rename is always a row, so the primary
// parent holds the same content under the same name.
if (pn != key.name) break :blk null;
const pi = primary orelse break :blk null; // outside -n window
std.debug.assert(pi < i); // parents precede children
// Unchanged from a gap is the same absent blob.
break :blk .{ .c = nodes[pi].content, .hash = gap[pi] };
};
var absent_hash: ?[]const u8 = null;
const content = if (known) |kn| blk: {
if (kn.c == null) absent_hash = kn.hash;
break :blk kn.c;
} else try annotateContentAt(gpa, arena, repo, &plaintext, &baseline, key.rid, names.get(key.name), &absent_hash);
if (content == null) if (absent_hash) |h| {
// PATH is in this version but its content is not here. Handing the
// engine a null would read as "not added yet" and credit every
// line it carried to the next version that holds content -- a
// wrong answer reported as a success. Refuse unless the absence
// is the store's own cut, below which the origin answers.
// partial bridges a gap anywhere but the annotated version.
const may_bridge = opts.partial and i != node_of.get(.{ .rid = cur, .name = path_name }).?;
switch (try absentVerdict(gpa, io, repo, &has_cone, h, may_bridge)) {
.origin => {},
.gap => {
gap[i] = h;
any_gap = true;
},
.purged, .shunned, .missing => |v| {
const name = try arena.dupe(u8, names.get(key.name));
return .{ .refused = switch (v) {
.purged => .{ .absent = .{ .why = .purged, .name = name, .rid = key.rid } },
.shunned => .{ .absent = .{ .why = .shunned, .name = name, .rid = key.rid } },
else => .{ .missing = .{ .name = name, .rid = key.rid } },
} };
},
}
};
if (content == null and crossed.contains(key)) {
// Across a rename, an old version this store elides ends the
// annotation with the elision message rather than handing its
// lines to the check-in that renamed the file.
const hash: ?[]const u8 = if (known) |kn| kn.hash else null;
if (try elidedAt(gpa, arena, repo, hook, key.rid, names.get(key.name), hash)) |r| return .{ .refused = r };
}
var merges: std.ArrayList(u32) = .empty;
var it = repo.state().parentsOf(key.rid);
while (it.next()) |p| {
if (primary_rid != null and p == primary_rid.?) continue; // primary handled separately
const idx = node_of.get(.{ .rid = p, .name = pn }) orelse continue;
if (gap[idx] != null) {
// A gap node already reads through to its own surviving parents.
bridged[i] = true;
if (nodes[idx].primary) |gp| try merges.append(arena, gp);
try merges.appendSlice(arena, nodes[idx].merges);
} else try merges.append(arena, idx);
}
var through: ?u32 = primary;
if (primary) |pi| if (gap[pi] != null) {
bridged[i] = true;
through = nodes[pi].primary;
try merges.appendSlice(arena, nodes[pi].merges);
};
nodes[i] = .{ .content = content, .primary = through, .merges = merges.items };
}
const cur_index = node_of.get(.{ .rid = cur, .name = path_name }).?;
const target = nodes[cur_index].content orelse {
// Sparse-cone RFC section 6.4 / horizon draft section 5.5 -- a file
// the check-in names whose blob this store elides is an absence of a
// named class, not an untracked path.
if (try elidedAt(gpa, arena, repo, hook, cur, path, null)) |r| return .{ .refused = r };
return .{ .refused = .not_tracked };
};
// Provenance is undefined over binary content (B-annotate-9, the merge3 NUL
// predicate) — refuse before answering anything.
if (merge3.isBinary(target)) return .{ .refused = .binary };
const origin_nodes = try engine.attribute(arena, nodes, cur_index);
const lines = try merge3.splitLines(arena, target);
std.debug.assert(lines.len == origin_nodes.len);
const origins = try arena.alloc(Origin, lines.len);
for (origin_nodes, origins) |node, *o| {
const e = entries.items[node_pos.items[node]];
o.* = .{ .rid = e.rid, .mtime = e.mtime, .approx = bridged[node] };
}
return .{ .ok = .{ .target = target, .lines = lines, .origins = origins, .any_gap = any_gap } };
}
/// The committing user of check-in `rid` (its U card), on `arena`; null
/// when its manifest cannot be read or carries none.
pub fn userOf(gpa: std.mem.Allocator, arena: std.mem.Allocator, repo: *Repository, rid: repostate.Rid) error{OutOfMemory}!?[]const u8 {
var om = (repo.manifest(gpa, rid) catch null) orelse return null;
defer om.deinit(gpa);
const u = om.manifest.u orelse return null;
return try arena.dupe(u8, u.login);
}
const Verdict = enum {
/// The store's own cut: the version reads as the file's origin.
origin,
/// Purged or shunned, and the caller reads through it.
gap,
purged,
shunned,
missing,
};
/// A version names blob `hash`, and the blob is not here. Purged or shunned
/// is a refusal (or a gap when `bridge`); with a cone or horizon the
/// absence is the store's own cut, the history below it is answered by the
/// origin, and the version reads as the file's origin; otherwise it is
/// missing.
fn absentVerdict(
gpa: std.mem.Allocator,
io: std.Io,
repo: *Repository,
has_cone: *?bool,
hash: []const u8,
bridge: bool,
) error{OutOfMemory}!Verdict {
if (thinned.contains(gpa, io, repo.root_dir, hash)) return if (bridge) .gap else .purged;
shun: {
var buf: [logstore.max_hash_len]u8 = undefined;
if (hash.len != 40 and hash.len != 64) break :shun;
const digest = std.fmt.hexToBytes(buf[0 .. hash.len / 2], hash) catch break :shun;
const log = repo.logStore(gpa) catch break :shun;
if (log.isShunned(digest)) return if (bridge) .gap else .shunned;
}
if (has_cone.* == null) {
var cone = repocache.readStoreCone(gpa, io, repo.root_dir);
has_cone.* = cone != null;
if (cone) |*c| c.deinit(gpa);
}
if (has_cone.*.?) return .origin;
return .missing;
}
/// Sparse-cone RFC section 6.4 / horizon draft section 5.5: `name` in
/// check-in `rid` names a blob this store elides. The refusal, or null when
/// the content is absent for any other reason. `hash` is the blob when the
/// caller already knows it, else the check-in's card is read.
fn elidedAt(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
hook: ElisionHook,
rid: repostate.Rid,
name: []const u8,
hash: ?[]const u8,
) error{OutOfMemory}!?Refusal {
var card: ?artifact.FCard = null;
defer if (card) |c| Repository.freeFileCard(gpa, c);
const h = hash orelse blk: {
card = repo.fileAt(gpa, rid, name) catch null;
break :blk (card orelse return null).hash orelse return null;
};
const e = hook.of(hook.ctx, gpa, h) orelse return null;
if (!e.elided) return null;
return .{ .elided = .{ .name = try arena.dupe(u8, name), .hash = try arena.dupe(u8, h) } };
}
/// Check-in `rid` renamed `new_name` from an old name, and a parent that
/// held the old name lies below this store's horizon: the renamed file's
/// blob, which names the cut, or null when the store has no horizon (the
/// parent is then simply missing, and the walk goes on).
fn elidedBelow(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
hook: ElisionHook,
rid: repostate.Rid,
new_name: []const u8,
) error{OutOfMemory}!?[]const u8 {
const card = (repo.fileAt(gpa, rid, new_name) catch null) orelse return null;
defer Repository.freeFileCard(gpa, card);
const e = hook.of(hook.ctx, gpa, card.hash.?) orelse return null;
if (!e.horizon) return null;
return try arena.dupe(u8, card.hash.?);
}