Artifact 46b079b974838f92b9508a647c0a89e3747fec09dab072a0bae2c0d0639c4831:
- File
src/cmd/tracking.zig
— part of check-in
[ed32969b32]
at
2026-07-20 01:46:43
on branch s3-tracking
— Extract the tracking verb family into src/cmd/tracking.zig
Release step S3, the second of four family extractions from main.zig. A pure behaviour-preserving move: the eight working-tree tracking handlers -- add, rm, mv, status, extras, clean, addremove, diff -- and the helpers and tests private to them move verbatim into src/cmd/tracking.zig, byte-identical to their source but for the pub visibility the dispatch switch forces on the moved handlers. Helpers shared with other verbs (runStat, commit, merge, revert) stay pub in main.zig and are aliased back so the moved bodies read unchanged. No helper is duplicated and no cross-cmd import is created; runInit and runStat stay in main.zig. The test count is unchanged, so nothing was orphaned. main.zig keeps main, the verb dispatch, and the shared helpers. (user: dmestas, size: 102598) [annotate] [blame] [check-ins using] [more...]
//! zit working-tree tracking verbs (add, rm, mv, status, extras, clean, //! addremove, diff) and the helpers and tests private to them. //! //! Extracted verbatim from src/main.zig (release step S3). Every declaration //! below is byte-identical to its main.zig source; only this import prologue is //! new. Helpers still shared with other verbs stay in main.zig and are aliased //! back here so the moved bodies call them unqualified. main.zig's verb-dispatch //! switch calls into this file (`cmd_tracking.runAdd` ...); its test block imports //! it so these inline tests keep running under `zig build test`. const std = @import("std"); const zit = @import("zit"); const cli = @import("../cli.zig"); const extras = @import("../extras.zig"); const logstore = @import("zit").logstore; const storage = @import("zit").storage; const checkout = @import("zit").checkout; const Io = std.Io; const File = Io.File; const managed = zit.managed; const artifact = zit.artifact; // Shared CLI helpers and test fixtures that stay in main.zig (other verbs use // them too); aliased back so the moved code and tests reference them by their // original unqualified names. const main = @import("../main.zig"); const symlink_unversioned = main.symlink_unversioned; const repo_filename = main.repo_filename; const reasonOf = main.reasonOf; const checkoutFail = main.checkoutFail; const managedFail = main.managedFail; const stateOom = main.stateOom; const max_tracked_file_bytes = main.max_tracked_file_bytes; const trackedFileSizeAllowed = main.trackedFileSizeAllowed; const executable = main.executable; const not_a_repo_msg = main.not_a_repo_msg; const diff_help = main.diff_help; const add_help = main.add_help; const rm_help = main.rm_help; const mv_help = main.mv_help; const status_help = main.status_help; const extras_help = main.extras_help; const clean_help = main.clean_help; const addremove_help = main.addremove_help; /// The per-path outcome of classifying one `add` argument. A tagged union so /// the runner renders each case to the correct stream and folds it into the /// exit code, with the managed-set key carried on the two cases that print it. const Outcome = union(enum) { /// Newly inserted; `added <key>` to stdout (`would add` under -n). added: []const u8, /// Already in the set; informational notice to stderr (B-add-3). already_tracked: []const u8, no_such_file, // B-add-4 /// A directory inside the checkout: `null` is the checkout root itself /// (`zit add .`), otherwise the resolved root-relative directory key. The /// verb layer expands it over one bounded `extras.sweep` (P3-F4). directory: ?[]const u8, // B-add-5 not_regular, // B-add-5: a fifo/socket/device — never a symlink /// A symlink: refused in 0.1.0, with its own wording (F-B3). symlink, // B-add-5 outside_root, // B-add-6 control_file, // B-add-7 /// A bound was exceeded on insert (TooLong/TooMany) or allocation failed; /// rendered by `addFail` with the offending arg (B-add-17). insert_error: managed.ManagedError, }; /// `zit add [-n] [-q] [--] <path>...` — the thin wiring: parse, discover the /// checkout root, load the managed set, classify/insert each named path in /// argument order (do-as-much-as-possible), persist once atomically, and fold /// the per-path outcomes into one exit code. Touches working-copy state only; /// no log append, no network. (contracts/add.md B-add-1..18.) pub fn runAdd( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-add-1). --help wins over everything, including a missing path // (B-add-13); an empty path list is the B-add-9 usage error, detected // before any repo discovery or I/O. const parsed = switch (cli.parseAddArgs(gpa, verb_args) catch return addFail(streams, "", error.OutOfMemory)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "add", .unknown_option, tok), }; defer gpa.free(parsed.paths); if (parsed.help) { streams.out.writeAll(add_help) catch {}; return .ok; } if (parsed.paths.len == 0) return cli.usageError(streams, "add", .no_paths, ""); // 2. Discover the checkout root (B-add-8). Absent → not a zit repository. const root = (cli.discoverRoot(arena, io) catch return addFail(streams, "", error.OutOfMemory)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // Uses the grow-buffer helper (not std's panicking `currentPathAlloc`); a // cwd we cannot name after discovery succeeded is a storage failure. const cwd_abs = (cli.currentPathAlloc(arena, io) catch return addFail(streams, "", error.OutOfMemory)) orelse return addFail(streams, "", error.Storage); // 3. Load the current set (absent file ⇒ empty; B-add-17 on a bad read). var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return addFail(streams, "", e); defer set.deinit(); // 4. Process each path in argument order (B-add-2..7/-11/-12/-15). A // directory argument is expanded from ONE `extras.sweep` of the whole // checkout root, swept lazily on the first directory argument and reused // for every later one (P3-F4): the same walk, the same exclusions, the // same symlink posture `addremove` uses — never a second walker. var failed = false; var changed = false; var swept: ?extras.Extras = null; defer if (swept) |*s| s.deinit(); for (parsed.paths) |arg| { const outcome = classifyPath(arena, io, root.abs, root_dir, cwd_abs, &set, arg) catch { failed = true; _ = addFail(streams, arg, error.OutOfMemory); continue; }; switch (outcome) { .added => |key| { changed = true; if (!parsed.quiet) { const verb_word: []const u8 = if (parsed.dry_run) "would add" else "added"; streams.out.print("{s} {s}\n", .{ verb_word, key }) catch {}; } }, .already_tracked => |key| { if (!parsed.quiet) streams.err.print("zit: '{s}' is already tracked\n", .{key}) catch {}; }, .no_such_file => { failed = true; streams.err.print("zit add: '{s}': no such file\n", .{arg}) catch {}; }, .directory => |dir_key| { // Every untracked file beneath `dir_key` (the whole tree when // it is null) becomes an add, in the sweep's ascending order. // The sweep already subtracts the tracked set and the depth-0 // control names, so neither can be re-inserted or re-reported // here; `insert`'s false branch only fires when an earlier // argument in this same run already took the path, which is // silent by design (it was never named). const items = sweepRootOnce(gpa, io, root.abs, &set, &swept) catch |e| { failed = true; _ = sweepFail(streams, "add", e); continue; }; for (items) |entry| { const path = entry.path; if (!pathUnderDir(path, dir_key)) continue; // 0.1.0 does not version symlinks, so the recursive path // must skip exactly what explicit `add` and `commit` both // refuse (F-B3): tracking one here is what wedged `commit`. // The kind is the sweep's own lstat verdict — never // re-derived here, or the two paths could drift again. if (entry.is_symlink) { if (!parsed.quiet) streams.err.print( "zit add: skipped '{s}': " ++ symlink_unversioned ++ "\n", .{path}, ) catch {}; continue; } const inserted = set.insert(path) catch |e| { failed = true; _ = addFail(streams, path, e); continue; }; if (!inserted) continue; changed = true; if (!parsed.quiet) { const verb_word: []const u8 = if (parsed.dry_run) "would add" else "added"; streams.out.print("{s} {s}\n", .{ verb_word, path }) catch {}; } } }, .not_regular => { failed = true; streams.err.print("zit add: '{s}' is not a regular file\n", .{arg}) catch {}; }, .symlink => { failed = true; streams.err.print( "zit add: '{s}': " ++ symlink_unversioned ++ "\n", .{arg}, ) catch {}; }, .outside_root => { failed = true; streams.err.print("zit add: '{s}' is outside the repository\n", .{arg}) catch {}; }, .control_file => { failed = true; streams.err.print( "zit add: '{s}' is a zit control file and cannot be tracked\n", .{arg}, ) catch {}; }, .insert_error => |e| { failed = true; _ = addFail(streams, arg, e); }, } } // 5. Persist once, atomically, only if the set actually changed and this is // not a dry run (B-add-11/-16). A write failure leaves the prior file // wholly intact (ManagedSet.write is write-temp → fsync → rename). if (changed and !parsed.dry_run) { set.write(io, root_dir) catch |e| return addFail(streams, "", e); } // 6. Fold outcomes into one code (B-add-18): valid additions persist even // when a sibling path failed (partial success). return if (failed) .failure else .ok; } /// Whether the root-relative `key` lies strictly beneath the directory `dir` /// (`null` = the checkout root, under which everything lies). The ONE /// directory-containment predicate both verbs use — `add`'s sweep filter and /// `rm`'s tracked-key expansion — so the boundary case cannot diverge between /// them: `src2/c` is NOT under `src`, because containment requires the `/` /// separator immediately after the directory's bytes. A directory never /// contains itself (a key equal to `dir` is the file/entry case, decided /// before this is reached). Pure; no I/O, no allocation. fn pathUnderDir(key: []const u8, dir: ?[]const u8) bool { const d = dir orelse return true; std.debug.assert(d.len != 0); // the root is spelled `null`, never "" return key.len > d.len + 1 and key[d.len] == '/' and std.mem.startsWith(u8, key, d); } /// The untracked working-tree files under the checkout root, swept at most ONCE /// per `add` invocation and memoized in `cache` (P3-F4). The first directory /// argument pays for the walk; every later one filters the same result, so `zit /// add a b c` over three directories still performs a single enumeration. The /// root is re-opened ITERABLE with no-follow, exactly as `addremove`/`clean` do, /// so a root symlink is never entered. Borrows `cache`; the slice is valid until /// the caller deinits it. fn sweepRootOnce( gpa: std.mem.Allocator, io: std.Io, root_abs: []const u8, set: *const managed.ManagedSet, cache: *?extras.Extras, ) extras.SweepError![]const extras.Entry { if (cache.*) |*done| return done.items(); var iter_root = Io.Dir.cwd().openDir(io, root_abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return error.Storage; defer iter_root.close(io); cache.* = try extras.sweep(gpa, io, iter_root, set); return cache.*.?.items(); } /// Classifies one raw `add` argument into an `Outcome` — all of add's /// filesystem access lives here (design-seams rule). Order is chosen so the most /// specific message wins: resolve to a repo-relative key (escape → outside), /// reject control files, stat for existence/kind, then membership/insert. Only /// allocation failure escapes as an error; every other condition is an /// `Outcome`. fn classifyPath( gpa: std.mem.Allocator, io: std.Io, root_abs: []const u8, root_dir: std.Io.Dir, cwd_abs: []const u8, set: *managed.ManagedSet, arg: []const u8, ) error{OutOfMemory}!Outcome { // 1. Normalize to a repo-relative key. An escape is B-add-6; the root dir // itself is B-add-5's directory case (e.g. `zit add .` at the root), not // an escape. const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .outside_root, .root => return .{ .directory = null }, .key => |k| k, }; // 2. A key beyond the managed-set bound cannot be tracked (B-add-17 // error.TooLong). Checked here, not via stat: a name-too-long key makes // the OS stat fail with NameTooLong, which would otherwise mask the honest // "path is too long" message as "no such file". Same bound // `ManagedSet.insert` enforces (`managed.max_path_len`). if (key.len > managed.max_path_len) return .{ .insert_error = error.TooLong }; // 3. The three control files are never version-controlled (B-add-7). Checked // before stat because they exist as real files and would otherwise add. if (isControlFile(key)) return .control_file; // 4. Existence and file-kind (B-add-4/-5). Symlinks are not followed, so a // symlink is rejected as a symlink, not silently resolved to its target. const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch return .no_such_file; switch (st.kind) { .directory => return .{ .directory = key }, .file => {}, // A symlink gets its own refusal (F-B3) so the message matches the skip // notice `add <dir>` prints for the same entry; a fifo/socket/device // keeps the generic non-regular wording. .sym_link => return .symlink, else => return .not_regular, } // 5. Membership then insert (B-add-2/-3). `contains` first keeps a re-add // error-free; `insert`'s false branch is race-defensive idempotency. if (set.contains(key)) return .{ .already_tracked = key }; const inserted = set.insert(key) catch |e| return .{ .insert_error = e }; return if (inserted) .{ .added = key } else .{ .already_tracked = key }; } /// Whether `key` names a repo-internal control entry that is never tracked /// (B-add-7, B-undo-13). Delegates to `extras.isControlName`, the single /// never-tracked predicate shared with `extras` (and later `clean`), so the /// tracked/never-tracked boundary cannot drift. That set is a STRICT SUPERSET /// of add/rm's original four names (`.zit`, `.zit-checkout`, `.zit-managed`, /// `.zit-undo`), additionally refusing the transient artifacts `.zit-cache` and /// `.zit-pack-tmp` — names `zit` creates, never user content (B-extras-5). fn isControlFile(key: []const u8) bool { return extras.isControlName(key); } /// Renders a B-add-17 managed-state failure for `arg` to stderr and returns /// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new /// variant fails to compile, catching drift. `arg` is used only by `TooLong` /// (the per-path bound); the read/write-context errors carry no path. fn addFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit add: cannot read tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit add: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit add: '{s}': path is too long to track\n", .{arg}) catch {}, error.TooMany => s.err.writeAll("zit add: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit add: out of memory\n") catch {}, } return .failure; } // ========================================================================== // `zit rm` — add's mirror over the per-checkout managed-path set. Forgets named // paths (default: disk untouched); `--hard` also deletes the working file after // the set is durably written. Thin consumer: no logic added to any library // module, no `std.http`. (contracts/rm.md B-rm-1..19.) // ========================================================================== /// The per-path outcome of classifying one `rm` argument. A tagged union so the /// runner renders each case to the correct stream and folds it into the exit /// code. `forgotten`/`not_tracked` carry the normalized managed-set key (the /// identity `contains`/`remove` operate on and, per B-rm-15, that `rm` reports); /// the three resolution-time rejects carry no payload and are rendered against /// the raw arg (exactly as `add` renders its rejects). const RmOutcome = union(enum) { /// Was in the set and is now removed; `removed <key>` (`would remove` -n). forgotten: []const u8, /// A resolved key not in the set (B-rm-4 error notice to stderr). not_tracked: []const u8, /// A directory argument with at least one tracked key beneath it: `null` is /// the checkout root (`zit rm .`), otherwise the resolved root-relative /// directory key. Expanded over the tracked set by the verb layer (P3-F4). directory: ?[]const u8, // B-rm-5 outside_root, // B-rm-6 control_file, // B-rm-7 }; /// `zit rm [-n] [--hard] [-q] [--] <path>...` — the thin wiring: parse, discover /// the checkout root, load the managed set, classify/forget each named path in /// argument order (do-as-much-as-possible), persist once atomically, then — /// strictly after a successful write — delete the `--hard` working files. Folds /// the per-path outcomes into one exit code. Touches working-copy state only /// (`.zit-managed`, and under `--hard` the named files); no log append, no /// network. (contracts/rm.md B-rm-1..19.) pub fn runRm( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-rm-1). --help wins over everything, including a missing path // (B-rm-13); an empty path list is the B-rm-9 usage error, detected // before any repo discovery or I/O (B-rm-9). Unknown option → B-rm-10. const parsed = switch (cli.parseRmArgs(gpa, verb_args) catch return rmFail(streams, "", error.OutOfMemory)) { .ok => |p| p, .unknown_option => |tok| { // A git user reaches for `--force`/`-f`, but `rm` is safe by default // (files are kept on disk) so forcing is meaningless. Teach the model // in one line instead of a bare "unknown option" (DX). Still a usage // error: exit 2, stderr only. if (std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f")) { streams.err.writeAll( "zit rm: never needs forcing: files are kept on disk by default; pass --hard to delete from disk\n", ) catch {}; return .usage; } return cli.usageError(streams, "rm", .unknown_option, tok); }, }; defer gpa.free(parsed.paths); if (parsed.help) { streams.out.writeAll(rm_help) catch {}; return .ok; } if (parsed.paths.len == 0) return cli.usageError(streams, "rm", .no_paths, ""); // 2. Discover the checkout root (B-rm-8). Absent → not a zit repository. const root = (cli.discoverRoot(arena, io) catch return rmFail(streams, "", error.OutOfMemory)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // kept open for the --hard unlink pass const cwd_abs = (cli.currentPathAlloc(arena, io) catch return rmFail(streams, "", error.OutOfMemory)) orelse return rmFail(streams, "", error.Storage); // 3. Load the current set (absent file ⇒ empty; B-rm-17 on a bad read). var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return rmFail(streams, "", e); defer set.deinit(); // 4. Process each path in argument order (B-rm-2/-4..7/-11/-15). Collect the // forgotten keys (in argument order) rather than reporting them inline: // the `removed`/`would remove` stdout lines are WITHHELD until the atomic // write has succeeded (step 5b), so a persistence failure (B-rm-17) never // claims a removal that did not happen — `zit rm <files> | wc -l` counts // exactly the forgets that were durably written (B-rm-14). The same list // drives the `--hard` unlink pass (B-rm-16: persist before destroy). // Per-path error/notice diagnostics still print inline: they are // independent of the write and belong on stderr regardless of its fate. var failed = false; var changed = false; var forgotten: std.ArrayList([]const u8) = .empty; for (parsed.paths) |arg| { const outcome = classifyRmPath(arena, root.abs, cwd_abs, &set, arg) catch { failed = true; _ = rmFail(streams, arg, error.OutOfMemory); continue; }; switch (outcome) { .forgotten => |key| { changed = true; forgotten.append(arena, key) catch return rmFail(streams, arg, error.OutOfMemory); }, .not_tracked => |key| { failed = true; streams.err.print("zit rm: '{s}' is not tracked\n", .{key}) catch {}; }, .directory => |dir_key| { // Expand the directory over the TRACKED keys (P3-F4). The keys // are copied into the arena BEFORE any removal, because // `iterate()`'s slice and each key's backing bytes are // invalidated by the first `remove` — the same discipline // `addremove` uses for its remove set. Ascending order is // preserved (iterate is sorted), and every key still passes the // per-key control-file guard (B-rm-7) before it is forgotten. var under: std.ArrayList([]const u8) = .empty; for (set.iterate()) |tracked| { if (!pathUnderDir(tracked, dir_key)) continue; const owned = arena.dupe(u8, tracked) catch return rmFail(streams, arg, error.OutOfMemory); under.append(arena, owned) catch return rmFail(streams, arg, error.OutOfMemory); } for (under.items) |k| { if (isControlFile(k)) { failed = true; streams.err.print( "zit rm: '{s}' is a zit control file and cannot be tracked\n", .{k}, ) catch {}; continue; } if (!set.remove(k)) continue; // unreachable in practice; never claim a forget changed = true; forgotten.append(arena, k) catch return rmFail(streams, arg, error.OutOfMemory); } }, .outside_root => { failed = true; streams.err.print("zit rm: '{s}' is outside the repository\n", .{arg}) catch {}; }, .control_file => { failed = true; streams.err.print( "zit rm: '{s}' is a zit control file and cannot be tracked\n", .{arg}, ) catch {}; }, } } // 5. Persist once, atomically, only if the set changed and this is not a dry // run (B-rm-16). Returning on a write failure guarantees NOTHING below // runs — neither the stdout report (5b) nor the --hard unlink (6): a write // failure claims no removal on stdout and unlinks nothing, leaving every // file both on disk and still tracked (B-rm-16/-17/-14). if (changed and !parsed.dry_run) { set.write(io, root_dir) catch |e| return rmFail(streams, "", e); } // 5b. Report the forgets on stdout — only now that they are durable (or, in a // dry run, never persisted but truthfully previewed). Argument order is // preserved (the collection order). Held until here so a failed write // never prints a `removed` line for a forget that did not survive // (B-rm-2/-11/-14). Under a real `--hard` run the disposition is NOT yet // known — it depends on the per-file unlink outcome — so those lines are // withheld and emitted in step 6, keyed on the actual deletion, so the // `(deleted from disk)` suffix can never claim a deletion that failed // (B-rm-3/-4/-14/-18). A dry run never unlinks, so `-n --hard` previews // `(deleted from disk)` here truthfully (subjunctive; B-rm-11). const defer_report = parsed.hard and !parsed.dry_run; if (!parsed.quiet and !defer_report) { // D1/D6: state the resulting file disposition in the line so the user is // never left guessing whether their working copy survived, while keeping // the leading `removed <key>` / `would remove <key>` tokens stable so the // output stays pipe-parseable and `zit rm <files> | wc -l` still counts // one line per removal (B-rm-2/-11/-14). The verb word tracks `-n`; the // suffix tracks `--hard` (only reachable here as a dry-run preview). const verb_word: []const u8 = if (parsed.dry_run) "would remove" else "removed"; const disposition: []const u8 = if (parsed.hard) "(deleted from disk)" else "(file kept on disk)"; for (forgotten.items) |key| streams.out.print("{s} {s} {s}\n", .{ verb_word, key, disposition }) catch {}; } // 6. --hard: delete each forgotten working file, strictly after the write // succeeded (B-rm-3/-16/-18). An already-absent file is success (the "no // working file" postcondition holds). The stdout `removed <key> (deleted // from disk)` line is emitted HERE, keyed on the REAL outcome: only a // deleted (or already-gone) file prints it (B-rm-3); a file that could not // be unlinked is a failed path, so its diagnostic goes to stderr and // NOTHING about it reaches stdout (B-rm-4/-14/-18) — the disposition suffix // can never lie. The forget itself already stands and is persisted (honest // partial). `-q` suppresses the stdout line but never the error. if (defer_report) { for (forgotten.items) |key| { const deleted = if (root_dir.deleteFile(io, key)) |_| true else |e| switch (e) { error.FileNotFound => true, // already gone ⇒ postcondition holds (B-rm-3) else => blk: { failed = true; streams.err.print( "zit rm: '{s}': removed from tracking but could not delete from disk: {s}\n", .{ key, reasonOf(e) }, ) catch {}; break :blk false; }, }; if (deleted and !parsed.quiet) streams.out.print("removed {s} (deleted from disk)\n", .{key}) catch {}; } } // 7. Fold outcomes into one code (B-rm-19): successful forgets persist even // when a sibling path failed (partial success). return if (failed) .failure else .ok; } /// Classifies one raw `rm` argument into an `RmOutcome`. Filesystem-free (unlike /// `add`'s `classifyPath`): membership in the set decides, not the disk, so a /// managed path whose working file the user already deleted is still forgettable /// (contracts/rm.md Model). Order is chosen so the most specific message wins: /// resolve to a repo-relative key (escape → outside, root → directory), reject /// control files, then membership. Only allocation failure escapes as an error. fn classifyRmPath( gpa: std.mem.Allocator, root_abs: []const u8, cwd_abs: []const u8, set: *managed.ManagedSet, arg: []const u8, ) error{OutOfMemory}!RmOutcome { // 1. Normalize to a repo-relative key. An escape is B-rm-6; the root dir // itself is B-rm-5's directory case (e.g. `zit rm .` at the root), which // means every tracked key — unless nothing is tracked at all, in which // case there is nothing to forget and the existing not-tracked // diagnostic tells the truth (no new diagnostic class, P3-F4). const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .outside_root, .root => return if (set.iterate().len == 0) .{ .not_tracked = arg } else .{ .directory = null }, .key => |k| k, }; // 2. The three control files are never version-controlled and must never be // `--hard`-unlinked (B-rm-7). Checked BEFORE membership: a belt-and- // suspenders guard so even a corrupt set holding a control-file key could // not get it deleted. No max_path_len guard is needed (unlike add): an // over-length key is simply absent from the set → falls to not_tracked. if (isControlFile(key)) return .control_file; // 3. Membership then forget (B-rm-2/-4). `remove` returns whether it was // present (managed.zig:114): true ⇒ forgotten, false ⇒ never tracked. It // is a no-op on an absent key, so the membership verdict and the forget // fold into one call — no separate `contains` needed. if (set.remove(key)) return .{ .forgotten = key }; // 4. Not an entry itself: it may still be a directory holding tracked keys // (P3-F4). The verdict is a PURE prefix scan of the tracked set — never a // directory walk — so `zit rm <dir>` still forgets a directory the user // already deleted from disk, which is rm's whole model (see the doc // comment above). An argument matching neither an entry nor any tracked // prefix stays not_tracked: no new diagnostic class. for (set.iterate()) |tracked| { if (pathUnderDir(tracked, key)) return .{ .directory = key }; } return .{ .not_tracked = key }; } /// Renders a B-rm-17 managed-state failure for `arg` to stderr and returns /// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new /// variant fails to compile, catching drift. Only `Corrupt`/`Storage`/ /// `OutOfMemory` are reachable from `rm`'s flow (`remove` never errors; `insert` /// is never called), so `TooLong`/`TooMany` exist only for exhaustiveness. fn rmFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit rm: cannot read tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit rm: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit rm: '{s}': path is too long to track\n", .{arg}) catch {}, error.TooMany => s.err.writeAll("zit rm: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit rm: out of memory\n") catch {}, } return .failure; } // ========================================================================== // `zit mv OLD NEW` — rename a tracked file. Moves the working file on disk, // swaps the tracked-name entry in the managed set, and RECORDS the rename so // the next `zit commit` emits the §4.7.6 four-token old-name F card. A thin // consumer of the existing managed-set + repo-relative machinery; the one new // mechanism (the recorded rename) is hidden inside managed.zig. // (contracts/mv.md B-mv-1..18.) // ========================================================================== /// The outcome of resolving one `mv` operand to a repo-relative key, or the /// refusal phrase to render against the raw operand (rendered as /// `zit mv: '<arg>' <phrase>`). A directory (the root) and an escape are both /// refusals: mv renames a single tracked file, never a directory or an outside /// path (contracts/mv.md Non-goals). const MvResolved = union(enum) { key: []const u8, refuse: []const u8, }; /// Resolves one raw `mv` operand to a normalized repo-relative key, or a refusal /// phrase. Filesystem-free: only string resolution + the control-file guard. fn mvResolveKey( gpa: std.mem.Allocator, root_abs: []const u8, cwd_abs: []const u8, arg: []const u8, ) error{OutOfMemory}!MvResolved { const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .{ .refuse = "is outside the repository" }, .root => return .{ .refuse = "is a directory; mv renames a single tracked file" }, .key => |k| k, }; if (isControlFile(key)) return .{ .refuse = "is a zit control file and cannot be tracked" }; return .{ .key = key }; } /// Whether `key` names an existing entry directly under `dir` (symlinks are not /// followed, so a dangling symlink still counts as present — mv must not clobber /// it). Any stat failure reads as absent. fn mvOnDisk(io: std.Io, dir: std.Io.Dir, key: []const u8) bool { _ = dir.statFile(io, key, .{ .follow_symlinks = false }) catch return false; return true; } /// The directory component of `key`, or null when `key` sits at the checkout /// root (which always exists, so there is nothing to check). fn mvParentDir(key: []const u8) ?[]const u8 { return std.fs.path.dirnamePosix(key); } /// Whether `key`'s parent directory exists and is a directory. A path at the /// checkout root is always satisfied. Symlinks ARE followed here: a symlink to /// a real directory is a usable rename destination, unlike `mvOnDisk`'s /// collision check where a dangling link must still count as occupied. fn mvParentOnDisk(io: std.Io, dir: std.Io.Dir, key: []const u8) bool { const parent = mvParentDir(key) orelse return true; const st = dir.statFile(io, parent, .{}) catch return false; return st.kind == .directory; } /// `zit mv OLD NEW` — the thin wiring: parse, discover the checkout root, load /// the managed set, resolve+validate both operands, refuse a collision, then /// (all-or-nothing) move the working file and then swap the managed entry + /// record the rename and persist it durably, rolling the move back if the /// persist fails (B-mv-2 / B-commit-15 order). /// Touches working-copy state only (`.zit-managed` and the renamed file); no log /// append, no network. (contracts/mv.md B-mv-1..18.) pub fn runMv( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-mv-12/-13/-14). --help wins over everything (B-mv-12). const parsed = switch (cli.parseMvArgs(verb_args)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "mv", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "mv", .extra_argument, tok), }; if (parsed.help) { streams.out.writeAll(mv_help) catch {}; return .ok; } // mv takes EXACTLY two operands: a missing one is the exit-2 usage class, // stderr-only with the try-help line (B-mv-14). const old_arg = parsed.old orelse return mvMissingOperand(streams); const new_arg = parsed.new orelse return mvMissingOperand(streams); // 2. Discover the checkout root (B-mv-15) — before any tracked/disk check. const root = (cli.discoverRoot(arena, io) catch return mvFail(streams, "", error.OutOfMemory)) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); const cwd_abs = (cli.currentPathAlloc(arena, io) catch return mvFail(streams, "", error.OutOfMemory)) orelse return mvFail(streams, "", error.Storage); // 3. Load the managed set (absent ⇒ empty; B-mv-16 on a bad read, before any // move). var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return mvFail(streams, "", e); defer set.deinit(); // 4. Resolve both operands to normalized repo-relative keys. const old_key = switch (mvResolveKey(arena, root.abs, cwd_abs, old_arg) catch return mvFail(streams, "", error.OutOfMemory)) { .key => |k| k, .refuse => |ph| return mvRefuse(streams, old_arg, ph), }; const new_key = switch (mvResolveKey(arena, root.abs, cwd_abs, new_arg) catch return mvFail(streams, "", error.OutOfMemory)) { .key => |k| k, .refuse => |ph| return mvRefuse(streams, new_arg, ph), }; // 5. Reject any illegal rename BEFORE mutating (B-mv-3/-4/-5 plus the // source-exists and dest-parent-exists guards). The gauntlet leaves // stderr set and returns the exit for the first violation; null means // the move cannot fail on an absent source or a missing dest directory. if (mvCheckLegal(io, root_dir, &set, old_key, new_key, streams)) |exit| return exit; // 6. Commit: move the file, THEN record + persist durably, rolling the move // back if the persist fails (B-mv-2). return mvCommit(io, root_dir, &set, old_key, new_key, new_arg, streams); } /// The pre-mutation legality gauntlet for a rename. Returns the `.failure` exit /// (diagnostic already on stderr) for the first violated condition, or null if /// the rename is legal. Order fixes which message wins when several hold: /// OLD must be tracked (B-mv-3); NEW must not be tracked (B-mv-4) — also catches /// NEW==OLD, which is tracked; NEW must not exist on disk (B-mv-5), also catching /// NEW==OLD on disk; OLD must exist on disk, so the caller's move cannot fail on /// an absent source (defines the disk-rename failure out of the mutation path). fn mvCheckLegal( io: std.Io, root_dir: std.Io.Dir, set: *const managed.ManagedSet, old_key: []const u8, new_key: []const u8, streams: cli.Streams, ) ?cli.Exit { if (!set.contains(old_key)) { streams.err.print("zit mv: '{s}' is not tracked\n", .{old_key}) catch {}; return .failure; } if (set.contains(new_key)) { streams.err.print("zit mv: '{s}' is already tracked\n", .{new_key}) catch {}; return .failure; } if (mvOnDisk(io, root_dir, new_key)) { streams.err.print("zit mv: '{s}' already exists on disk\n", .{new_key}) catch {}; return .failure; } if (!mvOnDisk(io, root_dir, old_key)) { streams.err.print("zit mv: '{s}' is tracked but missing from the working tree\n", .{old_key}) catch {}; return .failure; } // The most common way a legal-looking rename fails at the syscall: NEW's // parent directory does not exist. `mvOnDisk(new_key)` reads false in that // case (stat of a path under a missing directory fails), so without this // check the gauntlet passes and the failure only surfaces mid-move. Catch // it here and the refusal is a clean zero-state-change one that names the // directory the user has to create. if (!mvParentOnDisk(io, root_dir, new_key)) { streams.err.print( "zit mv: cannot move into '{s}': no such directory\n", .{mvParentDir(new_key).?}, ) catch {}; return .failure; } return null; } /// Performs the rename once `mvCheckLegal` has passed. MOVES THE WORKING FILE /// FIRST, then swaps the managed entry and persists the set durably (B-mv-2 / /// B-commit-15: write-temp + fsync + rename-replace). If the persist fails the /// working file is renamed back, so a failed mv is a zero state change and the /// gauntlet's refusals are the only way a caller sees "nothing happened". /// /// Move-first-with-rollback rather than record-first-with-record-rollback: undoing /// a durable record write needs a second durable write that can itself fail, /// nesting the failure; renaming a file back cannot realistically fail, since the /// source directory that existed a moment ago still exists and the source name is /// still free (nothing else can claim it — mv holds no lock, but the window is a /// single syscall pair). /// /// Residual window, stated honestly: a crash BETWEEN the move and the record /// leaves the file at NEW with the set still naming OLD — `status` then shows /// OLD missing and NEW as an extra, and `zit add NEW` + `zit rm OLD` recovers. /// Full crash-atomicity across filesystem + record is working-tree /// crash-consistency work (0.2 / DST), not this handler's job. /// /// Emits the one confirmation row to stdout on success; diagnostics stay on /// stderr (B-mv-17). fn mvCommit( io: std.Io, root_dir: std.Io.Dir, set: *managed.ManagedSet, old_key: []const u8, new_key: []const u8, new_arg: []const u8, streams: cli.Streams, ) cli.Exit { root_dir.rename(old_key, root_dir, new_key, io) catch |e| return mvRenameFail(streams, old_key, new_key, e); if (mvRecord(io, root_dir, set, old_key, new_key, new_arg, streams)) |exit| { root_dir.rename(new_key, root_dir, old_key, io) catch |e| return mvRollbackFail(streams, old_key, new_key, e); return exit; } streams.out.print("renamed {s} -> {s}\n", .{ old_key, new_key }) catch {}; return .ok; } /// Swaps the entry in memory (`remove` is true — OLD tracked; `insert` is true — /// NEW untracked; so `recordRename`'s precondition holds) and persists the set /// durably. Returns null on success, or the exit for the first failure with its /// diagnostic already on stderr — in which case NOTHING was persisted (the /// in-memory set is abandoned by the caller) and the caller must undo the move. fn mvRecord( io: std.Io, root_dir: std.Io.Dir, set: *managed.ManagedSet, old_key: []const u8, new_key: []const u8, new_arg: []const u8, streams: cli.Streams, ) ?cli.Exit { std.debug.assert(set.remove(old_key)); const inserted = set.insert(new_key) catch |e| return mvFail(streams, new_arg, e); std.debug.assert(inserted); // A rename that exactly inverts the pending one DROPS it rather than // replacing it. Replacing would leave OLD→NEW→OLD queueing a §4.7.6 // old-name F card asserting that OLD was formerly NEW — a name no manifest // has ever carried — writing a false claim about the past into a permanent // artifact. `mv --help` advertises exactly this sequence as the way to undo // a rename, so it is the path a user is most likely to take. if (mvInvertsPending(set.pendingRename(), old_key, new_key)) { set.clearRename(); } else { set.recordRename(old_key, new_key) catch |e| return mvFail(streams, new_arg, e); } set.write(io, root_dir) catch |e| return mvFail(streams, "", e); return null; } /// Whether renaming OLD→NEW exactly undoes `pending`, making the pair a no-op. fn mvInvertsPending(pending: ?managed.Rename, old_key: []const u8, new_key: []const u8) bool { const cur = pending orelse return false; return std.mem.eql(u8, cur.old, new_key) and std.mem.eql(u8, cur.new, old_key); } /// mv takes exactly two operands; fewer is the exit-2 usage class (B-mv-14). fn mvMissingOperand(s: cli.Streams) cli.Exit { s.err.writeAll("zit mv: two operands required: OLD NEW\ntry 'zit mv --help'\n") catch {}; return .usage; } /// Renders an operand refusal (B-mv-3..5 shape): `zit mv: '<arg>' <phrase>`, /// exit 1 (data-layer failure, distinct from the exit-2 usage class). fn mvRefuse(s: cli.Streams, arg: []const u8, phrase: []const u8) cli.Exit { s.err.print("zit mv: '{s}' {s}\n", .{ arg, phrase }) catch {}; return .failure; } /// The working file could not be moved. Nothing has been recorded yet (the move /// runs first), so this is a plain refusal and the tree is untouched. SF3 scoped /// to mv: report the ACTUAL failure rather than the old blanket "input/output /// error", which rendered ENOENT and EACCES identically and sent users looking /// for a disk fault. The phrase comes from `reasonOf`, the table `init` and /// `clean` already render through — no new mapping, so the global errno sweep /// stays 0.2 while mv stops printing a raw Zig error name at a user. fn mvRenameFail( s: cli.Streams, old_key: []const u8, new_key: []const u8, err: std.Io.Dir.RenameError, ) cli.Exit { s.err.print( "zit mv: cannot move '{s}' to '{s}': {s}\n", .{ old_key, new_key, reasonOf(err) }, ) catch {}; return .failure; } /// The move succeeded, the record did not, and putting the file back ALSO failed /// — the one case where mv leaves the working tree changed. Nothing is recorded /// either way, so the set still names OLD; say exactly where the file is so the /// user can move it back by hand. Exit 1. fn mvRollbackFail( s: cli.Streams, old_key: []const u8, new_key: []const u8, err: std.Io.Dir.RenameError, ) cli.Exit { s.err.print( "zit mv: recorded nothing, but '{s}' is now at '{s}' and could not be moved back: {s}\n" ++ "move it back by hand: mv '{s}' '{s}'\n", .{ old_key, new_key, reasonOf(err), new_key, old_key }, ) catch {}; return .failure; } /// Renders a B-mv-16 managed-state failure for `arg` to stderr and returns /// `.failure`. Exhaustive over the closed `managed.ManagedError` set — a new /// variant fails to compile, catching drift. The read-context wording matches /// the add/rm parity (`cannot read tracked files: .zit-managed is corrupt`). fn mvFail(s: cli.Streams, arg: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit mv: cannot read tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit mv: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit mv: '{s}': path is too long to track\n", .{arg}) catch {}, error.TooMany => s.err.writeAll("zit mv: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit mv: out of memory\n") catch {}, } return .failure; } // ========================================================================== // `zit status` — read-only report of the working copy (contracts/status.md // B-status-1..17). A thin consumer: it parses, discovers the checkout root, // reads `.zit-checkout` and `.zit-managed` through the library, resolves the // current check-in through the SAME `CheckinBaseline` machinery `diff` uses, // and renders. No logic enters any library module. The repository is opened // read-only (`logstore.LogStore.openReadOnly`) — no write frame is ever // appended, and `repostate`/`delta`/`std.http` stay untouched. Two things // remain honestly deferred (B-status-16, stated in `--help`): files deleted // from the check-in, and untracked ("extra") files — `status` iterates the // managed set, so neither is reachable from here. // ========================================================================== /// `zit status [-h|--help]` — the thin wiring: parse, discover the checkout /// root, read the checkout record and the managed set, resolve the current /// check-in, then one pass comparing the sorted managed set against it. /// Read-only: a dirty working copy is never a failure (B-status-15) — every /// `added`/`edited`/`missing` row exits 0. A *comparison* failure is not /// dirtiness and still exits 1 (an unreadable or oversized tracked file, a /// corrupt or unresolvable check-in); the whole report is buffered so such a /// failure leaves stdout empty rather than a header above a truncated report, /// exactly as `diff` does. Mirrors `runAdd`'s order (parse → discover → load /// → render). pub fn runStatus( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-status-1). --help wins over everything (B-status-14). const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "status", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "status", .extra_argument, tok), }; if (help) { streams.out.writeAll(status_help) catch {}; return .ok; } // 2. Discover the checkout root (B-status-2/-9). OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "status")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Read the checkout record (B-status-10). The buffer holds one byte past // the record bound so `checkout.read`'s `out_buf.len > max_ck_payload` // assertion holds and an oversized file is detected, not truncated. var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (checkout.read(io, root_dir, &ck_buf) catch |e| return checkoutFail(streams, "status", e)) orelse return checkoutMissing(streams, "status"); // null after a successful discovery // 4. Load the managed set (B-status-11); an absent file reads as the empty // set (`ManagedSet.read`), so a repo with no `zit add` is not an error. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "status", e); defer set.deinit(); // 5. Resolve the current check-in's effective file set — BEFORE any output, // so an unreadable check-in fails cleanly instead of leaving a header on // stdout above a report that never arrives. Unborn means there is no // check-in to compare against, so nothing is opened at all. const unborn = ck.version.len == 0; var current: CheckinBaseline = undefined; if (!unborn) { current.open(gpa, io, root_dir, ck.version) catch return compareOpenFail(streams, "status", current.failure); } defer if (!unborn) current.deinit(io); // 6. Header (B-status-3/-4/-17), buffered like every row below. An empty // branch field defaults to trunk; an empty version is the unborn state // (no check-ins yet). var sink: StatusSink = .{ .gpa = gpa }; defer sink.report.deinit(gpa); const branch = if (ck.branch.len == 0) "trunk" else ck.branch; (if (unborn) sink.report.print(gpa, "On branch {s} (no check-ins yet)\n", .{branch}) else sink.report.print(gpa, "On branch {s} (check-in {s})\n", .{ branch, ck.version })) catch return stateOom(streams, "status"); // 6b. A pending merge / cherrypick / backout is state the next `commit` will // consume (the P or Q card IS the change), so status must name it: a // merge whose file effects are already materialized otherwise looks // identical to a clean tree, and the user has no way to see that a // second parent is queued (blocker B5). `merge_parent` carries the other // side's hash; `special_op` is the q-sign byte glued onto REV's hash. if (ck.merge_parent.len != 0) { sink.report.print(gpa, "pending merge with {s}\n", .{ck.merge_parent}) catch return stateOom(streams, "status"); } else if (ck.special_op.len > 1) { const label = switch (ck.special_op[0]) { '-' => "backout", else => "cherrypick", // `merge` only ever writes '+' or '-' }; sink.report.print(gpa, "pending {s} of {s}\n", .{ label, ck.special_op[1..] }) catch return stateOom(streams, "status"); } // 7. One pass over the sorted set (B-status-5/-6/-8/-16), compared against // that file set. A path is present iff a no-follow stat succeeds AND its // kind is a regular file — a symlink, a directory, or any non-regular // entry folds to `missing` (B-status-5). `added`/`edited`/`missing` // interleave in path order (not grouped by kind). In the unborn state // every present tracked file is `added`. const paths = set.iterate(); walkWorkingTree( gpa, io, root_dir, paths, if (unborn) null else ¤t.walk, &sink, ) catch |err| return compareFail(streams, "status", err); // 8. Clean-state line when no change line was emitted (B-status-7). if (sink.changes == 0) { sink.report.appendSlice(gpa, if (unborn and paths.len == 0) "nothing tracked yet\n" else "nothing to report\n") catch return stateOom(streams, "status"); } // 9. One write, only now that the whole report exists: every row above is // a dirty-working-copy observation, never a failure (B-status-15). streams.out.writeAll(sink.report.items) catch { streams.err.writeAll("zit status: cannot write output\n") catch {}; return .failure; }; return .ok; } /// `status`'s rendering of a working-tree comparison: one human-readable row per /// changed tracked file, counted so B-status-7's clean line knows whether any /// row was printed. Deliberately narrower than `diff`'s report — a file that /// exists only in the check-in (deleted-from-check-in) is dropped, because /// `status` reports the tracked set and `status --help` still defers those. /// Buffered for the same reason `diff` buffers (see the `zit diff` banner): a /// failure part-way through the walk must not leave a partial report behind. const StatusSink = struct { gpa: std.mem.Allocator, report: std.ArrayList(u8) = .empty, changes: usize = 0, fn emit(self: *StatusSink, entry: WorkingTreeEntry) WalkError!void { if (!entry.tracked) return; // deleted-from-check-in: still deferred const label = switch (entry.inspection) { .same => return, // clean: no row .added => "added", .modified => "edited", // Absent on disk, whether or not the check-in carried it. .none, .deleted => "missing", .io_error => return error.Storage, .oom => return error.OutOfMemory, .corrupt => return error.Corrupt, }; const before = self.report.items.len; try self.report.print(self.gpa, "{s} {s}\n", .{ label, entry.path }); std.debug.assert(self.report.items.len > before); // a row is never empty self.changes += 1; } }; /// The checkout record vanished between discovery and read. fn checkoutMissing(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: cannot read checkout state: .zit-checkout is missing\n", .{verb}) catch {}; return .failure; } // ========================================================================== // `zit extras` — list the working-tree files zit is not tracking. Read-only: // discover the root, read the managed set, sweep the checkout once through the // `extras.zig` deep module, then render the sorted list. The verb only renders; // the sweep (and its bounds/ordering) lives in the module (contracts/extras.md). // ========================================================================== pub fn runExtras( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-extras-11/-12/-13). --help wins over any trailing token. const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "extras", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "extras", .extra_argument, tok), }; if (help) { streams.out.writeAll(extras_help) catch {}; return .ok; } // 2. Discover the checkout root (B-extras-14). OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "extras")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Load the managed set (B-extras-4/-15); an absent file reads as the // empty set, so a repo with no `zit add` sweeps every on-disk file. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "extras", e); defer set.deinit(); // 4. Re-open the root ITERABLE (B-extras-18): `discoverRoot`'s handle is // non-iterable, and the sweep needs to enumerate. No-follow so a symlink // at the root is never entered. var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return sweepFail(streams, "extras", error.Storage); defer iter_root.close(io); // 5. Sweep once (B-extras-1..9). The module owns ordering and bounds. var result = extras.sweep(gpa, io, iter_root, &set) catch |e| return sweepFail(streams, "extras", e); defer result.deinit(); // 6. Render: one root-relative path per line, ascending byte order // (B-extras-2/-3). An empty set prints nothing and still exits 0 // (B-extras-10). Streaming writes honor an inherited O_APPEND offset // under `>>` (B-extras-17). // A symlink is an extra like any other: `extras` enumerates, it does not // judge, so the entry's `is_symlink` tag is deliberately ignored here. for (result.items()) |e| streams.out.print("{s}\n", .{e.path}) catch {}; return .ok; } /// Renders a shared-sweep failure to stderr and returns `.failure` (exit 1, the /// data-layer class, distinct from the exit-2 usage class). `verb` names the /// caller so `extras` and `clean` each prefix their own diagnostics. Exhaustive /// over the closed `extras.SweepError` set — a new variant fails to compile. fn sweepFail(s: cli.Streams, verb: []const u8, err: extras.SweepError) cli.Exit { switch (err) { error.Storage => s.err.print("zit {s}: cannot read the checkout: input/output error\n", .{verb}) catch {}, error.TooMany => s.err.print("zit {s}: too many files to enumerate\n", .{verb}) catch {}, error.TooDeep => s.err.print("zit {s}: checkout tree is nested too deeply\n", .{verb}) catch {}, error.OutOfMemory => s.err.print("zit {s}: out of memory\n", .{verb}) catch {}, } return .failure; } // ========================================================================== // `zit clean` — delete the working-tree files zit is not tracking. It consumes // the SAME `extras.zig` sweep unchanged (B-clean-18) and adds exactly one new // mechanism: deletion. Tracked and control paths are untouchable BY // CONSTRUCTION — clean acts only on the set the sweep hands it and performs no // membership re-check (B-clean-7). clean is the destructive companion to // `extras` and is NOT sheltered by undo; deletion is final (contracts/clean.md). // ========================================================================== /// `zit clean [--dry-run] [--force]` — the thin wiring: parse, discover the /// checkout root, read the managed set, sweep once through the shared module, /// then branch. Un-forced is a gate that refuses and deletes nothing /// (B-clean-1/-2); `--dry-run` previews byte-identically to `zit extras` /// (B-clean-3/-4); `--force` deletes exactly the swept set (B-clean-5..10). /// Mirrors `runExtras` through the sweep, then diverges only in step 6. pub fn runClean( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-clean-12/-13/-14). --help wins over any trailing token and over // --force — help never sweeps and never deletes. const args = switch (cli.parseCleanArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "clean", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "clean", .extra_argument, tok), }; if (args.help) { streams.out.writeAll(clean_help) catch {}; return .ok; } // 2. Discover the checkout root (B-clean-15): the repository check precedes // any sweep, regardless of --force/--dry-run. OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "clean")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Load the managed set (B-clean-16): a tree whose tracked set cannot be // read is a tree clean must not touch — fail cleanly BEFORE any deletion. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "clean", e); defer set.deinit(); // 4. Re-open the root ITERABLE (B-clean-18), exactly as extras: the sweep // enumerates and the deletion pass unlinks through this same handle. var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return sweepFail(streams, "clean", error.Storage); defer iter_root.close(io); // 5. Sweep once through the SHARED module (B-clean-18). The extras set is // clean's SOLE path source — no re-walk, no re-derivation. var result = extras.sweep(gpa, io, iter_root, &set) catch |e| return sweepFail(streams, "clean", e); defer result.deinit(); // 6. Branch. Order encodes the B-clean-11 precedence: --dry-run wins, so it is // checked before --force and can never be upgraded into a real deletion. if (args.dry_run) { // Preview byte-identically to `zit extras` (B-clean-3/-4): one // root-relative path per line, ascending, deleting nothing. Like // `extras`, clean is kind-blind: a symlink is a deletable extra, so the // entry's `is_symlink` tag is ignored (only add/addremove filter on it). for (result.items()) |e| streams.out.print("{s}\n", .{e.path}) catch {}; return .ok; } if (!args.force) { // The gate (B-clean-1/-2/-8): an empty set is a silent success no-op with // nothing to guard; otherwise refuse without deleting, naming the count. if (result.items().len == 0) return .ok; return cleanRefuse(streams, result.items().len); } // --force: delete exactly the swept set (B-clean-5..10). return cleanForce(streams, io, iter_root, &result); } /// Renders the B-clean-1 two-line refusal to stderr and returns `.failure` /// (exit 1). The count is `> 0` by construction — the caller handles the empty /// set as a silent success before reaching here (B-clean-8). Non-interactive: /// no prompt is ever issued, stdin is never read (B-clean-2). fn cleanRefuse(s: cli.Streams, count: usize) cli.Exit { std.debug.assert(count > 0); const noun: []const u8 = if (count == 1) "file" else "files"; s.err.print( "zit clean: {d} untracked {s} would be deleted; refusing without --force\n", .{ count, noun }, ) catch {}; s.err.writeAll( "run 'zit clean --dry-run' to preview, or 'zit clean --force' to delete\n", ) catch {}; return .failure; } /// Deletes exactly the swept extras set (B-clean-5) through `root` — the same /// ITERABLE handle the sweep enumerated, so no path outside that set is ever /// reachable and tracked/control files stay untouched BY CONSTRUCTION /// (B-clean-7: no membership re-check). A failed unlink is reported to stderr /// and the sweep CONTINUES its siblings (B-clean-9); the run then exits 1 /// (B-clean-10). An already-absent path satisfies the postcondition and counts /// as success (idempotent, B-clean-6). Full success is silent on stdout and /// exits 0 (cli-stream-separation). fn cleanForce(s: cli.Streams, io: std.Io, root: std.Io.Dir, result: *const extras.Extras) cli.Exit { var failed = false; // Bound = the finite swept count (B-clean-18): no re-walk, no recursion. for (result.items()) |entry| { const path = entry.path; std.debug.assert(path.len > 0); // shape invariant, NOT a membership re-check root.deleteFile(io, path) catch |e| switch (e) { error.FileNotFound => {}, // already gone ⇒ postcondition holds (B-clean-6) else => { failed = true; s.err.print( "zit clean: '{s}': could not delete: {s}\n", .{ path, reasonOf(e) }, ) catch {}; }, }; } return if (failed) .failure else .ok; } // ========================================================================== // `zit addremove` — reconcile the tracked set with the working tree in ONE // pass: every untracked file the extras sweep reports is ADDED, every tracked // file missing from disk is REMOVED (fossil/hg parity; git spells it `add -A`). // A composite of the shipped add/rm state transitions over the ONE shipped // `extras.zig` sweep — it adds NO second tree walker and NO ad-hoc managed-set // mutation. It mutates `.zit-managed` only (never file contents, never the // repository); the next `zit commit` records the result. Not journaled — the // inverse is re-running add/rm (the B-clean posture). (contracts/addremove.md // B-addremove-1..13.) // ========================================================================== /// `zit addremove [--dry-run]` — the thin wiring: parse, discover the checkout /// root, read the managed set, sweep once through the SHARED `extras.zig` module /// (the ADD set), derive the REMOVE set as the tracked entries absent from disk, /// then apply both to the managed set in ONE atomic write. Mirrors `runClean` /// through the sweep, then diverges: instead of deleting the swept set it folds /// it into the tracked set, plus the missing-managed removals. The report is /// WITHHELD until the write is durable (B-addremove-7/-12), so a failed write /// claims no transition. Per-path add failures report and continue, exiting 1 /// (B-addremove-6). `--dry-run` computes the same report and leaves `.zit-managed` /// byte-unchanged (B-addremove-4). pub fn runAddremove( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { // 1. Parse (B-addremove-9/-10). --help wins over any trailing token. const args = switch (cli.parseAddremoveArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "addremove", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "addremove", .extra_argument, tok), }; if (args.help) { streams.out.writeAll(addremove_help) catch {}; return .ok; } // 2. Discover the checkout root (B-addremove-11). OOM → exit 1. const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "addremove")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); // 3. Load the current set; an absent file reads as the empty set, so a repo // with no `zit add` tracks every on-disk file in one addremove. var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return managedFail(streams, "addremove", e); defer set.deinit(); // 4. Re-open the root ITERABLE (B-addremove-2), exactly as extras/clean: the // sweep enumerates through this handle. No-follow so a root symlink is // never entered. var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return sweepFail(streams, "addremove", error.Storage); defer iter_root.close(io); // 5. Sweep once through the SHARED module (B-addremove-2). The extras set IS // the add set — same sweep, same exclusions, same symlink posture. A // second walker is a refutation. var extra_paths = extras.sweep(gpa, io, iter_root, &set) catch |e| return sweepFail(streams, "addremove", e); defer extra_paths.deinit(); // 6. Derive the REMOVE set: the tracked entries whose working-tree file is // absent (B-addremove-3). A no-follow stat that succeeds means present — // a modified or replaced file is NEVER removed (content is irrelevant to // membership). Keys are copied into the arena BEFORE any mutation, because // `set.iterate()`'s slice (and each key's backing bytes) are invalidated // by the removals below. Ascending order is preserved (iterate is sorted). var to_remove: std.ArrayList([]const u8) = .empty; for (set.iterate()) |key| { const present = blk: { _ = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch break :blk false; break :blk true; }; if (!present) { const owned = arena.dupe(u8, key) catch return stateOom(streams, "addremove"); to_remove.append(arena, owned) catch return stateOom(streams, "addremove"); } } // 7. Apply both verdicts to the tracked set. Adds first (each swept path is // untracked, so `insert` returns true unless it fails a bound — a failure // reports and continues, B-addremove-6), then the missing-managed removals. // The report lines are collected, not printed, so step 9 can withhold them // until the write is durable. `added` borrows `extra_paths` (owned until // this function returns); `to_remove` is arena-owned. var failed = false; var changed = false; var added: std.ArrayList([]const u8) = .empty; for (extra_paths.items()) |entry| { // 0.1.0 does not version symlinks (F-B3). addremove withholds them // SILENTLY: its report is a two-channel ADDED/REMOVED ledger of state // transitions, and a skipped symlink is a non-transition — `zit extras` // is the verb that shows it is still there. `add`, whose contract is // per-argument feedback, does print the skip. if (entry.is_symlink) continue; const path = entry.path; const inserted = set.insert(path) catch |e| { failed = true; _ = addremoveFail(streams, path, e); continue; }; if (inserted) { changed = true; added.append(arena, path) catch return stateOom(streams, "addremove"); } } for (to_remove.items) |key| { if (set.remove(key)) changed = true; } // 8. Persist once, atomically, only if the set changed and this is not a dry // run (B-addremove-4/-7). A write failure leaves the prior file wholly // intact (ManagedSet.write is write-temp → fsync → rename) and returns // before any report line prints, so nothing below claims a transition. if (changed and !args.dry_run) { set.write(io, root_dir) catch |e| return addremoveFail(streams, "", e); } // 9. Report — now that the change is durable (or, in a dry run, truthfully // previewed and never persisted): the ADDED lines then the REMOVED lines, // each sorted ascending, root-relative (B-addremove-1). Dry-run output // equals the real run's report on the same tree (B-addremove-4). for (added.items) |path| streams.out.print("ADDED {s}\n", .{path}) catch {}; for (to_remove.items) |key| streams.out.print("REMOVED {s}\n", .{key}) catch {}; // 10. Fold outcomes into one code (B-addremove-6): valid transitions persist // even when a sibling path failed to track (partial success). return if (failed) .failure else .ok; } /// Renders an addremove managed-state failure for `path` to stderr and returns /// `.failure` (exit 1). Mirrors `addFail`'s update-context messages with the /// addremove verb prefix; exhaustive over the closed `managed.ManagedError` set /// so a new variant fails to compile. `path` is used only by `TooLong` (the /// per-path bound); the write-context errors carry no path. fn addremoveFail(s: cli.Streams, path: []const u8, err: managed.ManagedError) cli.Exit { switch (err) { error.Corrupt => s.err.writeAll( "zit addremove: cannot update tracked files: .zit-managed is corrupt\n", ) catch {}, error.Storage => s.err.writeAll( "zit addremove: cannot update tracked files: input/output error\n", ) catch {}, error.TooLong => s.err.print("zit addremove: '{s}': path is too long to track\n", .{path}) catch {}, error.TooMany => s.err.writeAll("zit addremove: too many tracked files\n") catch {}, error.OutOfMemory => s.err.writeAll("zit addremove: out of memory\n") catch {}, } return .failure; } const DiffInspection = enum { none, added, same, modified, deleted, io_error, oom, corrupt, }; const DiffBaselineResolver = struct { const Failure = enum { unresolved, storage, corrupt, oom }; gpa: std.mem.Allocator, log: *logstore.LogStore, bytes: ?[]u8 = null, manifest: ?artifact.Manifest = null, /// Non-null when the read-path shim normalized a legacy trailing-separator /// F card; the manifest then borrows this copy instead of `bytes`. normalized: ?[]u8 = null, failure: Failure = .unresolved, fn deinit(self: *DiffBaselineResolver) void { if (self.manifest) |*m| m.deinit(); if (self.normalized) |b| self.gpa.free(b); if (self.bytes) |bytes| self.gpa.free(bytes); self.* = undefined; } fn resolve(ctx: *anyopaque, hash: []const u8) ?artifact.ManifestFiles { const self: *DiffBaselineResolver = @ptrCast(@alignCast(ctx)); var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeDiffId(hash, &digest_buf) orelse return null; const rid = self.log.lookup(digest) orelse return null; const bytes = self.log.content(self.gpa, rid) catch |err| { self.failure = switch (err) { error.OutOfMemory => .oom, error.Corrupt => .corrupt, error.Storage => .storage, }; return null; } orelse return null; self.bytes = bytes; if (!artifact.verify(hash, bytes)) { self.failure = .corrupt; return null; } // Read path: tolerate the legacy F-card trailing separator in // fossil-origin history when resolving a diff baseline. const im = artifact.parseForImport(self.gpa, bytes) catch |err| { self.failure = if (err == error.OutOfMemory) .oom else .corrupt; return null; }; self.manifest = im.manifest; self.normalized = im.normalized; if (self.manifest.?.kind != .manifest) return null; return .{ .baseline_hash = if (self.manifest.?.b) |b| b.hash else null, .files = self.manifest.?.f, }; } }; /// Decodes a canonical 40- or 64-hex artifact name into the raw log key. fn decodeDiffId(name: []const u8, out: *[logstore.max_hash_len]u8) ?[]const u8 { if (!artifact.validateName(name)) return null; const algo = artifact.nameAlgo(name.len) orelse return null; const len: usize = switch (algo) { .sha1 => 20, .sha3_256 => 32, }; _ = std.fmt.hexToBytes(out[0..len], name) catch return null; return out[0..len]; } fn absentDiffOutcome(baseline: ?artifact.FCard) DiffInspection { return if (baseline == null) .none else .deleted; } /// Inspects one managed pathname without following it. The path is no-follow /// stated before opening; its opened handle is then stated and read, so neither /// a symlink nor a replacement after the initial stat can be followed. fn inspectDiffFile( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, path: []const u8, baseline: ?artifact.FCard, ) DiffInspection { const path_stat = root_dir.statFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound => return absentDiffOutcome(baseline), else => return .io_error, }; if (path_stat.kind != .file) return absentDiffOutcome(baseline); var file = root_dir.openFile(io, path, .{ .follow_symlinks = false }) catch |err| switch (err) { error.FileNotFound, error.SymLinkLoop => return absentDiffOutcome(baseline), else => return .io_error, }; defer file.close(io); const st = file.stat(io) catch return .io_error; if (st.kind != .file) return absentDiffOutcome(baseline); const f = baseline orelse return .added; const working_mode: artifact.FileMode = if (executable(st.permissions)) .executable else .regular; if (working_mode != f.mode) return .modified; if (!trackedFileSizeAllowed(st.size)) return .io_error; std.debug.assert(st.size <= max_tracked_file_bytes); // the @intCast below is sound const content = gpa.alloc(u8, @intCast(st.size)) catch return .oom; defer gpa.free(content); const n = file.readPositionalAll(io, content, 0) catch return .io_error; if (n != content.len) return .io_error; const algo = artifact.nameAlgo(f.hash.?.len) orelse return .corrupt; var name_buf: [64]u8 = undefined; const name = artifact.artifactName(algo, content, &name_buf); return if (std.mem.eql(u8, name, f.hash.?)) .same else .modified; } fn appendDiffRecord( gpa: std.mem.Allocator, report: *std.ArrayList(u8), kind: u8, path: []const u8, ) error{OutOfMemory}!void { if (path.len > std.math.maxInt(usize) / 2) return error.OutOfMemory; try report.append(gpa, kind); try report.append(gpa, ' '); const encoded = try report.addManyAt(gpa, report.items.len, 2 * path.len); const escaped = artifact.encodeEscapes(path, encoded); report.items.len -= encoded.len - escaped.len; try report.append(gpa, '\n'); } fn appendDiffInspection( gpa: std.mem.Allocator, report: *std.ArrayList(u8), path: []const u8, inspection: DiffInspection, ) error{ OutOfMemory, Storage, Corrupt }!void { switch (inspection) { .none, .same => {}, .added => try appendDiffRecord(gpa, report, 'A', path), .modified => try appendDiffRecord(gpa, report, 'M', path), .deleted => try appendDiffRecord(gpa, report, 'D', path), .io_error => return error.Storage, .oom => return error.OutOfMemory, .corrupt => return error.Corrupt, } } fn nextEffectiveFile(walk: *artifact.MergeWalk) ?artifact.FCard { while (walk.next()) |f| { if (f.hash != null) return f; } return null; } /// The failure modes of resolving the current check-in into a comparable file /// set. Shared by every verb that compares the working tree against the /// check-in (`diff`, `status`), so one mapping serves all of them; the verb is /// a parameter exactly as in `checkoutFail`/`managedFail`. fn compareLogFail(s: cli.Streams, verb: []const u8, err: logstore.OpenError) cli.Exit { switch (err) { error.OutOfMemory => return stateOom(s, verb), error.Corrupt => s.err.print("zit {s}: cannot read repository: repository is corrupt\n", .{verb}) catch {}, error.Storage => s.err.print("zit {s}: cannot read repository: input/output error\n", .{verb}) catch {}, } return .failure; } fn compareCurrentCorrupt(s: cli.Streams, verb: []const u8) cli.Exit { s.err.print("zit {s}: current check-in is not a valid manifest\n", .{verb}) catch {}; return .failure; } fn compareBaselineFail(s: cli.Streams, verb: []const u8, failure: DiffBaselineResolver.Failure) cli.Exit { switch (failure) { .oom => return stateOom(s, verb), .storage => return compareLogFail(s, verb, error.Storage), .corrupt => return compareLogFail(s, verb, error.Corrupt), .unresolved => s.err.print("zit {s}: cannot resolve baseline\n", .{verb}) catch {}, } return .failure; } fn compareFail(s: cli.Streams, verb: []const u8, err: error{ OutOfMemory, Storage, Corrupt }) cli.Exit { switch (err) { error.OutOfMemory => return stateOom(s, verb), error.Corrupt => s.err.print("zit {s}: cannot compare: repository is corrupt\n", .{verb}) catch {}, error.Storage => s.err.print("zit {s}: cannot compare working tree: input/output error\n", .{verb}) catch {}, } return .failure; } /// Maps every `CheckinBaseline.open` failure onto the verb's diagnostic. The /// open path is shared, so its rendering is too. fn compareOpenFail(s: cli.Streams, verb: []const u8, failure: CheckinBaseline.OpenFailure) cli.Exit { return switch (failure) { .log => |err| compareLogFail(s, verb, err), .current_corrupt => compareCurrentCorrupt(s, verb), .baseline => |b| compareBaselineFail(s, verb, b), }; } /// The current check-in resolved into a merge walk over its *effective* file /// set — the one thing every working-tree comparison needs and the one place /// the delta-manifest walk, the legacy trailing-separator F-card tolerance and /// the corrupt/unresolvable mapping are written down. /// /// Self-referential by construction: `log` reads through `fs`, the resolver /// borrows `log`, and the walk borrows the resolver. It is therefore /// initialized in place through `open` into caller-owned storage and must not /// be copied or moved afterwards. const CheckinBaseline = struct { const OpenFailure = union(enum) { log: logstore.OpenError, current_corrupt, baseline: DiffBaselineResolver.Failure, }; /// `open`'s single error; the discriminated reason lands in `failure`. Zig /// error sets carry no payload, so the detail travels beside the error — /// the same shape `DiffBaselineResolver` already uses for its resolve hook. const OpenError = error{OpenFailed}; gpa: std.mem.Allocator, repo_file: File, fs: storage.FileStorage, log: logstore.LogStore, current_bytes: []u8, current: artifact.ImportedManifest, resolver: DiffBaselineResolver, walk: artifact.MergeWalk, /// Valid exactly when `open` returned `error.OpenFailed`. failure: OpenFailure, /// True exactly between a successful `open` and `deinit`. The open/deinit /// contract below used to rest entirely on each call site placing its /// `defer deinit` after the `open ... catch return`; hoisting that defer, /// or slipping an early return between the two, was silent UB. This makes /// the invariant checkable: `deinit` asserts it, and `fail` asserts the /// mirror (nothing is left open on the failure path). opened: bool, fn fail(self: *CheckinBaseline, reason: OpenFailure) OpenError { std.debug.assert(!self.opened); self.failure = reason; return error.OpenFailed; } /// Opens the repository read-only and resolves `version` (a born check-in's /// full artifact name) into a walk. On failure nothing is left open and /// `deinit` must NOT be called; read `failure` for the reason. fn open( self: *CheckinBaseline, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, version: []const u8, ) OpenError!void { // First, before any fallible step: every `fail` below asserts it, and // it must read false if `open` returns an error. self.opened = false; std.debug.assert(version.len > 0); // callers screen the unborn state self.gpa = gpa; self.repo_file = root_dir.openFile(io, repo_filename, .{}) catch return self.fail(.{ .log = error.Storage }); errdefer self.repo_file.close(io); const repo_stat = self.repo_file.stat(io) catch return self.fail(.{ .log = error.Storage }); self.fs = storage.FileStorage.init(io, self.repo_file, repo_stat.size); self.log = logstore.LogStore.openReadOnly(gpa, self.fs.storageFile()) catch |err| return self.fail(.{ .log = err }); errdefer self.log.deinit(gpa); var digest_buf: [logstore.max_hash_len]u8 = undefined; const digest = decodeDiffId(version, &digest_buf) orelse return self.fail(.current_corrupt); const rid = self.log.lookup(digest) orelse return self.fail(.current_corrupt); self.current_bytes = (self.log.content(gpa, rid) catch |err| return self.fail(.{ .log = err })) orelse return self.fail(.current_corrupt); errdefer gpa.free(self.current_bytes); if (!artifact.verify(version, self.current_bytes)) return self.fail(.current_corrupt); // Read path: tolerate the legacy F-card trailing separator when reading // the current check-in (fossil-origin history). self.current = artifact.parseForImport(gpa, self.current_bytes) catch |err| switch (err) { error.OutOfMemory => return self.fail(.{ .baseline = .oom }), else => return self.fail(.current_corrupt), }; errdefer self.current.deinit(gpa); if (self.current.manifest.kind != .manifest) return self.fail(.current_corrupt); self.resolver = .{ .gpa = gpa, .log = &self.log }; errdefer self.resolver.deinit(); self.walk = artifact.enumerate(.{ .baseline_hash = if (self.current.manifest.b) |b| b.hash else null, .files = self.current.manifest.f, }, .{ .ctx = &self.resolver, .resolveFn = DiffBaselineResolver.resolve, }) catch |err| switch (err) { error.BaselineUnresolvable => return self.fail(.{ .baseline = self.resolver.failure }), error.BaselineIsDelta => return self.fail(.{ .baseline = .unresolved }), }; self.opened = true; // last: every fallible step above is behind us } fn deinit(self: *CheckinBaseline, io: std.Io) void { std.debug.assert(self.opened); // never call after a failed `open` self.resolver.deinit(); self.current.deinit(self.gpa); self.gpa.free(self.current_bytes); self.log.deinit(self.gpa); self.repo_file.close(io); self.* = undefined; } }; /// One entry of a working-tree comparison, as `walkWorkingTree` reports it. const WorkingTreeEntry = struct { path: []const u8, inspection: DiffInspection, /// False when the path exists only in the check-in and is no longer tracked /// — a `diff` reports those as deleted; `status` iterates only the managed /// set, so it drops them (still deferred, and `status --help` says so). tracked: bool, }; const WalkError = error{ OutOfMemory, Storage, Corrupt }; /// Walks the sorted managed set against the check-in's effective file set, /// classifying every path exactly once and emitting it in path order. `walk` /// is null in the unborn state, where every tracked path is compared against /// no baseline. This is the whole comparison; a verb supplies only `sink`, a /// pointer to a struct with `emit(entry) WalkError!void`, which decides how a /// classification is rendered. `sink` is comptime-generic rather than an /// erased `*anyopaque` + fn pointer: both verbs' sinks are known at compile /// time, so the type stays checked and the call stays direct. fn walkWorkingTree( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, paths: []const []const u8, walk: ?*artifact.MergeWalk, sink: anytype, ) WalkError!void { // The merge below is only correct on a sorted managed set (`ManagedSet` // keeps it sorted; §8.1-p1 gives the same for the F-card side). One pass, // debug-only, against per-file stat+read cost. if (std.debug.runtime_safety) { for (paths[0..paths.len -| 1], 1..) |p, i| { std.debug.assert(std.mem.order(u8, p, paths[i]) == .lt); } } const w = walk orelse { for (paths) |path| { try sink.emit(.{ .path = path, .inspection = inspectDiffFile(gpa, io, root_dir, path, null), .tracked = true, }); } return; }; // Termination guard, the house pattern from `artifact.MergeWalk.next` // (src/artifact.zig): every arm of the switch below advances exactly one // cursor — `path_index` over the managed set, or `baseline` over the // effective file set — and the loop exits only once both are exhausted. // Nothing else enforces that, so a step counter against the exact bound // (one iteration per managed path, one per effective file, plus the // exit test) fails loudly instead of spinning if an arm ever stops // advancing. `w.baseline.len + w.delta.len` bounds the effective set: // the merge emits at most one card per input card. const bound = paths.len + w.baseline.len + w.delta.len + 1; var steps: usize = 0; var path_index: usize = 0; var baseline = nextEffectiveFile(w); while (path_index < paths.len or baseline != null) { std.debug.assert(steps <= bound); steps += 1; std.debug.assert(path_index < paths.len or baseline != null); const path: ?[]const u8 = if (path_index < paths.len) paths[path_index] else null; const order: std.math.Order = if (baseline == null) .lt // only tracked paths remain else if (path == null) .gt // only baseline files remain else std.mem.order(u8, path.?, baseline.?.filename); const before_index = path_index; switch (order) { .lt => { try sink.emit(.{ .path = path.?, .inspection = inspectDiffFile(gpa, io, root_dir, path.?, null), .tracked = true, }); path_index += 1; }, .eq => { try sink.emit(.{ .path = path.?, .inspection = inspectDiffFile(gpa, io, root_dir, path.?, baseline.?), .tracked = true, }); path_index += 1; baseline = nextEffectiveFile(w); }, .gt => { try sink.emit(.{ .path = baseline.?.filename, .inspection = .deleted, .tracked = false, }); baseline = nextEffectiveFile(w); }, } // Progress: the managed cursor moved, or the baseline one did. std.debug.assert(path_index > before_index or order == .gt); std.debug.assert(path_index <= paths.len); } } fn writeDiffOutput(s: cli.Streams, bytes: []const u8) cli.Exit { s.out.writeAll(bytes) catch { s.err.writeAll("zit diff: cannot write output\n") catch {}; return .failure; }; return .ok; } pub fn runDiff( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const help = switch (cli.parseNoPositionalArgs(verb_args)) { .ok => |a| a.help, .unknown_option => |tok| return cli.usageError(streams, "diff", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "diff", .extra_argument, tok), }; if (help) { return writeDiffOutput(streams, diff_help); } const root = (cli.discoverRoot(arena, io) catch return stateOom(streams, "diff")) orelse { streams.err.writeAll(not_a_repo_msg) catch {}; return .failure; }; var root_dir = root.dir; defer root_dir.close(io); var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (checkout.read(io, root_dir, &ck_buf) catch |err| return checkoutFail(streams, "diff", err)) orelse return checkoutMissing(streams, "diff"); var set = managed.ManagedSet.read(gpa, io, root_dir) catch |err| return managedFail(streams, "diff", err); defer set.deinit(); var sink: DiffSink = .{ .gpa = gpa }; defer sink.report.deinit(gpa); const paths = set.iterate(); if (ck.version.len == 0) { walkWorkingTree(gpa, io, root_dir, paths, null, &sink) catch |err| return compareFail(streams, "diff", err); return writeDiffOutput(streams, sink.report.items); } var baseline: CheckinBaseline = undefined; baseline.open(gpa, io, root_dir, ck.version) catch return compareOpenFail(streams, "diff", baseline.failure); defer baseline.deinit(io); walkWorkingTree(gpa, io, root_dir, paths, &baseline.walk, &sink) catch |err| return compareFail(streams, "diff", err); return writeDiffOutput(streams, sink.report.items); } /// `diff`'s rendering of a working-tree comparison: the compact A/M/D record /// report, with §4.3 escapes on every filename. const DiffSink = struct { gpa: std.mem.Allocator, report: std.ArrayList(u8) = .empty, fn emit(self: *DiffSink, entry: WorkingTreeEntry) WalkError!void { try appendDiffInspection(self.gpa, &self.report, entry.path, entry.inspection); } }; // --- add error-mapper unit test (B-add-17; pure switch) -------------------- test "add B-add-17: ManagedError maps to the contract messages and exit 1" { const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .arg = "", .want = "zit add: cannot read tracked files: .zit-managed is corrupt\n" }, .{ .err = error.Storage, .arg = "", .want = "zit add: cannot update tracked files: input/output error\n" }, .{ .err = error.TooLong, .arg = "deep/file", .want = "zit add: 'deep/file': path is too long to track\n" }, .{ .err = error.TooMany, .arg = "x", .want = "zit add: too many tracked files\n" }, .{ .err = error.OutOfMemory, .arg = "x", .want = "zit add: out of memory\n" }, }; for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; const code = addFail(s, c.arg, c.err); try std.testing.expectEqual(cli.Exit.failure, code); try std.testing.expectEqualStrings(c.want, w.buffered()); } } // --- rm error-mapper unit test (B-rm-17; pure switch) ---------------------- test "rm B-rm-17: ManagedError maps to the contract messages and exit 1" { const Case = struct { err: managed.ManagedError, arg: []const u8, want: []const u8 }; const cases = [_]Case{ .{ .err = error.Corrupt, .arg = "", .want = "zit rm: cannot read tracked files: .zit-managed is corrupt\n" }, .{ .err = error.Storage, .arg = "", .want = "zit rm: cannot update tracked files: input/output error\n" }, .{ .err = error.TooLong, .arg = "deep/file", .want = "zit rm: 'deep/file': path is too long to track\n" }, .{ .err = error.TooMany, .arg = "x", .want = "zit rm: too many tracked files\n" }, .{ .err = error.OutOfMemory, .arg = "x", .want = "zit rm: out of memory\n" }, }; for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; const code = rmFail(s, c.arg, c.err); try std.testing.expectEqual(cli.Exit.failure, code); try std.testing.expectEqualStrings(c.want, w.buffered()); } } test "status B-status-10: a vanished checkout record renders the missing message and exit 1" { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, checkoutMissing(s, "status")); try std.testing.expectEqualStrings( "zit status: cannot read checkout state: .zit-checkout is missing\n", w.buffered(), ); } test "compare: compareLogFail maps the contract messages and exit 1, exhaustively over all 3 logstore.OpenError variants and both verbs" { const Case = struct { err: logstore.OpenError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "out of memory\n" }, .{ .err = error.Corrupt, .want = "cannot read repository: repository is corrupt\n" }, .{ .err = error.Storage, .want = "cannot read repository: input/output error\n" }, }; for ([_][]const u8{ "diff", "status" }) |verb| { for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, compareLogFail(s, verb, c.err)); var want_buf: [128]u8 = undefined; const want = try std.fmt.bufPrint(&want_buf, "zit {s}: {s}", .{ verb, c.want }); try std.testing.expectEqualStrings(want, w.buffered()); } } } test "compare: compareFail maps the contract messages and exit 1, exhaustively over all 3 comparison-error variants and both verbs" { const Case = struct { err: WalkError, want: []const u8 }; const cases = [_]Case{ .{ .err = error.OutOfMemory, .want = "out of memory\n" }, .{ .err = error.Corrupt, .want = "cannot compare: repository is corrupt\n" }, .{ .err = error.Storage, .want = "cannot compare working tree: input/output error\n" }, }; for ([_][]const u8{ "diff", "status" }) |verb| { for (cases) |c| { var buf: [128]u8 = undefined; var w = Io.Writer.fixed(&buf); var sink: [1]u8 = undefined; var wnull = Io.Writer.fixed(&sink); const s: cli.Streams = .{ .out = &wnull, .err = &w }; try std.testing.expectEqual(cli.Exit.failure, compareFail(s, verb, c.err)); var want_buf: [128]u8 = undefined; const want = try std.fmt.bufPrint(&want_buf, "zit {s}: {s}", .{ verb, c.want }); try std.testing.expectEqualStrings(want, w.buffered()); } } } test "status: StatusSink maps every DiffInspection to its row, exhaustively over all 8 variants" { const Case = struct { inspection: DiffInspection, want: []const u8 }; // Every variant, tracked. `.same` is clean and prints nothing; the three // failure variants escape as errors rather than as a row. const cases = [_]Case{ .{ .inspection = .none, .want = "missing a\n" }, .{ .inspection = .added, .want = "added a\n" }, .{ .inspection = .same, .want = "" }, .{ .inspection = .modified, .want = "edited a\n" }, .{ .inspection = .deleted, .want = "missing a\n" }, }; const gpa = std.testing.allocator; for (cases) |c| { var sink: StatusSink = .{ .gpa = gpa }; defer sink.report.deinit(gpa); try sink.emit(.{ .path = "a", .inspection = c.inspection, .tracked = true }); try std.testing.expectEqualStrings(c.want, sink.report.items); try std.testing.expectEqual(@as(usize, if (c.want.len == 0) 0 else 1), sink.changes); } // The three failure variants escape as errors AND leave the buffer clean: // a partial report is exactly what buffering exists to prevent. const failures = [_]struct { inspection: DiffInspection, want: WalkError }{ .{ .inspection = .io_error, .want = error.Storage }, .{ .inspection = .oom, .want = error.OutOfMemory }, .{ .inspection = .corrupt, .want = error.Corrupt }, }; for (failures) |f| { var sink: StatusSink = .{ .gpa = gpa }; defer sink.report.deinit(gpa); try std.testing.expectError( f.want, sink.emit(.{ .path = "a", .inspection = f.inspection, .tracked = true }), ); try std.testing.expectEqualStrings("", sink.report.items); try std.testing.expectEqual(@as(usize, 0), sink.changes); } // An untracked check-in-only path is dropped whatever its classification: // deleted-from-check-in reporting is still deferred (B-status-16). inline for (comptime std.meta.tags(DiffInspection)) |tag| { var sink: StatusSink = .{ .gpa = gpa }; defer sink.report.deinit(gpa); try sink.emit(.{ .path = "a", .inspection = tag, .tracked = false }); try std.testing.expectEqualStrings("", sink.report.items); try std.testing.expectEqual(@as(usize, 0), sink.changes); } } test "diff: output write failure is exit 1" { var out_buf: [0]u8 = .{}; var out = Io.Writer.fixed(&out_buf); var err_buf: [64]u8 = undefined; var err = Io.Writer.fixed(&err_buf); const streams: cli.Streams = .{ .out = &out, .err = &err }; try std.testing.expectEqual(cli.Exit.failure, writeDiffOutput(streams, "M a\n")); try std.testing.expectEqualStrings("zit diff: cannot write output\n", err.buffered()); }