//! One file's history, as `zit log CHECKIN PATH` lists it: the ancestry
//! walk restricted to the check-ins that change PATH (following renames
//! unless told not to), the file index that narrows it, and the per-row facts
//! `-F json` and the daemon's `log` report. The CLI and the daemon both
//! call this, so the two cannot list different check-ins or describe one
//! differently.
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 fileindex = @import("../fileindex.zig");
const Repository = @import("../repository.zig").Repository;
const feed = @import("feed.zig");
const digest_len = 32;
pub const LogWalkError = error{
OutOfMemory,
Storage,
Corrupt,
MissingRoot,
InvalidRoot,
InvalidTarget,
};
fn decodeFullId(id: []const u8, out: *[digest_len]u8) error{InvalidId}![]const u8 {
if (!artifact.validateName(id)) return error.InvalidId;
const decoded = out[0 .. id.len / 2];
_ = std.fmt.hexToBytes(decoded, id) catch return error.InvalidId;
// §3.6: `validateName` accepted a 40/64-hex token, so the decode is a whole
// 20/32-byte digest that fits the fixed buffer — never a truncated prefix.
std.debug.assert(decoded.len <= digest_len);
return decoded;
}
/// The optional PATH restriction for a `zit log CHECKIN PATH` walk (B-log-2).
/// `repo` answers the one general-purpose §8 effective-set query (`fileAt`) the
/// change predicate consumes (B-log-5); `path` is the exact repo-relative name.
pub const LogPathFilter = struct {
repo: *Repository,
path: []const u8,
/// Follow renames: below a check-in whose card for the followed name
/// carries an old name, the walk continues under the old name. False is
/// `--no-follow`, the name-literal history.
follow: bool = true,
/// The file index, when it can be trusted. When present the predicate
/// starts as a set membership test and a check-in the index does not name
/// is skipped without reading anything; when null the walk computes the
/// same answer by reconstruction. The index is refused whenever it cannot
/// be trusted whole (stale key, changed shun set), and a check-in whose
/// change set it records as UNKNOWN is reconstructed individually, so the
/// reconstruction is the correctness backstop, never a second definition
/// of the predicate.
index: ?*PathIndex = null,
};
/// A name a PATH walk follows, interned in a `NameTable`.
pub const NameId = u32;
/// One check-in under one of the names a walk follows. A rename makes the
/// same check-in reachable under two names, and each is a different question.
pub const NamedRid = struct { rid: repostate.Rid, name: NameId };
/// The names a following walk has met, interned so a (check-in, name) pair is
/// two integers. A walk meets one more name per rename it crosses, so this
/// grows with the file's rename history, not with the walk.
pub const NameTable = struct {
arena: std.heap.ArenaAllocator,
ids: std.StringHashMapUnmanaged(NameId) = .empty,
names: std.ArrayListUnmanaged([]const u8) = .empty,
pub fn init(gpa: std.mem.Allocator) NameTable {
return .{ .arena = std.heap.ArenaAllocator.init(gpa) };
}
pub fn deinit(self: *NameTable, gpa: std.mem.Allocator) void {
self.ids.deinit(gpa);
self.names.deinit(gpa);
self.arena.deinit();
}
pub fn intern(self: *NameTable, gpa: std.mem.Allocator, name: []const u8) error{OutOfMemory}!NameId {
if (self.ids.get(name)) |id| return id;
const copy = try self.arena.allocator().dupe(u8, name);
const id: NameId = @intCast(self.names.items.len);
try self.names.append(gpa, copy);
errdefer _ = self.names.pop();
try self.ids.put(gpa, copy, id);
return id;
}
pub fn get(self: *const NameTable, id: NameId) []const u8 {
return self.names.items[id];
}
};
/// Emit each check-in's own D/U/C cards beside its id. The manifest is
/// already parsed at the emit site to walk its P cards, so the facts are free
/// here and cost a subprocess per row anywhere else: a consumer drawing a file
/// history page (`log CHECKIN PATH`) otherwise runs `zit info` once per entry.
/// Same defect, and same fix, as `ls --long`.
///
/// `event_time` is the check-in's stored event time when derived state knows
/// it, which a date tag may have moved off the D card (repo-state §4.2 step
/// 6); it is printed in the writer form `info` and `timeline` use. Only the
/// walk with no derived state passes null and prints the D card itself.
pub fn logEmit(out: *std.Io.Writer, id: []const u8, manifest: *const artifact.Manifest, long: bool, event_time: ?repostate.Mtime) void {
if (!long) {
out.print("checkin {s}\n", .{id}) catch {};
return;
}
// Comment last: it is the only field that may contain spaces, so every
// earlier column stays splittable. A check-in with no C card prints an
// empty trailing field rather than a short line, so the arity is fixed.
var iso_buf: [19]u8 = undefined;
const when = if (event_time) |mt|
artifact.formatWriterTimestamp(@intCast(mt / 1000), &iso_buf)
else if (manifest.d) |d| d.timestamp else "-";
const who = if (manifest.u) |u| u.login else "-";
const what = if (manifest.c) |c| c.comment else "";
out.print("checkin {s} {s} {s} {s}\n", .{ id, when, who, what }) catch {};
}
/// `times`, when present, supplies each emitted check-in's stored event time
/// for `--long`; without it the row prints the D card.
pub fn eventTimeOf(times: ?*Repository, id: []const u8) ?repostate.Mtime {
const repo = times orelse return null;
const rid = repo.lookup(id) orelse return null;
return repo.state().mtimeOf(rid);
}
/// One check-in a PATH walk listed: the name PATH had there and, on a rename
/// row, the name it was renamed from. `-F json` renders these; the strings
/// live in the caller's arena.
pub const LogRow = struct { id: []const u8, name: []const u8, prior: ?[]const u8 };
/// Where a PATH walk reports its rows besides the text lines.
pub const LogRows = struct {
arena: std.mem.Allocator,
rows: std.ArrayListUnmanaged(LogRow) = .empty,
/// Resume after this check-in: the rows up to and including it are
/// neither kept nor counted toward the walk's limit, so a page picks up
/// where the previous one ended. Cleared once it is passed.
after: ?[]const u8 = null,
/// Whether the row counts toward the walk's limit.
fn add(self: *LogRows, names: *const NameTable, id: []const u8, name: NameId, prior: ?NameId) error{OutOfMemory}!bool {
if (self.after) |resume_at| {
if (std.mem.eql(u8, resume_at, id)) self.after = null;
return false;
}
try self.rows.append(self.arena, .{
.id = try self.arena.dupe(u8, id),
.name = try self.arena.dupe(u8, names.get(name)),
.prior = if (prior) |p| try self.arena.dupe(u8, names.get(p)) else null,
});
return true;
}
};
/// What one visited check-in means for the name a PATH walk follows there.
pub const PathStep = struct {
/// List it: its content or presence for the name differs from its primary
/// parent's, or (following) it renamed the file onto the name.
hit: bool = false,
/// Following, on a rename row: the name the parents are walked under.
prior: ?NameId = null,
/// The name is present here. Only meaningful when `known`.
present: bool = false,
/// The content was reconstructed; false for a check-in the index skipped.
known: bool = false,
/// The primary parent holds the parent-side name.
parent_present: bool = false,
/// Following, the walk ends on this side: the name was renamed away here
/// after the walk had already met the file now at this name, so what lies
/// below under this name is the renamed file's history, not this one's.
stop: bool = false,
};
/// The step a PATH walk takes at check-in `rid` (null: not interned, a
/// phantom the derived view never crosslinked) under `name`. `primary` is its
/// §3.2 primary parent. `on_file` says whether the walk has already met the
/// file present under the name it follows.
pub fn pathStep(
gpa: std.mem.Allocator,
f: LogPathFilter,
probe: *PathProbe,
rid: ?repostate.Rid,
primary: ?repostate.Rid,
name: NameId,
on_file: bool,
) LogWalkError!PathStep {
// The index NARROWS; the reconstruction DECIDES. A check-in the index
// does not name for this name is skipped without reading anything; one
// it does name is still confirmed by reconstruction, because the two
// relations are not identical: `captureFileFacts` diffs at F-card level,
// so a delta manifest that restates a path with the hash it already had
// reads as a change there while the path's effective content did not
// move. On the SQLite mirror that is one check-in in 2,113 — an
// over-report, never an omission — and confirming costs one
// reconstruction per CANDIDATE instead of one per visited check-in.
//
// The direction being trusted is stated plainly: a check-in the index
// omits is taken as unchanged. The differential test pins that against
// the reconstructing walk so a regression in `captureFileFacts` cannot
// silently drop a file's history.
const ix: ?*PathIndex = if (f.index) |i| (if (rid) |r| (if (i.unknown.contains(r)) null else i) else null) else null;
if (ix) |i| {
const set = try i.changedSet(gpa, probe.names.get(name));
if (!set.contains(rid.?)) {
// Not a row: nothing to list. But until a following walk has met
// the file, whether this check-in HOLDS the name is what decides
// the walk below it (`stop`), and the reconstruction learns that
// at every check-in it visits. Asked here too, the two walks
// agree; it costs one read per check-in only on the stretch
// above the point where the file is first met.
if (!f.follow or on_file) return .{};
const here = try effectivePathContent(gpa, f.repo, probe, rid, name);
return .{ .present = here.present, .known = true };
}
}
const here = try effectivePathContent(gpa, f.repo, probe, rid, name);
// The rename edge: from the index when it answers this check-in, else
// from the card. Both are keyed by THIS check-in and name, never by the
// path alone, so a file later created at the old name is never routed
// into the renamed file's history.
const prior: ?NameId = if (!f.follow or !here.present)
null
else if (ix) |i|
(if (try i.priorOf(gpa, rid.?, probe.names.get(name))) |old| try probe.names.intern(gpa, old) else null)
else
here.prior;
const parent = if (primary) |p|
try effectivePathContent(gpa, f.repo, probe, p, prior orelse name)
else
PathContent{ .present = false };
const changed = here.present != parent.present or
(here.present and !std.mem.eql(u8, here.hash[0..here.hash_len], parent.hash[0..parent.hash_len]));
var step: PathStep = .{
.hit = changed or prior != null,
.prior = prior,
.present = here.present,
.known = true,
.parent_present = parent.present,
};
if (f.follow and on_file and !here.present and parent.present) {
const away = if (ix) |i|
try i.renamedAway(gpa, rid.?, probe.names.get(name))
else
try renamedAway(gpa, f.repo, probe, rid.?, name);
if (away) {
step.hit = false;
step.stop = true;
}
}
return step;
}
/// Whether check-in `rid` renamed a file away from `name`: one of its own F
/// cards carries `name` as its old name. Read only at a check-in that removes
/// the name a walk follows, so the full file list is read rarely.
fn renamedAway(gpa: std.mem.Allocator, repo: *Repository, probe: *PathProbe, rid: repostate.Rid, name: NameId) LogWalkError!bool {
var list = (repo.files(gpa, rid) catch |e| return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.CannotRead => error.Storage,
else => error.Corrupt,
}) orelse return false;
defer list.deinit(gpa);
const wanted = probe.names.get(name);
var it = list.iterator();
while (it.next()) |card| {
if (card.old_name) |old| if (std.mem.eql(u8, old, wanted)) return true;
}
return false;
}
pub fn walkLog(
gpa: std.mem.Allocator,
log: *logstore.LogStore,
root_id: []const u8,
out: *std.Io.Writer,
filter: ?LogPathFilter,
limit: ?usize,
long: bool,
times: ?*Repository,
rows: ?*LogRows,
) LogWalkError!void {
const Pending = struct { id: []u8, name: NameId, on_file: bool };
var pending: std.ArrayList(Pending) = .empty;
defer {
for (pending.items) |p| gpa.free(p.id);
pending.deinit(gpa);
}
// Keyed by id, and by name too once a walk follows a rename: the same
// check-in under another name is another question.
var visited: std.StringHashMapUnmanaged(void) = .empty;
defer visited.deinit(gpa);
var visited_keys: std.ArrayList([]u8) = .empty;
defer {
for (visited_keys.items) |k| gpa.free(k);
visited_keys.deinit(gpa);
}
var shown: std.StringHashMapUnmanaged(void) = .empty;
defer shown.deinit(gpa);
// The plaintext memo, and this walk is the case it exists for. Check-in manifests
// are routinely stored as deltas against their neighbours, so reconstructing
// each one from `content` alone re-resolves the SAME base chain once per
// check-in — work that grows with how much history sits behind the node
// rather than with the node, which is why the per-check-in cost climbed with
// repository size instead of staying flat. Every other read path in this
// file already takes the memo; this one did not.
var plaintext: logstore.PlaintextCache = .{};
defer plaintext.deinit(gpa);
// The PATH predicate reads through the SAME plaintext memo, plus
// a baseline memo and an answer memo of its own. It runs twice per node
// (here, and the primary parent), so it, not the walk, was the cost.
var probe = PathProbe.init(gpa, &plaintext);
defer probe.deinit(gpa);
// `-n N` caps the ENTRIES NAMED, not the nodes traversed: with a PATH filter
// most visited check-ins print nothing, and capping traversal would make the
// answer depend on how much unrelated history sat in between. Counting the
// lines the user actually sees is the only reading that stays stable.
var emitted: usize = 0;
var root_name: NameId = 0;
var root_on_file = false;
if (filter) |f| {
root_name = try probe.names.intern(gpa, f.path);
root_on_file = (try effectivePathContent(gpa, f.repo, &probe, f.repo.lookup(root_id), root_name)).present;
}
const root_copy = try gpa.dupe(u8, root_id);
pending.append(gpa, .{ .id = root_copy, .name = root_name, .on_file = root_on_file }) catch {
gpa.free(root_copy);
return error.OutOfMemory;
};
var first = true;
while (pending.pop()) |entry| {
const id = entry.id;
// `-n N` (N ≥ 1; `parseLimitValue` maps 0 → null) returns the moment the
// Nth line is named, so at every loop entry fewer than N lines are out.
if (limit) |n| std.debug.assert(emitted < n);
const key: []u8 = if (entry.name == 0) id else blk: {
defer gpa.free(id);
break :blk try std.fmt.allocPrint(gpa, "{s}/{d}", .{ id, entry.name });
};
if (visited.contains(key)) {
gpa.free(key);
continue;
}
visited_keys.append(gpa, key) catch {
gpa.free(key);
return error.OutOfMemory;
};
visited.put(gpa, key, {}) catch {
_ = visited_keys.pop();
gpa.free(key);
return error.OutOfMemory;
};
const hex = key[0 .. std.mem.indexOfScalar(u8, key, '/') orelse key.len];
const is_root = first;
first = false;
var hash_buf: [digest_len]u8 = undefined;
const decoded = decodeFullId(hex, &hash_buf) catch
return if (is_root) error.InvalidRoot else error.InvalidTarget;
// A decoded id is a full §3.6 artifact name (40/64 hex), never a prefix —
// it is exactly what every `checkin`/`phantom` line below renders.
std.debug.assert(artifact.validateName(hex));
const rid = log.lookup(decoded) orelse {
if (is_root) return error.MissingRoot;
// A phantom's effective set is unknowable, so the PATH filter cannot
// assert it changes PATH — it is silently skipped (the unfiltered
// walk still names it once).
if (filter == null) {
out.print("phantom {s}\n", .{hex}) catch {};
emitted += 1;
if (limit) |n| if (emitted >= n) return;
}
continue;
};
const bytes = (try log.contentCached(gpa, rid, &plaintext)) orelse {
if (is_root) return error.MissingRoot;
if (filter == null) {
out.print("phantom {s}\n", .{hex}) catch {};
emitted += 1;
if (limit) |n| if (emitted >= n) return;
}
continue;
};
defer gpa.free(bytes);
if (!artifact.verify(hex, bytes)) return error.Corrupt;
// Read path: the log-walk reads fossil-origin history, so it tolerates
// the legacy F-card trailing separator.
var loaded = artifact.parseForImport(gpa, bytes) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return if (is_root) error.InvalidRoot else error.InvalidTarget,
};
defer loaded.deinit(gpa);
const manifest = &loaded.manifest;
if (manifest.kind != .manifest)
return if (is_root) error.InvalidRoot else error.InvalidTarget;
// With no PATH the walk names every reachable check-in (B-log-1); with
// PATH it names only the check-ins whose effective content for PATH
// differs from their primary parent's (B-log-2), and, following, the
// check-in that renamed the file onto the name it follows.
var step: PathStep = .{ .hit = true };
if (filter) |f| {
// §3.2: the primary parent is the first hash of the first P card.
var primary: ?[]const u8 = null;
for (manifest.p) |pc| {
var hashes = pc.hashes();
if (hashes.next()) |p| {
primary = p;
break;
}
}
const primary_rid: ?repostate.Rid = if (primary) |p| f.repo.lookup(p) else null;
step = try pathStep(gpa, f, &probe, f.repo.lookup(hex), primary_rid, entry.name, entry.on_file);
}
if (step.hit and !shown.contains(key[0..hex.len])) {
try shown.put(gpa, key[0..hex.len], {});
logEmit(out, hex, manifest, long, eventTimeOf(times, hex));
const counts = if (rows) |r| try r.add(&probe.names, hex, entry.name, step.prior) else true;
if (counts) {
emitted += 1;
if (limit) |n| if (emitted >= n) return;
}
}
if (step.stop) continue;
// Push in reverse wire order so the LIFO stack visits the first P hash
// first. Reversing only this node's appended segment preserves DFS when
// older sibling work is already below it.
const parent_name = step.prior orelse entry.name;
const on_file = entry.on_file or (step.known and step.present);
const parent_start = pending.items.len;
for (manifest.p) |pc| {
var hashes = pc.hashes();
while (hashes.next()) |parent| {
// Across a rename, a parent that lacks the old name ends the
// walk on that side (Fossil treats the file as new there).
if (step.prior != null) {
const f = filter.?;
const held = (try effectivePathContent(gpa, f.repo, &probe, f.repo.lookup(parent), parent_name)).present;
if (!held) continue;
}
const owned = try gpa.dupe(u8, parent);
pending.append(gpa, .{ .id = owned, .name = parent_name, .on_file = on_file }) catch {
gpa.free(owned);
return error.OutOfMemory;
};
}
}
std.mem.reverse(Pending, pending.items[parent_start..]);
}
}
/// `plink` — walk the check-in DAG from DERIVED ADJACENCY instead of
/// re-parsing every manifest, which is what fossil does and what this walk
/// used to not do.
///
/// Fossil parses a manifest exactly ONCE, when it crosslinks it, and records
/// the edge in a persistent table:
///
/// CREATE TABLE plink(pid, cid, isprim, mtime, baseid, UNIQUE(pid,cid));
/// CREATE INDEX plink_i2 ON plink(cid,pid);
///
/// Every later traversal is an indexed query over that table -- `timeline.c`'s
/// ancestor walk is a recursive CTE reading `plink.cid ... WHERE plink.pid=dx.id`
/// and touches no manifest at all. The tell is fossil's parsed-manifest cache:
/// `#define MX_MANIFEST_CACHE 6`, six entries, and its comment says it is there
/// for `rebuild`. Nothing on fossil's read path re-parses manifests, so nothing
/// needed a cache big enough to make that fast.
///
/// zit's derived state holds the same relation -- `parents_by_child`, carrying
/// the `is_primary` flag `plink.isprim` carries -- and the zero-parse
/// image carries it too, so this walk reads no artifact content whatever.
///
/// WHAT THIS GIVES UP, stated plainly: the manifest walk verified every
/// reachable artifact's name hash and re-validated its card grammar, so a
/// corrupt frame or a wrong-ID blob surfaced as a `log` failure. This does not.
/// That is fossil's behaviour too -- validation happens once at crosslink, and
/// `fossil test-integrity` is the verb that re-checks a repository, exactly as
/// `zit verify` is here. Paying a full-history re-verification on every history
/// listing was the anomaly, not the guarantee.
///
/// Order is preserved exactly: depth-first, parents pushed in reverse wire
/// order so the LIFO stack visits the first P hash first, first visit winning
/// across a diamond. A parent the store never received is `phantom`, the same
/// line the manifest walk printed for it.
///
/// With a PATH that follows renames, a stack entry is a check-in under a
/// name: crossing the check-in that renamed the file, the parents are walked
/// under the old name, and the same check-in reached under another name is
/// visited again (a rename on a merged branch reaches the fork point under
/// both). Each check-in is still listed once, under the first name that
/// lists it.
pub fn walkLogDerived(
gpa: std.mem.Allocator,
repo: *Repository,
root_id: []const u8,
out: *std.Io.Writer,
limit: ?usize,
/// The PATH filter, answered from the file index. Null walks
/// everything (B-log-1).
filter: ?LogPathFilter,
/// `--long` needs each row's own D/U/C cards, and derived state keeps the
/// timestamp PARSED, not as the check-in wrote it. So a long row reads its
/// own manifest — one read per EMITTED row, a page of them, rather than
/// the per-visited-node read that made this verb slow. The bytes then come
/// from the same place the manifest walk printed them, which is what keeps
/// the line identical.
long: bool,
rows: ?*LogRows,
) LogWalkError!void {
const root_rid = repo.lookup(root_id) orelse return error.MissingRoot;
// A crosslinked check-in has an event row; a rid interned only because some
// P card named it does not. That is the derive's record of "we actually
// received this", and it is what distinguishes a check-in from a phantom
// here -- NOT the view's own store, whose `held` answers about the identity
// space rather than about artifact bytes.
if (repo.state().mtimeOf(root_rid) == null) return error.MissingRoot;
const Pending = struct { rid: repostate.Rid, name: NameId, on_file: bool };
var pending: std.ArrayList(Pending) = .empty;
defer pending.deinit(gpa);
var visited: std.AutoHashMapUnmanaged(NamedRid, void) = .empty;
defer visited.deinit(gpa);
var shown: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty;
defer shown.deinit(gpa);
var emitted: usize = 0;
var plaintext: logstore.PlaintextCache = .{};
defer plaintext.deinit(gpa);
var probe = PathProbe.init(gpa, &plaintext);
defer probe.deinit(gpa);
var root_name: NameId = 0;
var root_on_file = false;
if (filter) |f| {
root_name = try probe.names.intern(gpa, f.path);
if (f.follow) root_on_file = (try effectivePathContent(gpa, repo, &probe, root_rid, root_name)).present;
}
try pending.append(gpa, .{ .rid = root_rid, .name = root_name, .on_file = root_on_file });
while (pending.pop()) |entry| {
const rid = entry.rid;
if (limit) |n| std.debug.assert(emitted < n);
if ((try visited.getOrPut(gpa, .{ .rid = rid, .name = entry.name })).found_existing) continue;
// A rid interned from a P card that never arrived. The manifest walk
// reached the same verdict by failing to read its content.
if (repo.state().mtimeOf(rid) == null) {
// A PATH filter cannot assert that a phantom changes PATH — its
// effective set is unknowable — so it is skipped silently, which
// is what the manifest walk does. Unfiltered, it is named.
if (filter == null) {
out.print("phantom {s}\n", .{repo.hashOf(rid)}) catch {};
emitted += 1;
if (limit) |n| if (emitted >= n) return;
}
continue;
}
// A manifest is read ONLY for an emitted row under `--long`; the
// predicate reads through `fileAt`, and only for the check-ins the
// index names. Reading one per visited check-in is the cost the
// index removed.
var step: PathStep = .{ .hit = true };
if (filter) |f| {
step = try pathStep(gpa, f, &probe, rid, repo.state().primaryParentOf(rid), entry.name, entry.on_file);
}
if (step.hit and !(try shown.getOrPut(gpa, rid)).found_existing) {
var owned: ?Repository.OwnedManifest = null;
defer if (owned) |*o| o.deinit(gpa);
if (long) {
owned = repo.manifestCached(gpa, rid, &plaintext) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => null,
};
}
if (owned) |*o| {
logEmit(out, repo.hashOf(rid), &o.manifest, true, repo.state().mtimeOf(rid));
} else {
out.print("checkin {s}\n", .{repo.hashOf(rid)}) catch {};
}
const counts = if (rows) |r| try r.add(&probe.names, repo.hashOf(rid), entry.name, step.prior) else true;
if (counts) {
emitted += 1;
if (limit) |n| if (emitted >= n) return;
}
}
if (step.stop) continue;
// Push the parents for the LIFO stack, reversing only this node's
// own appended segment so the first P hash is visited first while
// older sibling work stays below it.
const parent_name = step.prior orelse entry.name;
const on_file = entry.on_file or (step.known and step.present);
const start = pending.items.len;
var it = repo.state().parentsOf(rid);
while (it.next()) |p| {
// Across a rename, a parent that lacks the old name ends the walk
// on that side (Fossil treats the file as new there).
if (step.prior != null) {
const held = if (repo.state().primaryParentOf(rid) == p)
step.parent_present
else
(try effectivePathContent(gpa, repo, &probe, p, parent_name)).present;
if (!held) continue;
}
try pending.append(gpa, .{ .rid = p, .name = parent_name, .on_file = on_file });
}
std.mem.reverse(Pending, pending.items[start..]);
}
}
/// PATH's presence and effective content hash inside one check-in's file set.
/// The hash is a 40/64-char lowercase-hex F-card token (§4.7.6), copied into a
/// fixed buffer so the comparison outlives `fileAt`'s duped card. `prior` is
/// the card's own old name, which only the check-in that recorded a rename
/// carries (an inherited card never does).
pub const PathContent = struct {
present: bool,
hash: [2 * digest_len]u8 = undefined,
hash_len: usize = 0,
prior: ?NameId = null,
};
/// The memos the PATH predicate reads through. Two of them, because
/// the predicate repeats two different things:
///
/// * `plaintext` and `baseline` are the pair `annotate` already threads
/// through `fileAtMemo`. A check-in manifest is routinely stored as a
/// delta against its neighbour, so reconstructing one per `fileAt` call
/// re-resolves the SAME base chain per check-in, and a wide baseline is
/// re-parsed per check-in on top of that — the cost then grows with how
/// much history sits behind the node rather than with the node. The walk
/// built this memo for its own manifest reads and did not hand it to the
/// predicate, which is the part that runs twice per node.
/// * `content` memoizes the ANSWER by check-in and name. Every check-in is
/// asked for its content twice — once as `here` when it is visited, once
/// as `parent` when its child is — so in a mostly-linear history this
/// halves what is left. It is bounded by the nodes visited.
///
/// Neither changes what the predicate answers: both are pure memos over a
/// reconstruction that is a function of the store. `names` interns the names
/// a following walk meets.
pub const PathProbe = struct {
plaintext: *logstore.PlaintextCache,
baseline: ?*Repository.BaselineMemo = null,
content: std.AutoHashMapUnmanaged(NamedRid, PathContent) = .empty,
names: NameTable,
pub fn init(gpa: std.mem.Allocator, plaintext: *logstore.PlaintextCache) PathProbe {
return .{ .plaintext = plaintext, .names = NameTable.init(gpa) };
}
pub fn deinit(self: *PathProbe, gpa: std.mem.Allocator) void {
if (self.baseline) |b| b.deinit(gpa);
self.content.deinit(gpa);
self.names.deinit(gpa);
}
};
/// The effective content of `name` in check-in `rid` (artifact §8
/// reconstruction via `Repository.fileAt` — the one general-purpose query, so
/// no CLI-side re-derivation off raw F cards, B-log-5). `present` is false when
/// `rid` is null (a phantom the derived view never crosslinked) or the name
/// is not in its effective set (never added, or a delta delete marker §4.7.6).
pub fn effectivePathContent(
gpa: std.mem.Allocator,
repo: *Repository,
probe: *PathProbe,
rid: ?repostate.Rid,
name: NameId,
) LogWalkError!PathContent {
const r = rid orelse return .{ .present = false };
// Asked once as `here`, once as `parent` of the child below it.
if (probe.content.get(.{ .rid = r, .name = name })) |hit| return hit;
const answer = try effectivePathContentAt(gpa, repo, probe, r, name);
try probe.content.put(gpa, .{ .rid = r, .name = name }, answer);
return answer;
}
/// `effectivePathContent` past the memo: the actual §8 reconstruction for one
/// interned rid, through the caller's baseline and plaintext memos.
fn effectivePathContentAt(
gpa: std.mem.Allocator,
repo: *Repository,
probe: *PathProbe,
rid: repostate.Rid,
name: NameId,
) LogWalkError!PathContent {
const card = (repo.fileAtMemo(gpa, rid, probe.names.get(name), &probe.baseline, probe.plaintext) catch |e| return switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.CannotRead => error.Storage,
// NotManifest/BaselineUnresolvable/Corrupt/NotARepo all
// mean the reachable check-in cannot be reconstructed — the same class
// the unfiltered walk reports as a corrupt/invalid target.
else => error.Corrupt,
}) orelse return .{ .present = false };
defer Repository.freeFileCard(gpa, card);
// The iterator behind `fileAt` suppresses delete markers, so a returned
// card always carries a hash (§4.7.6).
const h = card.hash.?;
std.debug.assert(h.len <= 2 * digest_len);
var out: PathContent = .{ .present = true, .hash_len = h.len };
@memcpy(out.hash[0..h.len], h);
if (card.old_name) |old| out.prior = try probe.names.intern(gpa, old);
return out;
}
/// Set from `$ZIT_NO_FILE_INDEX` at the one environment seam in
/// `main`. The differential test drives the reconstructing answer through it.
pub var no_file_index: bool = false;
/// Evidence seam — how many check-ins the adopted file index recorded
/// as UNKNOWN (reconstructed per query rather than answered). On a tree-
/// manifest store this was the whole log, which is how an index that was
/// valid, adopted and never rebuilt still cost 39 minutes a query. Printed
/// by `main` under `ZIT_FILEINDEX_WALK`, beside the walk count. Null when
/// no index was adopted at all — printed as `none`, so a test asserting
/// zero unknowns cannot pass on a query that never consulted an index.
pub var file_index_unknown: ?usize = null;
/// The file index as a PATH walk reads it: the check-ins that changed each
/// name the walk follows, filled on first use, and every rename edge keyed by
/// check-in and name. All rids are DERIVED rids.
pub const PathIndex = struct {
repo: *Repository,
log: *logstore.LogStore,
/// Owned; `index` borrows it.
blob: []u8,
index: fileindex.Index,
/// The names below are interned here, so the index is asked by string
/// and never shares ids with a walk's own table.
names: NameTable,
/// The check-ins whose change set the index could not know. They are
/// resolved ONE AT A TIME by reconstruction, rather than refusing the
/// whole index: a real corpus carries a handful (the SQLite mirror has
/// exactly one, a 2009 check-in whose parent is genuinely absent), and
/// discarding 32,000 answers over one of them is how an index ends up
/// built, stored, and never used.
unknown: std.AutoHashMapUnmanaged(repostate.Rid, void) = .empty,
changed: std.AutoHashMapUnmanaged(NameId, std.AutoHashMapUnmanaged(repostate.Rid, void)) = .empty,
/// (check-in, new name) -> old name: the check-in renamed the file onto
/// the new name from the old. Filled per name on first use, from the
/// edges the index finds for that name alone (`from_loaded`).
renamed_from: std.AutoHashMapUnmanaged(NamedRid, NameId) = .empty,
from_loaded: std.AutoHashMapUnmanaged(NameId, void) = .empty,
/// (check-in, old name): the check-in renamed a file away from the name,
/// filled per name on first use (`away_loaded`).
renamed_away: std.AutoHashMapUnmanaged(NamedRid, void) = .empty,
away_loaded: std.AutoHashMapUnmanaged(NameId, void) = .empty,
pub fn deinit(self: *PathIndex, gpa: std.mem.Allocator) void {
var it = self.changed.valueIterator();
while (it.next()) |set| set.deinit(gpa);
self.changed.deinit(gpa);
self.unknown.deinit(gpa);
self.renamed_from.deinit(gpa);
self.from_loaded.deinit(gpa);
self.renamed_away.deinit(gpa);
self.away_loaded.deinit(gpa);
self.names.deinit(gpa);
gpa.free(self.blob);
}
/// The check-ins the index says changed `name`.
pub fn changedSet(self: *PathIndex, gpa: std.mem.Allocator, name: []const u8) error{OutOfMemory}!*const std.AutoHashMapUnmanaged(repostate.Rid, void) {
const id = try self.names.intern(gpa, name);
const gop = try self.changed.getOrPut(gpa, id);
if (gop.found_existing) return gop.value_ptr;
gop.value_ptr.* = .empty;
var rids: std.ArrayListUnmanaged(u32) = .empty;
defer rids.deinit(gpa);
try self.index.changedBy(name, &rids, gpa);
for (rids.items) |r| {
if (ridOfLogRid(self.repo, self.log, r)) |view_rid| try gop.value_ptr.put(gpa, view_rid, {});
}
return gop.value_ptr;
}
/// The name check-in `rid` renamed `name` from, if it recorded a rename.
/// Only `name`'s own edges are read, by bisection, the first time it is
/// asked: a walk pays for the renames of the file it follows, not for
/// every rename the repository holds.
fn priorOf(self: *PathIndex, gpa: std.mem.Allocator, rid: repostate.Rid, name: []const u8) error{OutOfMemory}!?[]const u8 {
const id = try self.names.intern(gpa, name);
if (!(try self.from_loaded.getOrPut(gpa, id)).found_existing) {
var edges: std.ArrayListUnmanaged(fileindex.Edge) = .empty;
defer edges.deinit(gpa);
try self.index.renamesInto(name, &edges, gpa);
for (edges.items) |e| {
const view_rid = ridOfLogRid(self.repo, self.log, e.rid) orelse continue;
const old = try self.names.intern(gpa, e.old);
try self.renamed_from.put(gpa, .{ .rid = view_rid, .name = id }, old);
}
}
const old = self.renamed_from.get(.{ .rid = rid, .name = id }) orelse return null;
return self.names.get(old);
}
/// Whether no check-in ever held `name`, so a PATH walk has nothing to
/// find. A file present anywhere was added by some check-in, which then
/// names it in its change set or is UNKNOWN; so it is enough that no change
/// set names it, no rename leads onto it, and no unknown check-in holds it.
/// The unknown check-ins asked are those the derived view can place; one
/// it cannot, the walk could not visit either. A phantom filled after the
/// index was built is neither known nor unknown, which the walk this
/// replaces never saw either. Any doubt answers false and the walk decides.
pub fn neverHeld(self: *PathIndex, gpa: std.mem.Allocator, name: []const u8) error{OutOfMemory}!bool {
var rids: std.ArrayListUnmanaged(u32) = .empty;
defer rids.deinit(gpa);
try self.index.changedBy(name, &rids, gpa);
if (rids.items.len != 0) return false;
var edges: std.ArrayListUnmanaged(fileindex.Edge) = .empty;
defer edges.deinit(gpa);
try self.index.renamesInto(name, &edges, gpa);
if (edges.items.len != 0) return false;
// Each unknown check-in costs a read; past a handful the walk, which
// reconstructs them anyway, is no slower than asking here.
if (self.unknown.count() > max_unknown_probed) return false;
var it = self.unknown.keyIterator();
while (it.next()) |rid| {
const card = self.repo.fileAt(gpa, rid.*, name) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return false,
};
if (card) |c| {
Repository.freeFileCard(gpa, c);
return false;
}
}
return true;
}
const max_unknown_probed = 64;
/// Whether check-in `rid` renamed a file away from `name`, read the
/// first time `name` is asked.
fn renamedAway(self: *PathIndex, gpa: std.mem.Allocator, rid: repostate.Rid, name: []const u8) error{OutOfMemory}!bool {
const id = try self.names.intern(gpa, name);
if (!(try self.away_loaded.getOrPut(gpa, id)).found_existing) {
var edges: std.ArrayListUnmanaged(fileindex.Edge) = .empty;
defer edges.deinit(gpa);
try self.index.renamesFrom(name, &edges, gpa);
for (edges.items) |e| {
const view_rid = ridOfLogRid(self.repo, self.log, e.rid) orelse continue;
try self.renamed_away.put(gpa, .{ .rid = view_rid, .name = id }, {});
}
}
return self.renamed_away.contains(.{ .rid = rid, .name = id });
}
};
/// The file index for a PATH walk, or null when it cannot be trusted and the
/// walk must reconstruct the predicate.
///
/// Adopt-or-build, the shape every other `.zit-cache` member uses: a blob whose
/// key and shun fingerprint still validate is used as it stands; anything else
/// is rebuilt by one `repoview.eachChangeSet` pass and stored. The store is
/// advisory — a write that fails costs the next query a rebuild and nothing
/// else.
///
/// A check-in whose change set is UNKNOWN (a tree manifest, an absent or
/// shunned parent, an unresolvable baseline) is recorded in `unknown` and
/// reconstructed individually by the walk. Dropping such a check-in from the
/// index's answer would silently drop it from a file's history — a wrong
/// answer rather than a slow one.
pub fn loadPathIndex(
gpa: std.mem.Allocator,
io: std.Io,
root_dir: std.Io.Dir,
repo: *Repository,
log: *logstore.LogStore,
) error{OutOfMemory}!?PathIndex {
// A test seam, and the operator's escape hatch: set, the query is answered
// by reconstruction alone, which is what the differential test compares
// the indexed answer against. Read from the environment in `main` and set
// here, never read in a runner: that is the design-seams rule, and reading
// it through libc also made the build refuse on any target that does not
// link libc (every Linux runner, while macOS links it always).
if (no_file_index) return null;
var blob: ?[]u8 = repocache.tryLoadFileIndex(gpa, io, root_dir);
errdefer if (blob) |b| gpa.free(b);
var idx = if (blob) |b| fileindex.parse(b, log) else null;
const have: u64 = if (idx) |i| i.n_rids else 0;
const want: u64 = @intCast(log.entryCount());
// Three cases, and only the first two cost anything. An index that
// already covers the log is used as it stands; one that covers a prefix
// is EXTENDED over the appended tail; anything else is built once.
// Rebuilding on every append is what made a commit cost a full pass.
if (idx == null or have < want) {
var builder = fileindex.Builder.init(gpa);
defer builder.deinit();
if (idx) |*i| try builder.absorb(i);
// The derive builds this index as it captures, so this is the fallback
// for a store whose cache predates that. Walked in bounded chunks, each
// checkpointed: a build a restart interrupts resumes from the last
// chunk instead of from zero, so a forge that restarts more often than
// the whole build takes still converges.
// Known limit of this fallback, and the reason the derive-built index
// is primary: coverage here is by entry count, so a phantom filled
// after a build (its rid already below the count) is never revisited
// by a later resume. The derive walks by frame offset and re-covers it.
const chunk: u64 = 8192;
var covered: u64 = have;
while (covered < want) {
const upto: u64 = @min(covered + chunk, want);
try repoview.eachChangeSet(gpa, log, @intCast(covered), @intCast(upto), builder.sink());
const fresh = (try fileindex.serialize(gpa, log, &builder, upto)) orelse {
if (blob) |b| gpa.free(b);
return null;
};
repocache.storeFileIndex(io, root_dir, fresh);
if (blob) |b| gpa.free(b);
blob = fresh;
covered = upto;
}
const final = blob orelse return null; // an empty log: nothing to index
idx = fileindex.parse(final, log);
}
const index = idx orelse {
if (blob) |b| gpa.free(b);
return null;
};
var out: PathIndex = .{ .repo = repo, .log = log, .blob = blob.?, .index = index, .names = NameTable.init(gpa) };
errdefer out.deinit(gpa);
blob = null; // owned by `out` from here
var u: usize = 0;
while (u < index.unknownCount()) : (u += 1) {
if (ridOfLogRid(repo, log, index.unknownAt(u))) |view_rid| try out.unknown.put(gpa, view_rid, {});
}
file_index_unknown = index.unknownCount();
return out;
}
/// Translate a LOG rid (a frame index, which is what the file index stores)
/// into the DERIVED rid the walk uses. The two spaces are not the same — the
/// derived space interns phantoms a P card named and orders by first sight —
/// so comparing one to the other silently matches unrelated check-ins. The
/// artifact name is the identity both agree on.
pub fn ridOfLogRid(repo: *Repository, log: *logstore.LogStore, log_rid: u32) ?repostate.Rid {
const digest = log.hashOf(@intCast(log_rid));
const hex: []const u8 = if (digest.len == 32)
&std.fmt.bytesToHex(digest[0..32].*, .lower)
else if (digest.len == 20)
&std.fmt.bytesToHex(digest[0..20].*, .lower)
else
return null;
return repo.lookup(hex);
}
/// What a PATH walk is asked: the file, how many rows (`-n`), whether rows
/// carry their D/U/C cards (`--long`), and whether renames are followed.
pub const PathQuery = struct {
path: []const u8,
limit: ?usize = null,
long: bool = false,
follow: bool = true,
};
/// `zit log ROOT PATH`'s walk, writing its text lines to `out` and, when
/// `rows` is given, its rows there too. The predicate is answered from
/// `index` when one is given; the derived adjacency is walked when the root
/// is crosslinked, and the manifest walk otherwise.
pub fn walkPath(
gpa: std.mem.Allocator,
repo: *Repository,
log: *logstore.LogStore,
index: ?*PathIndex,
root_id: []const u8,
q: PathQuery,
out: *std.Io.Writer,
rows: ?*LogRows,
) LogWalkError!void {
const filter: LogPathFilter = .{
.repo = repo,
.path = q.path,
.follow = q.follow,
.index = index,
};
// Traverse the DERIVED adjacency, exactly as the unfiltered walk
// does: same depth-first order, same phantom line, and no
// artifact content read for traversal. With the predicate answered from
// the index, that leaves nothing per visited check-in at all — the reads
// that remain are one per EMITTED row under `--long`. The manifest walk
// stays as the fallback for a root the derived state cannot resolve.
const derived = repo.lookup(root_id) != null and repo.state().mtimeOf(repo.lookup(root_id).?) != null;
// A following walk reads each check-in's file list until it meets the
// file, so a name no check-in ever held would be read for in every
// ancestor. The answer there is no rows, known without walking.
if (derived) if (index) |ix| {
if (try ix.neverHeld(gpa, q.path)) return;
};
if (derived)
try walkLogDerived(gpa, repo, root_id, out, q.limit, filter, q.long, rows)
else
try walkLog(gpa, log, root_id, out, filter, q.limit, q.long, repo, rows);
}
/// One listed check-in as `log CHECKIN PATH -F json` describes it. Field
/// names and change words are Fossil's (`json_finfo.c`): `uuid` is the
/// file's content in the check-in, `parent` its content in the primary
/// parent, `state` one of added, modified, removed — a pure rename is
/// `modified`, as there. `name` is the file's name in that check-in, which
/// differs from the queried path below a rename, and `prior` is the name a
/// rename row moved it from. A version whose content is absent by policy
/// has a null `size` and names why in `content`.
pub const Entry = struct {
checkin: []const u8,
name: []const u8,
prior: ?[]const u8,
uuid: ?[]const u8,
parent: ?[]const u8,
state: []const u8,
time_ms: ?repostate.Mtime,
user: ?[]const u8,
comment: ?[]const u8,
size: ?u64,
/// Purged, shunned or elided; null whenever the content is here.
content: ?feed.Absence,
};
/// Why a blob the store names is not here, as the caller can tell: the
/// cone and horizon classifier lives with the sync machinery.
pub const Classify = struct {
ctx: ?*anyopaque = null,
absence: *const fn (ctx: ?*anyopaque, hash: []const u8) feed.Absence,
};
/// The facts of one row the walk listed. Strings live on `arena`. Content
/// missing for no recorded reason is damage: `error.Corrupt`.
pub fn entryOf(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
probe: *PathProbe,
row: LogRow,
classify: Classify,
) !Entry {
const id = row.id;
// Neighbouring check-ins are stored as deltas of each other: the page's
// plaintext memo rebuilds each chain once, not once per row.
const bytes = (try repo.blobCached(gpa, id, probe.plaintext)) orelse return error.Corrupt;
defer gpa.free(bytes);
var loaded = try artifact.parseForImport(gpa, bytes);
defer loaded.deinit(gpa);
const m = &loaded.manifest;
var primary: ?[]const u8 = null;
for (m.p) |pc| {
var hashes = pc.hashes();
if (hashes.next()) |first| {
primary = first;
break;
}
}
const name = try probe.names.intern(gpa, row.name);
const parent_name = try probe.names.intern(gpa, row.prior orelse row.name);
const here = try effectivePathContent(gpa, repo, probe, repo.lookup(id), name);
const before: PathContent = if (primary) |pid| try effectivePathContent(gpa, repo, probe, repo.lookup(pid), parent_name) else .{ .present = false };
var size: ?u64 = null;
var content: ?feed.Absence = null;
if (here.present) {
const hash = here.hash[0..here.hash_len];
// The size is in the frame; the version itself need not be rebuilt.
if (try repo.blobSize(gpa, hash)) |n| {
size = n;
} else {
// A version purged, shunned or elided by a cone is still part
// of the history; only its size is unknown. Content missing
// for no recorded reason is damage and keeps failing.
const why = classify.absence(classify.ctx, hash);
if (why == .missing) return error.Corrupt;
content = why;
}
}
return .{
.checkin = try arena.dupe(u8, id),
.name = try arena.dupe(u8, row.name),
.prior = if (row.prior) |p| try arena.dupe(u8, p) else null,
.uuid = if (here.present) try arena.dupe(u8, here.hash[0..here.hash_len]) else null,
.parent = if (before.present) try arena.dupe(u8, before.hash[0..before.hash_len]) else null,
.state = if (!before.present) "added" else if (!here.present) "removed" else "modified",
.time_ms = if (repo.lookup(id)) |rid| repo.state().mtimeOf(rid) else null,
.user = if (m.u) |u| try arena.dupe(u8, u.login) else null,
.comment = if (m.c) |c| try arena.dupe(u8, c.comment) else null,
.size = size,
.content = content,
};
}
pub const TextWriter = fn (jw: *std.json.Stringify, s: []const u8) std.json.Stringify.Error!void;
/// A string as `std.json.Stringify` writes it.
pub fn plainText(jw: *std.json.Stringify, s: []const u8) std.json.Stringify.Error!void {
return jw.write(s);
}
/// One row as a JSON object, in `log -F json`'s field order. `text` writes
/// the strings the store holds (names, user, comment), which need not be
/// UTF-8: the CLI writes them as they are, the daemon as valid JSON.
pub fn writeEntry(jw: *std.json.Stringify, e: Entry, comptime text: TextWriter) std.json.Stringify.Error!void {
const opt = struct {
fn write(w: *std.json.Stringify, s: ?[]const u8) std.json.Stringify.Error!void {
if (s) |v| return text(w, v);
return w.write(null);
}
}.write;
try jw.beginObject();
try jw.objectField("checkin");
try jw.write(e.checkin);
try jw.objectField("name");
try text(jw, e.name);
try jw.objectField("prior");
try opt(jw, e.prior);
try jw.objectField("uuid");
try jw.write(e.uuid);
try jw.objectField("parent");
try jw.write(e.parent);
try jw.objectField("state");
try jw.write(e.state);
try jw.objectField("time");
if (e.time_ms) |t| {
var buf: [24]u8 = undefined;
try jw.write(rfc3339Millis(&buf, t));
} else try jw.write(null);
try jw.objectField("user");
try opt(jw, e.user);
try jw.objectField("comment");
try opt(jw, e.comment);
try jw.objectField("size");
try jw.write(e.size);
if (e.content) |why| {
try jw.objectField("content");
try jw.write(switch (why) {
.purged => "purged",
.shunned => "shunned",
.elided => "elided",
.missing => unreachable, // `entryOf` refuses it as damage
});
}
try jw.endObject();
}
/// `mt` (milliseconds since the epoch) as RFC 3339 UTC with milliseconds.
pub fn rfc3339Millis(buf: *[24]u8, mt: repostate.Mtime) []const u8 {
var iso_buf: [19]u8 = undefined;
const secs = artifact.formatWriterTimestamp(@intCast(@divFloor(mt, 1000)), &iso_buf);
return std.fmt.bufPrint(buf, "{s}.{d:0>3}Z", .{ secs, @as(u64, @intCast(@mod(mt, 1000))) }) catch unreachable; // 19 + 5 bytes
}
// ---------------------------------------------------------------------------
// Paging, for a resident reader
/// One page of a file's history and where the next one starts.
pub const Page = struct {
entries: []const Entry,
/// Null when nothing follows this page.
cursor: ?[]const u8,
};
/// A page cursor: the check-in the walk started from, then the last row
/// listed. Rows are unique per check-in, so the last row's check-in alone
/// says where to resume; carrying the root keeps the walk the same one when
/// new check-ins land on the branch between pages.
pub const Cursor = struct { root: []const u8, after: []const u8 };
pub fn parseCursor(text: []const u8) ?Cursor {
const colon = std.mem.indexOfScalar(u8, text, ':') orelse return null;
const c: Cursor = .{ .root = text[0..colon], .after = text[colon + 1 ..] };
if (!artifact.validateName(c.root) or !artifact.validateName(c.after)) return null;
return c;
}
pub fn formatCursor(arena: std.mem.Allocator, root: []const u8, after: []const u8) error{OutOfMemory}![]const u8 {
return std.fmt.allocPrint(arena, "{s}:{s}", .{ root, after });
}
pub const PageError = LogWalkError || error{
/// The cursor's row is not in this walk: another path, another
/// `follow`, or history the store no longer holds.
CursorInvalid,
};
/// The rows after `after` (all of them when null), at most `limit` (all
/// when null), from the walk `zit log root_id PATH -F json` makes. Each
/// page re-walks from the root to its resume point, so a page costs what
/// the rows before it cost; nothing is held between pages.
pub fn page(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
repo: *Repository,
log: *logstore.LogStore,
index: ?*PathIndex,
root_id: []const u8,
after: ?[]const u8,
q: PathQuery,
classify: Classify,
) PageError!Page {
std.debug.assert(!q.long and (q.limit orelse 1) >= 1);
var rows: LogRows = .{ .arena = arena, .after = after };
var discard: std.Io.Writer.Discarding = .init(&.{});
// One row past the page says whether another page follows.
var walk_q = q;
if (q.limit) |n| walk_q.limit = n + 1;
try walkPath(gpa, repo, log, index, root_id, walk_q, &discard.writer, &rows);
if (rows.after != null) return error.CursorInvalid;
const kept = rows.rows.items;
const shown = if (q.limit) |n| kept[0..@min(n, kept.len)] else kept;
var plaintext: logstore.PlaintextCache = .{};
defer plaintext.deinit(gpa);
var probe = PathProbe.init(gpa, &plaintext);
defer probe.deinit(gpa);
const entries = try arena.alloc(Entry, shown.len);
for (shown, entries) |row, *e| e.* = entryOf(gpa, arena, repo, &probe, row, classify) catch |err| return switch (err) {
error.OutOfMemory => error.OutOfMemory,
error.Storage => error.Storage,
else => error.Corrupt,
};
const more = shown.len < kept.len;
return .{
.entries = entries,
.cursor = if (more) try formatCursor(arena, root_id, shown[shown.len - 1].id) else null,
};
}