//! 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;
}
// ---------------------------------------------------------------------------
// `purge artifacts`: chosen old file versions.
//
// Fossil's `purge artifacts HASH...` moves arbitrary artifacts to the
// graveyard. zit narrows it to FILE VERSIONS, the case it exists for: thinning
// a long history of one file down to the versions worth keeping. A manifest,
// tag, cluster or sub-manifest node is structure other artifacts point at, and
// removing it orphans history rather than thinning it, so those are refused
// with a pointer to `purge checkins` or `shun`.
//
// The other guard is the reason this is safe to schedule: a version some open
// leaf (any branch tip) or the current checkout still holds is refused, with no
// override. Those are what `update` and the working tree need, and a store that
// cannot materialize its own tips has been damaged, not thinned. That check
// needs the derived repository (`Repository.hashesNamed`), so it lives in the
// verb; this half supplies what it searches by: the file names each target is
// stored under, read from every F card in the store.
// ---------------------------------------------------------------------------
/// Why a named artifact cannot be thinned.
pub const ArtifactBlocker = enum {
not_found,
ambiguous,
/// The store knows the name but holds no content for it.
not_held,
/// It parses as an artifact: structure, not a file version.
not_a_file,
/// An open leaf holds it.
open_leaf,
/// The current checkout holds it.
current_checkout,
};
/// One HASH the operator named under `purge artifacts`, classified in place.
pub const ArtifactTarget = struct {
name: []const u8,
rid: Rid = 0,
blocker: ?ArtifactBlocker = null,
/// What it parses as, when `not_a_file`.
kind: ?artifact.ArtifactType = null,
/// The file names F cards give it anywhere in the store, arena-owned and
/// unique. Empty for a blob nothing names.
paths: []const []const u8 = &.{},
};
/// Classify each unblocked target: refuse what is not a held file blob, and
/// collect the paths every F card in the store gives the rest. One pass over
/// the store, like `plan`. `arena` owns the paths.
pub fn scanArtifacts(gpa: std.mem.Allocator, arena: std.mem.Allocator, log: *logstore.LogStore, targets: []ArtifactTarget) Error!void {
var want: std.AutoHashMapUnmanaged(Rid, usize) = .empty;
defer want.deinit(gpa);
for (targets, 0..) |*tg, i| {
if (tg.blocker != null) continue;
if (log.isPhantom(tg.rid)) {
tg.blocker = .not_held;
continue;
}
const bytes = log.content(gpa, tg.rid) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.Corrupt,
} orelse {
tg.blocker = .not_held;
continue;
};
defer gpa.free(bytes);
if (artifact.parse(gpa, bytes)) |parsed| {
var m = parsed;
defer m.deinit();
tg.kind = m.kind;
tg.blocker = .not_a_file;
continue;
} else |_| {}
try want.put(gpa, tg.rid, i);
}
if (want.count() == 0) return;
var paths = try arena.alloc(std.ArrayList([]const u8), targets.len);
for (paths) |*l| l.* = .empty;
const all = try log.ridsInPersistedOrder(arena);
for (all) |rid| {
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);
var m = artifact.parse(gpa, bytes) catch continue; // a blob names nothing
defer m.deinit();
for (m.f) |f| {
const h = f.hash orelse continue;
const r = (try resolve(log, h)) orelse continue;
const i = want.get(r) orelse continue;
for (paths[i].items) |p| {
if (std.mem.eql(u8, p, f.filename)) break;
} else try paths[i].append(arena, try arena.dupe(u8, f.filename));
}
}
for (targets, 0..) |*tg, i| {
if (want.contains(tg.rid)) tg.paths = paths[i].items;
}
}
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.?);
}
test "purge artifacts: a file blob gets the paths that name it, structure 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 = [_]ArtifactTarget{
.{ .name = "shared", .rid = fx.shared_blob },
.{ .name = "leaf-only", .rid = fx.leaf_only_blob },
.{ .name = "a check-in", .rid = fx.leaf },
.{ .name = "a tag", .rid = fx.tag },
};
try scanArtifacts(gpa, arena, &fx.log, &targets);
try t.expect(targets[0].blocker == null);
// Named by both check-ins under one path: listed once.
try t.expectEqual(@as(usize, 1), targets[0].paths.len);
try t.expectEqualStrings("shared.txt", targets[0].paths[0]);
try t.expect(targets[1].blocker == null);
try t.expectEqualStrings("leaf.txt", targets[1].paths[0]);
try t.expectEqual(ArtifactBlocker.not_a_file, targets[2].blocker.?);
try t.expectEqual(artifact.ArtifactType.manifest, targets[2].kind.?);
try t.expectEqual(ArtifactBlocker.not_a_file, targets[3].blocker.?);
try t.expectEqual(artifact.ArtifactType.control, targets[3].kind.?);
}
fn contains(haystack: []const Rid, needle: Rid) bool {
for (haystack) |h| if (h == needle) return true;
return false;
}