Artifact 7f0fbc850e842141d7796e6727b8c010e9a257eccffd8765e304bcb01f99da9b:
- File
src/purge.zig
— part of check-in
[8536f59fba]
at
2026-08-17 19:00:15
on branch trunk
— zit purge: recoverable removal of leaf check-ins (#87)
Removes a check-in and the blobs only it introduced, into a local graveyard that purge undo restores and purge obliterate empties.
The drop set is (seed + reachable(seed)) minus reachable(everything retained), so a blob a surviving check-in still names is never freed -- dropping it would create a phantom rather than reclaim anything. Only leaves go, judged over the whole named set: purging a parent alongside its child is accepted, purging it alone is refused.
Reuses shun's rewriteAndSwap and records no tombstone: a purge is local, so content that already synced comes back from a peer. The help says so and points at shun. (user: dmestas, size: 25563) [annotate] [blame] [check-ins using]
//! #87 — what a purge would remove, decided before anything is removed. //! //! `zit shun` answers "this content must never exist here again": permanent, //! propagating, and correct once content has escaped. Purge answers the other //! half — the `zit add .` that swept in a build tree, noticed before anyone //! else has it — by removing the check-in and reclaiming its bytes while //! keeping them recoverable in the local graveyard (`graveyard.zig`). //! //! This module computes the PLAN and nothing else. It opens nothing, writes //! nothing, and reclaims nothing; `cmd/purge.zig` renders it and, only if the //! operator confirms, hands its drop set to the same `rewriteAndSwap` that //! `zit shun` uses. Separating the two is what makes the interesting property //! testable without a repository on disk. //! //! THE PROPERTY: a purge must leave ZERO PHANTOMS. Dropping a blob a retained //! manifest still references does not free anything — it converts held content //! into a phantom the next `zit update` fails on and the next sync re-fetches, //! which is worse than the bloat the operator was trying to remove. So the //! drop set is not "what the named check-ins reference"; it is //! //! (seed ∪ reachable(seed)) \ reachable(everything retained) //! //! and the plan is refused outright if that subtraction would take a named //! check-in back out. Stating it as a set difference over a full-store walk, //! rather than as a reference count maintained incrementally, is deliberate: a //! count that drifts is silent, and this is the one operation where being //! wrong destroys data. //! //! LEAF-ONLY, and by the strict definition. `zit leaves` lists OPEN leaves on //! their branch — a check-in with a child on another branch is still a leaf //! there, and a closed one is not listed at all. Purge needs the stricter //! question: does ANY check-in in the DAG name this as a parent? Purging a //! check-in with a child would orphan the child, so this module counts P-card //! references directly rather than reusing that branch-scoped answer. const std = @import("std"); const artifact = @import("artifact.zig"); const logstore = @import("logstore.zig"); const Rid = logstore.Rid; pub const Error = error{ OutOfMemory, Corrupt, Storage, /// The computed drop set does not remove every check-in that was named, /// and no per-target blocker explained why. Unreachable through any input /// this module knows how to describe, which is exactly why it is an ERROR /// rather than an assert: an assert compiles to nothing under /// `--release=fast`, and the thing it guards here is a destructive rewrite. /// Refusing on an inconsistency we cannot explain is the only safe arm. PlanIncomplete, }; /// Why a named check-in cannot be purged. Every one of these is reported with /// the operator's own token, and every one blocks the WHOLE plan: a purge that /// silently did the possible half of what was asked would leave the operator /// believing the rest had happened too. pub const Blocker = enum { /// No artifact in this repository carries that name. not_found, /// The prefix names more than one artifact. ambiguous, /// The name resolves, but to a blob, a tag, or a wiki page — not a /// check-in. Purging those is a different operation with different /// consequences, so it is refused rather than guessed at. not_a_checkin, /// Some check-in names this one as a parent. Removing it would orphan that /// child: its P card would point at nothing, which is a phantom by another /// name. Purge the descendants first, or reach for `shun`. not_a_leaf, /// The checkout is standing on it. The one refusal with no override, for /// the same reason `zit shun` has it. current_checkout, /// Something retained still names it — a tag artifact that also tags /// check-ins we are keeping, or a cluster. Dropping it would leave that /// artifact pointing at absent content. still_named, pub fn describe(self: Blocker) []const u8 { return switch (self) { .not_found => "no such artifact", .ambiguous => "ambiguous prefix", .not_a_checkin => "not a check-in", .not_a_leaf => "not a leaf: a later check-in names it as a parent", .current_checkout => "the current checkout", .still_named => "a retained artifact still names it", }; } }; /// One name the operator supplied, and what became of it. Classified in place /// the way `zit shun` classifies its targets, so every token keeps its own /// diagnostic instead of collapsing into one count. pub const Target = struct { name: []const u8, rid: Rid = 0, blocker: ?Blocker = null, }; /// The artifacts a purge would remove, and what they weigh. pub const Plan = struct { /// Persisted order, which is the order the rewrite will walk them in. drop: []const Rid, /// How the drop set breaks down, for the line the operator reads before /// confirming. `others` is blobs and submanifests — content that exists in /// this repository only because the named check-ins introduced it. checkins: usize = 0, controls: usize = 0, others: usize = 0, /// Stored bytes reclaimed: frame spans, not plaintext sizes, because the /// number an operator is deciding about is how much smaller the file gets. bytes: u64 = 0, }; /// What one artifact points at. Built once per artifact for the whole store, /// because both halves of the set difference need it and parsing the store /// twice to answer the same question is how the two halves drift apart. const Refs = struct { kind: ?artifact.ArtifactType, /// P cards: the DAG edges that decide leaf-ness. parents: []const Rid = &.{}, /// B, F and S cards: the content this artifact NEEDS. Following these /// transitively is what "reachable" means here. content: []const Rid = &.{}, /// T targets and M members: what this artifact SPEAKS ABOUT. A tag needs /// its target to exist to mean anything, but the target is not part of the /// tag, so these retain without being reachable content. names: []const Rid = &.{}, }; /// Classify every artifact in the store. `arena` owns the result. /// /// An artifact that does not parse is a blob — that is exactly how the rest of /// zit tells the two apart, and a blob points at nothing, so it gets an empty /// entry rather than an error. A parse failure here is therefore never fatal. fn classify(gpa: std.mem.Allocator, arena: std.mem.Allocator, log: *logstore.LogStore, all: []const Rid) Error![]Refs { const out = try arena.alloc(Refs, all.len); for (all, 0..) |rid, i| { out[i] = .{ .kind = null }; // A phantom has no bytes here to parse; it also cannot be dropped, // since there is nothing of it to drop. if (log.isPhantom(rid)) continue; const bytes = log.content(gpa, rid) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Corrupt, } orelse continue; defer gpa.free(bytes); // Parsed with `gpa`: the Manifest owns its own arena and frees it on // deinit, while the rid lists below live in `arena` and outlive it. var m = artifact.parse(gpa, bytes) catch continue; // a blob defer m.deinit(); var parents: std.ArrayList(Rid) = .empty; var content: std.ArrayList(Rid) = .empty; var names: std.ArrayList(Rid) = .empty; // A P card carries the whole parent RUN, so the edges come from its own // re-tokenizer rather than one hash per card. for (m.p) |p| { var it = p.hashes(); while (it.next()) |h| { if (try resolve(log, h)) |r| try parents.append(arena, r); } } if (m.b) |b| if (try resolve(log, b.hash)) |r| try content.append(arena, r); for (m.f) |f| if (f.hash) |h| if (try resolve(log, h)) |r| try content.append(arena, r); for (m.s) |s| if (try resolve(log, s.hash)) |r| try content.append(arena, r); // A T card with no target is the `*` form: it speaks about the // artifact carrying it, not about another one, so it names nothing. for (m.t) |tc| if (tc.target) |tgt| { if (try resolve(log, tgt)) |r| try names.append(arena, r); }; for (m.m) |mc| if (try resolve(log, mc.hash)) |r| try names.append(arena, r); out[i] = .{ .kind = m.kind, .parents = parents.items, .content = content.items, .names = names.items, }; } return out; } /// A card hash to the rid it names, or null when this repository does not hold /// it. The index is keyed by binary digest while cards carry hex, so the /// lookup goes through the prefix resolver; a full-width hex name cannot be /// ambiguous, but a malformed one is treated as naming nothing rather than /// aborting a walk that is only trying to find out what is referenced. fn resolve(log: *logstore.LogStore, hex: []const u8) Error!?Rid { return log.resolveHexPrefix(hex) catch return null; } const RidSet = std.AutoHashMapUnmanaged(Rid, void); /// Everything `roots` needs, transitively, including the roots. Cycle-safe via /// the visited set — an artifact graph should be acyclic, but a walk that /// assumes it while deciding what to delete is a walk that hangs on a corrupt /// store instead of reporting one. fn reach(gpa: std.mem.Allocator, index: *const std.AutoHashMapUnmanaged(Rid, usize), refs: []const Refs, roots: []const Rid, out: *RidSet) Error!void { var stack: std.ArrayList(Rid) = .empty; defer stack.deinit(gpa); for (roots) |r| try stack.append(gpa, r); while (stack.pop()) |rid| { if ((try out.getOrPut(gpa, rid)).found_existing) continue; const at = index.get(rid) orelse continue; for (refs[at].content) |c| try stack.append(gpa, c); } } /// Decide what `targets` would remove. Returns null when any target is /// blocked; the caller renders the blockers from `targets`, which this /// function has classified in place. /// /// `checkout_rid` is the check-in the working tree is standing on, or null in /// a repository with no checkout (a bare server clone). pub fn plan( gpa: std.mem.Allocator, arena: std.mem.Allocator, log: *logstore.LogStore, targets: []Target, checkout_rid: ?Rid, ) Error!?Plan { const all = try log.ridsInPersistedOrder(arena); const refs = try classify(gpa, arena, log, all); var index: std.AutoHashMapUnmanaged(Rid, usize) = .empty; defer index.deinit(gpa); try index.ensureTotalCapacity(gpa, @intCast(all.len)); for (all, 0..) |rid, i| index.putAssumeCapacity(rid, i); // The DAG edges, counted over the whole store rather than asked of the // branch cache. See the module doc: purge needs "has no child at all". var children: std.AutoHashMapUnmanaged(Rid, std.ArrayListUnmanaged(Rid)) = .empty; defer { var it = children.valueIterator(); while (it.next()) |v| v.deinit(gpa); children.deinit(gpa); } for (all, 0..) |rid, i| { if (refs[i].kind != .manifest) continue; for (refs[i].parents) |p| { const e = try children.getOrPut(gpa, p); if (!e.found_existing) e.value_ptr.* = .empty; try e.value_ptr.append(gpa, rid); } } // Classified in two passes, because leaf-ness is a property of the NAMED // SET rather than of one token. `zit purge checkins A B` where B is A's // child removes the tip of a branch and orphans nothing — refusing it // because A "has a child" would refuse the ordinary case this verb exists // for, an accidental commit noticed one commit later. var seed: RidSet = .empty; defer seed.deinit(gpa); var blocked = false; for (targets) |*tg| { if (tg.blocker != null) { blocked = true; continue; } const at = index.get(tg.rid) orelse { tg.blocker = .not_found; blocked = true; continue; }; if (refs[at].kind != .manifest) { tg.blocker = .not_a_checkin; blocked = true; continue; } if (checkout_rid != null and checkout_rid.? == tg.rid) { tg.blocker = .current_checkout; blocked = true; continue; } try seed.put(gpa, tg.rid, {}); } if (blocked) return null; // Now that the whole named set is known: a target is a leaf OF THIS PURGE // when every check-in naming it as a parent is going too. for (targets) |*tg| { const kids = children.get(tg.rid) orelse continue; for (kids.items) |c| { if (seed.contains(c)) continue; tg.blocker = .not_a_leaf; blocked = true; break; } } if (blocked) return null; // A tag artifact about a purged check-in goes with it — it is metadata // that becomes meaningless, and leaving it behind is what would dangle. // But only when EVERY check-in it names is going: a tag that also speaks // about something we keep is not ours to delete, and quietly dropping half // its meaning is worse than making the operator deal with it. Clusters are // never dropped this way; a cluster is a sync manifest, and one naming a // purged artifact blocks the plan below rather than being rewritten here. for (all, 0..) |rid, i| { if (refs[i].kind != .control) continue; if (refs[i].names.len == 0) continue; var any = false; var every = true; for (refs[i].names) |n| { if (seed.contains(n)) any = true else every = false; } if (any and every) try seed.put(gpa, rid, {}); } var seed_roots: std.ArrayList(Rid) = .empty; defer seed_roots.deinit(gpa); var keep_roots: std.ArrayList(Rid) = .empty; defer keep_roots.deinit(gpa); for (all, 0..) |rid, i| { if (seed.contains(rid)) { try seed_roots.append(gpa, rid); continue; } // ONLY TOP-LEVEL ARTIFACTS RETAIN. A blob and a submanifest exist in a // repository because something else names them; they are content, not // reasons to keep content. Treating every retained artifact as a root // makes `reachable(retained)` the whole store minus the seed, and the // set difference then frees nothing but the named check-ins themselves // — a purge that reports success and reclaims no bytes, which is the // one outcome indistinguishable from working. if (refs[i].kind) |k| { if (k != .submanifest) try keep_roots.append(gpa, rid); } } var reach_seed: RidSet = .empty; defer reach_seed.deinit(gpa); try reach(gpa, &index, refs, seed_roots.items, &reach_seed); // Everything retained needs, transitively — the half that decides which // candidates are actually free. Note the roots are ALL retained artifacts, // not only retained check-ins: a retained tag, wiki page or cluster keeps // what it points at just as firmly. var reach_keep: RidSet = .empty; defer reach_keep.deinit(gpa); try reach(gpa, &index, refs, keep_roots.items, &reach_keep); // A retained artifact that NAMES a seed member without containing it — // the tag with mixed targets, the cluster. It is not caught by the content // walk above, because naming is not reachability, and it is exactly the // case where dropping would leave a live pointer to absent bytes. for (all, 0..) |rid, i| { if (seed.contains(rid)) continue; for (refs[i].names) |n| { if (!seed.contains(n)) continue; for (targets) |*tg| { if (tg.rid == n) tg.blocker = .still_named; } blocked = true; } } if (blocked) return null; var drop: std.ArrayList(Rid) = .empty; var out: Plan = .{ .drop = &.{} }; for (all, 0..) |rid, i| { if (!reach_seed.contains(rid)) continue; if (reach_keep.contains(rid)) continue; // still referenced: not ours to free try drop.append(arena, rid); out.bytes += log.frameSpanOf(rid); switch (refs[i].kind orelse .submanifest) { .manifest => out.checkins += 1, .control => out.controls += 1, else => out.others += 1, } } // THE PROPERTY, RE-ASKED OF THE ANSWER. Every named check-in must have // survived the subtraction; if the retained side reached one, some // reference kind kept it alive that the checks above did not name, and the // right response is to refuse rather than to remove it anyway. This is the // backstop that makes "zero phantoms" a property of the OUTPUT rather than // a property of the cases we thought of. for (targets) |*tg| { if (reach_keep.contains(tg.rid)) { tg.blocker = .still_named; blocked = true; } } if (blocked) return null; out.drop = drop.items; if (out.checkins != targets.len) return error.PlanIncomplete; return out; } const t = std.testing; const storage = @import("storage.zig"); /// A two-check-in repository with one blob each and one blob in common: /// /// root ── root.txt, shared.txt /// │ /// leaf ── leaf.txt, shared.txt + a tag artifact naming leaf /// /// The shared blob is what makes the set difference observable — a purge of /// `leaf` that frees it has produced a phantom in `root`. const Fixture = struct { tmp: std.testing.TmpDir, file: std.Io.File, fs: storage.FileStorage, log: logstore.LogStore, root: Rid, leaf: Rid, tag: Rid, shared_blob: Rid, root_only_blob: Rid, leaf_only_blob: Rid, fn deinit(self: *Fixture, gpa: std.mem.Allocator) void { self.log.deinit(gpa); self.file.close(std.testing.io); self.tmp.cleanup(); } }; const Named = struct { rid: Rid, hex: []const u8 }; /// Store `body` under its own content hash and hand back the rid and the hex /// name the cards will carry. fn putBlob(arena: std.mem.Allocator, log: *logstore.LogStore, body: []const u8) !Named { var digest: [32]u8 = undefined; std.crypto.hash.sha3.Sha3_256.hash(body, &digest, .{}); return .{ .rid = try log.putArtifact(&digest, body, null), .hex = try std.fmt.allocPrint(arena, "{x}", .{&digest}), }; } /// Seal card text with its §6.2 Z self-checksum and store it. Built as text /// rather than through a `Manifest` literal so each test states the artifact /// it means in the form the format defines. fn putArtifactText(arena: std.mem.Allocator, log: *logstore.LogStore, cards: []const u8) !Named { var z: [32]u8 = undefined; _ = artifact.selfChecksum(cards, &z); return putBlob(arena, log, try std.fmt.allocPrint(arena, "{s}Z {s}\n", .{ cards, z })); } /// Initializes IN PLACE: `fx.log` borrows a `StorageFile` pointing into /// `fx.fs`, so the fixture must never be moved after the store is opened. fn buildFixture(gpa: std.mem.Allocator, arena: std.mem.Allocator, fx: *Fixture) !void { const io = std.testing.io; fx.tmp = std.testing.tmpDir(.{}); errdefer fx.tmp.cleanup(); fx.file = try fx.tmp.dir.createFile(io, "repo", .{ .read = true }); errdefer fx.file.close(io); fx.fs = storage.FileStorage.init(io, fx.file, 0); fx.log = try logstore.LogStore.open(gpa, fx.fs.storageFile(), 0); errdefer fx.log.deinit(gpa); const shared = try putBlob(arena, &fx.log, "bytes both check-ins name"); const root_only = try putBlob(arena, &fx.log, "bytes only the root names"); const leaf_only = try putBlob(arena, &fx.log, "bytes only the leaf names"); const root = try putArtifactText(arena, &fx.log, try std.fmt.allocPrint(arena, \\C root \\D 2026-01-01T00:00:00.000 \\F root.txt {s} \\F shared.txt {s} \\U dan \\ , .{ root_only.hex, shared.hex })); const leaf = try putArtifactText(arena, &fx.log, try std.fmt.allocPrint(arena, \\C leaf \\D 2026-01-02T00:00:00.000 \\F leaf.txt {s} \\F shared.txt {s} \\P {s} \\U dan \\ , .{ leaf_only.hex, shared.hex, root.hex })); const tag = try putArtifactText(arena, &fx.log, try std.fmt.allocPrint(arena, \\D 2026-01-03T00:00:00.000 \\T +sym-release {s} \\U dan \\ , .{leaf.hex})); try fx.log.commit(); fx.root = root.rid; fx.leaf = leaf.rid; fx.tag = tag.rid; fx.shared_blob = shared.rid; fx.root_only_blob = root_only.rid; fx.leaf_only_blob = leaf_only.rid; } test "purge: a leaf's exclusive blob is freed and its shared blob is not" { // The property the whole module exists for. Purging the leaf frees the // leaf's own blob and must NOT free the one `root` also names — that would // convert held content into a phantom in a check-in nobody asked to touch. // Its tag goes with it, because a tag about a removed check-in is what // would otherwise be left pointing at nothing. const gpa = t.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var fx: Fixture = undefined; try buildFixture(gpa, arena, &fx); defer fx.deinit(gpa); var targets = [_]Target{.{ .name = "leaf", .rid = fx.leaf }}; const p = (try plan(gpa, arena, &fx.log, &targets, null)) orelse { std.debug.print("unexpected blocker: {s}\n", .{@tagName(targets[0].blocker.?)}); return error.TestUnexpectedResult; }; try t.expectEqual(@as(usize, 1), p.checkins); try t.expectEqual(@as(usize, 1), p.controls); try t.expectEqual(@as(usize, 1), p.others); // leaf.txt's blob, and only it try t.expect(contains(p.drop, fx.leaf)); try t.expect(contains(p.drop, fx.tag)); try t.expect(contains(p.drop, fx.leaf_only_blob)); try t.expect(!contains(p.drop, fx.shared_blob)); try t.expect(!contains(p.drop, fx.root)); try t.expect(!contains(p.drop, fx.root_only_blob)); try t.expect(p.bytes > 0); } test "purge: the current checkout is refused" { const gpa = t.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var fx: Fixture = undefined; try buildFixture(gpa, arena, &fx); defer fx.deinit(gpa); var targets = [_]Target{.{ .name = "leaf", .rid = fx.leaf }}; try t.expect((try plan(gpa, arena, &fx.log, &targets, fx.leaf)) == null); try t.expectEqual(Blocker.current_checkout, targets[0].blocker.?); } test "purge: a blob named on the command line is not a check-in" { const gpa = t.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var fx: Fixture = undefined; try buildFixture(gpa, arena, &fx); defer fx.deinit(gpa); var targets = [_]Target{.{ .name = "a blob", .rid = fx.shared_blob }}; try t.expect((try plan(gpa, arena, &fx.log, &targets, null)) == null); try t.expectEqual(Blocker.not_a_checkin, targets[0].blocker.?); } test "purge: purging every check-in frees every blob including the shared one" { // The other end of the set difference: with nothing retained to reach it, // the shared blob IS free, and a plan that kept it would leave the operator // with bytes no artifact names — the bloat they were trying to remove. const gpa = t.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var fx: Fixture = undefined; try buildFixture(gpa, arena, &fx); defer fx.deinit(gpa); // `root` is a leaf OF THIS PURGE only because `leaf` is named alongside // it — the case a per-token leaf test would have refused. var targets = [_]Target{ .{ .name = "leaf", .rid = fx.leaf }, .{ .name = "root", .rid = fx.root }, }; const p = (try plan(gpa, arena, &fx.log, &targets, null)) orelse { std.debug.print("unexpected blocker: {s}\n", .{@tagName(targets[1].blocker.?)}); return error.TestUnexpectedResult; }; try t.expectEqual(@as(usize, 2), p.checkins); try t.expect(contains(p.drop, fx.shared_blob)); try t.expect(contains(p.drop, fx.root_only_blob)); try t.expect(contains(p.drop, fx.leaf_only_blob)); // Everything the fixture holds, so the store would be left empty. try t.expectEqual(@as(usize, 6), p.drop.len); } test "purge: naming the parent but not its child is refused" { const gpa = t.allocator; var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var fx: Fixture = undefined; try buildFixture(gpa, arena, &fx); defer fx.deinit(gpa); var targets = [_]Target{.{ .name = "root", .rid = fx.root }}; try t.expect((try plan(gpa, arena, &fx.log, &targets, null)) == null); try t.expectEqual(Blocker.not_a_leaf, targets[0].blocker.?); } fn contains(haystack: []const Rid, needle: Rid) bool { for (haystack) |h| if (h == needle) return true; return false; }