Artifact 6920ddff87a2a146ea6f41f589cbed58f508fb4fa770a72826ae6e1f702d9648:
- File
src/control_paths.zig
— part of check-in
[f1ac625093]
at
2026-08-10 17:25:19
on branch sub-manifest
— control files: registry, provenance opt-in, one scratch prefix, advisory dir
Tasks 1-5 of the control-file consolidation plan. The checkout root goes from twelve zit-owned names to six live ones; the authoritative-tier container (task 6) and legacy removal (task 7) are deliberately not in this change.
Registry: control_paths now carries every reserved root name with its tier -- authoritative, advisory, scratch, legacy -- and is the single authority the case table, the extras exclusions and the ignore-glob coverage pin all derive from or test against. A name added without its glob line or case coverage is a test failure, proven by mutation. The pin caught two live gaps on arrival: a crashed update's transaction directory had no ignore-glob line, and a crashed import's staging directory was never reserved at all.
import-git --provenance: the per-object map and the source ref manifest are now instrumentation, published only on request. The default destination is the repository and its checkout record -- fossil's own shape. Both files are still written and verified in staging every run; without the flag they leave with the raw generation, and the staging manifest check enforces whichever set was chosen.
Scratch: one prefix. .zit-tmp-txn- (update transactions), .zit-tmp-import- (import staging), .zit-tmp-pack (pack scratch; name only, the truncate-reuse and rewrite-exclusion lock protocol are untouched). One registry prefix row and one glob line cover the tier including names that do not exist yet.
Advisory: .zit-cache is a directory -- repo (derived-state cache) and vstat (materialization plan), with the tree-checkpoint's member reserved. A legacy cache FILE is served through a read fallback and migrated by the first write via rename through a scratch name: the derivation survives with no bytes copied and no re-derive. Verified on a 66k-file pkgsrc checkout: identical SHA-256 across migration of a 35.8MB cache, fallback reads at parity, no-op update 7.69s vs 7.74s.
Legacy spellings of every renamed or relocated name stay reserved and ignore-globbed until the removal release. (user: dmestas, size: 5768) [annotate] [blame] [check-ins using]
const std = @import("std"); pub const WorktreePathError = error{ InvalidPath, ReservedPath, }; /// Valid relative working-tree paths use `/` separators, contain no empty or /// dot components, and never claim a reserved root. Import and update share /// this check so neither can admit a path the other would later reject. pub fn validateWorktreePath(path: []const u8) WorktreePathError!void { if (path.len == 0 or std.fs.path.isAbsolute(path)) return error.InvalidPath; if (std.mem.indexOfScalar(u8, path, '\\') != null) return error.InvalidPath; var components = std.mem.splitScalar(u8, path, '/'); const root = components.next() orelse return error.InvalidPath; if (root.len == 0 or std.mem.eql(u8, root, ".") or std.mem.eql(u8, root, "..")) return error.InvalidPath; if (isReservedRoot(path)) return error.ReservedPath; while (components.next()) |component| { if (component.len == 0 or std.mem.eql(u8, component, ".") or std.mem.eql(u8, component, "..")) return error.InvalidPath; } } /// What losing the file costs -- the property that dictates its format, its /// durability discipline, and who may delete it. The registry below is the /// single authority on which names zit owns at a checkout root; every other /// list (extras exclusions, the ignore-glob pins, the case tables) is derived /// from or tested against it, so a name added here without its glob line or /// case coverage is a TEST FAILURE, not quiet drift. pub const Tier = enum { /// Cannot be rebuilt: losing it loses work (checkout record, managed set, /// undo image, bisect session, and the store itself). authoritative, /// Discardable by construction, rebuilt on demand; deleting it must never /// change an answer (repocache, vstat). advisory, /// Exists only while an operation is in flight; a crash strands one, and a /// stranger must be able to identify and sweep it without understanding it. scratch, /// Import provenance the consolidation plan retires from the root. legacy, }; pub const Reserved = struct { name: []const u8, tier: Tier, /// A prefix entry reserves every root beginning with `name`, not one name. prefix: bool = false, }; pub const reserved = [_]Reserved{ .{ .name = ".zit", .tier = .authoritative }, .{ .name = ".zit-checkout", .tier = .authoritative }, .{ .name = ".zit-managed", .tier = .authoritative }, .{ .name = ".zit-undo", .tier = .authoritative }, .{ .name = ".zit-bisect", .tier = .authoritative }, .{ .name = ".zit-cache", .tier = .advisory }, // Legacy: the pre-directory vstat root name, reserved until the removal // release; the live sidecar is `.zit-cache/vstat`. .{ .name = ".zit-vstat", .tier = .legacy }, .{ .name = ".zit-tmp-", .tier = .scratch, .prefix = true }, // Legacy scratch spellings: still reserved so a dir a crashed OLD binary // stranded stays untrackable and extras-invisible until the removal // release. `.zit-import-tmp-` was never reserved at all -- a crashed // import's staging directory was admissible working-tree content, which // this row closes. .{ .name = ".zit-pack-tmp", .tier = .legacy }, .{ .name = ".zit-update-txn-", .tier = .legacy, .prefix = true }, .{ .name = ".zit-import-tmp-", .tier = .legacy, .prefix = true }, .{ .name = ".zit-import-map.jsonl", .tier = .legacy }, .{ .name = "refs", .tier = .legacy }, }; /// Root-level checkout metadata names that imported and materialized paths /// must never own. Inspects only the first slash-delimited path component. pub fn isReservedRoot(path: []const u8) bool { const slash = std.mem.indexOfScalar(u8, path, '/') orelse path.len; const root = path[0..slash]; for (reserved) |entry| { if (entry.prefix) { if (asciiStartsWithIgnoreCase(root, entry.name)) return true; } else if (asciiEqlIgnoreCase(root, entry.name)) return true; } return false; } fn asciiEqlIgnoreCase(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; for (a, b) |left, right| { if (std.ascii.toLower(left) != std.ascii.toLower(right)) return false; } return true; } fn asciiStartsWithIgnoreCase(value: []const u8, prefix: []const u8) bool { return value.len >= prefix.len and asciiEqlIgnoreCase(value[0..prefix.len], prefix); } test "control path policy reserves complete roots case-insensitively" { // Generated from the registry, so a new entry gets its coverage by // existing: the bare name, a child path, a case-flipped copy, and the // near-miss (`name ++ "x"`) -- which for a PREFIX entry must be reserved // rather than refused, that being what a prefix means. inline for (reserved) |entry| { try std.testing.expect(isReservedRoot(entry.name)); try std.testing.expect(isReservedRoot(entry.name ++ "/sub")); var flipped: [entry.name.len]u8 = undefined; for (entry.name, 0..) |c, i| { flipped[i] = if (std.ascii.isLower(c)) std.ascii.toUpper(c) else std.ascii.toLower(c); } try std.testing.expect(isReservedRoot(&flipped)); try std.testing.expectEqual(entry.prefix, isReservedRoot(entry.name ++ "x")); } // Hand-written rows the generator cannot derive: shapes that LOOK like // control names and must not be. const negatives = [_][]const u8{ "", "zit", ".zit-other/data", ".zit-cacheable/data", ".zit-vstats/x", ".zit-checkout-user/file", ".zit-update-txn", ".zit-update-txns/user", "refs-old/heads/main", "sub/.zit/object", }; for (negatives) |path| try std.testing.expect(!isReservedRoot(path)); }