Artifact b3bdaac0d08ff18ed8a8922365efc42300f04acb20dd44b3ae093870d69056da:
- File
src/cmd/tracking.zig
— part of check-in
[982afa3d8f]
at
2026-09-05 15:13:05
on branch trunk
— Honour every Fossil checkout setting file (#174)
Glob lists follow Fossil's tokenizer and '#' comment lines; crnl-glob, clean-glob, empty-dirs, encoding-glob, dotfiles and mimetypes work at every tier. Dotfiles are hidden by default and Fossil's checkout-state names are never listed. UTF-16 and invalid UTF-8 classify as Fossil's lookslike.c does; tokenizer rules from glob.c. (user: dmestas, size: 178043) [annotate] [blame] [check-ins using]
//! 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("zit").cli; const cmd_sync = @import("sync.zig"); const extras = @import("../extras.zig"); const merge3 = @import("../merge3.zig"); const logstore = @import("zit").logstore; const storage = @import("zit").storage; const checkout = @import("zit").checkout; const Repository = @import("zit").Repository; const Io = std.Io; const File = Io.File; const managed = zit.managed; const artifact = zit.artifact; const assert = std.debug.assert; // 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 /// Sparse-cone RFC section 4.1: the path is real but the checkout's cone /// does not cover it -- refused by NAME (the diagnostic states the cone /// mechanism, never "no such file"). outside_cone: []const u8, 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, amb: cli.Ambient, ) 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(); // Sparse-cone RFC section 4.1: track requests are gated by the stored // cone. Read once; a corrupt cone section refuses the whole verb (it is // authoritative state, and guessing dense would track out-of-cone paths). var stored_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) { error.OutOfMemory => return addFail(streams, "", error.OutOfMemory), else => return addFail(streams, "", error.Storage), }; defer if (stored_cone) |*c| c.deinit(gpa); const sparse: ?*const zit.cone.Cone = if (stored_cone) |*c| c else null; // 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(); // #64 — a directory expansion honors the ignore set exactly as // `addremove` does (same walk, same exclusions). Naming an ignored file // EXPLICITLY still adds it: the exact-path arm never consults ignores, // which is the deliberate escape hatch. // zit#164 --force: the expansion takes ignored files too. var add_config: RepoConfig = .load(gpa, io, root_dir); defer add_config.deinit(gpa); var add_filter: SweepFilter = loadFilter(gpa, io, arena, root_dir, amb, add_config.ptr(), parsed.dotfiles, !parsed.force, true); defer add_filter.deinit(); // zit#174 item 11: an explicit `zit add manifest` is refused while the // checkout is writing that stamp, exactly as the sweep hides it from a // directory expansion — the same derivation feeding both doors. const add_hidden: []const []const u8 = add_filter.roots[0..add_filter.roots_len]; for (parsed.paths) |arg| { const outcome = classifyPath(arena, io, root.abs, root_dir, cwd_abs, &set, sparse, add_hidden, 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 {}; }, .outside_cone => |key| { // Names the mechanism and the remedy; never "no such file" or // "corrupt" (RFC section 4.1 diagnostic discipline). failed = true; streams.err.print( "zit add: '{s}' is outside this checkout's sparse cone\n" ++ "widen it with 'zit update --cone={s}' or return to dense with 'zit update --dense'\n", .{ key, key }, ) 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). // zit#174 item 7: a dot-prefixed directory NAMED here still // expands. That walk is specific to this one argument (it lifts // the dot rule for this path alone), so it is neither taken from // nor written to the shared memo; every other directory keeps // sharing the single walk. var named: ?extras.Extras = null; defer if (named) |*n| n.deinit(); const items = if (needsDotExempt(add_filter.show_dotfiles, dir_key)) blk: { var exempt_filter = add_filter.filter(); exempt_filter.dot_exempt = dir_key; named = sweepRootNow(gpa, io, root.abs, &set, exempt_filter) catch |e| { failed = true; _ = sweepFail(streams, "add", e); continue; }; break :blk named.?.items(); } else sweepRootOnce(gpa, io, root.abs, &set, add_filter.filter(), &swept) catch |e| { failed = true; _ = sweepFail(streams, "add", e); continue; }; for (items) |entry| { const path = entry.path; if (!pathUnderDir(path, dir_key)) continue; // Sparse expansion filter (RFC section 4.1): a directory // request adds the subtree's IN-CONE part; an uncovered // candidate is not untracked and is skipped silently -- // the explicit-file refusal above covers the named case. if (sparse) |c| { if (!c.covers(path)) 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, filter: extras.Filter, cache: *?extras.Extras, ) extras.SweepError![]const extras.Entry { if (cache.*) |*done| return done.items(); cache.* = try sweepRootNow(gpa, io, root_abs, set, filter); return cache.*.?.items(); } /// One walk of the checkout root under `filter`, owned by the caller. The root /// is opened ITERABLE with no-follow, exactly as `addremove`/`clean` do, so a /// root symlink is never entered. fn sweepRootNow( gpa: std.mem.Allocator, io: std.Io, root_abs: []const u8, set: *const managed.ManagedSet, filter: extras.Filter, ) extras.SweepError!extras.Extras { 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); return extras.sweepIgnoring(gpa, io, iter_root, set, filter); } /// zit#174 item 7 — whether expanding the directory argument `dir_key` needs /// the dot rule lifted for that one path. It does when the dot rule is active /// and some component of the named path begins with `.`: fossil applies the /// skip to what its scan discovers, never to the directory the user named, so /// `zit add .fossil-settings` must still expand. `null` is the whole checkout /// root, which names no component and needs no exemption. Pure. fn needsDotExempt(show_dotfiles: bool, dir_key: ?[]const u8) bool { if (show_dotfiles) return false; const key = dir_key orelse return false; assert(key.len != 0); if (key[0] == '.') return true; return std.mem.indexOf(u8, key, "/.") != null; } /// #64 — the effective ignore set for this checkout: the `ignore-glob` /// setting resolved through the tiers `zit settings` reports (a versioned /// `.fossil-settings/`/`.zit-settings/` file, this repository's config frame, /// the operator's global file), combined with a root `.zitignore`. /// /// `cfg` is the config frame the caller already read, so a listing verb opens /// the store exactly ONCE however many settings it needs. Null (no /// exclusions) on any failure to resolve — ignores narrow a listing, so an /// unreadable pattern source must widen back to everything rather than fail /// the verb or silently hide more. /// /// zit#174 item 4 — `with_clean` folds `clean-glob` into the same set. Fossil's /// `add DIR` and `addremove` hand BOTH lists to the scan that expands a /// directory (add.c, then vfile.c's scan skips an entry matching either), /// while `extras`/`status`/`clean` build their scan from `ignore-glob` alone /// (checkin.c). So the admitting verbs pass true and the listing verbs pass /// false, and a `*.o` in `clean-glob` is never added by expansion yet is still /// reported as an extra. Both lists are already comment-stripped and /// `PatternIter` treats the joining newline as a separator, so concatenating /// them yields ONE pattern list — no second matcher, no second match per entry. fn loadIgnores( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: Io.Dir, amb: cli.Ambient, cfg: ?*const logstore.Config, with_clean: bool, ) ?zit.ignore.Set { const r = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .ignore_glob) catch return null; var list: []const u8 = r.value; if (with_clean) { if (cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .clean_glob)) |c| { if (c.value.len != 0) { // Bound: one arena allocation of exactly the two lists plus the // separator. Failure degrades to ignore-glob alone, the same // widen-back direction the doc comment above states. const joined = arena.alloc(u8, list.len + 1 + c.value.len) catch return zit.ignore.fromResolved(gpa, io, root_dir, list); @memcpy(joined[0..list.len], list); joined[list.len] = '\n'; @memcpy(joined[list.len + 1 ..], c.value); list = joined; } } else |_| {} } return zit.ignore.fromResolved(gpa, io, root_dir, list); } /// The owner of everything an `extras.Filter` borrows, so a verb can hold one /// value with one `deinit` rather than three locals in the right order. The /// `Filter` it hands out borrows `self`, so it must not outlive it. const SweepFilter = struct { ignored: ?zit.ignore.Set, show_dotfiles: bool, roots: [extras.max_hidden_roots][]const u8, roots_len: usize, fn filter(self: *const SweepFilter) extras.Filter { return .{ .ignored = if (self.ignored) |*ig| ig else null, .show_dotfiles = self.show_dotfiles, .hidden_roots = self.roots[0..self.roots_len], }; } fn deinit(self: *SweepFilter) void { if (self.ignored) |*ig| ig.deinit(); self.* = undefined; } }; /// zit#174 item 7 + item 11 — everything a tree scan is narrowed by, built from /// the ONE config frame the verb already read plus the checkout record. /// /// `dotfiles_flag` is the verb's `--dotfiles`; it wins over the setting, which /// is read through `settings.isTruth` (fossil's `is_truth`, db.c) so every /// spelling fossil accepts — `on`, `yes`, `true`, a non-zero leading integer — /// means the same thing at every tier. Hiding is the default, as in fossil. /// /// Null ignores (no exclusions) on any failure to resolve, as `loadIgnores` /// documents: ignores narrow a listing, so an unreadable source must widen back /// to everything rather than fail the verb or silently hide more. /// `use_ignores` false is `add --force` (zit#164), whose expansion deliberately /// takes ignored files: the read is skipped rather than made and discarded. /// `with_clean` is zit#174 item 4 — true for the verbs that ADMIT files /// (`add`, `addremove`), false for the ones that LIST them; `loadIgnores` /// documents why. `add --force` suppresses both lists through the one switch. fn loadFilter( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: Io.Dir, amb: cli.Ambient, cfg: ?*const logstore.Config, dotfiles_flag: bool, use_ignores: bool, with_clean: bool, ) SweepFilter { var out: SweepFilter = .{ .ignored = if (use_ignores) loadIgnores(gpa, io, arena, root_dir, amb, cfg, with_clean) else null, .show_dotfiles = dotfiles_flag, .roots = undefined, .roots_len = 0, }; if (!out.show_dotfiles) { if (cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .dotfiles)) |r| { out.show_dotfiles = zit.settings.isTruth(r.value); } else |_| {} } out.roots_len = hiddenRoots(io, root_dir, &out.roots); assert(out.roots_len <= extras.max_hidden_roots); return out; } /// The manifest build-stamp names this checkout is currently writing, read from /// the checkout record's stored selector letters rather than from the setting: /// the stored letters are what actually wrote the stamps on disk, so they are /// the only value under which a stamp file is not user content. `r` names /// `manifest`, `u` its uuid, `t` its tags (checkout.zig). Returns how many of /// `out` were filled; no allocation, one bounded read. fn hiddenRoots(io: std.Io, root_dir: Io.Dir, out: *[extras.max_hidden_roots][]const u8) usize { var ck_buf: [checkout.max_ck_payload + 1]u8 = undefined; const ck = (checkout.read(io, root_dir, &ck_buf) catch return 0) orelse return 0; var n: usize = 0; const rows = [_]struct { letter: u8, name: []const u8 }{ .{ .letter = 'r', .name = "manifest" }, .{ .letter = 'u', .name = "manifest.uuid" }, .{ .letter = 't', .name = "manifest.tags" }, }; for (rows) |row| { if (std.mem.indexOfScalar(u8, ck.manifest_flags, row.letter) != null) { out[n] = row.name; n += 1; } } return n; } /// The config frame every listing verb reads once, plus the borrowed pointer /// its setting lookups want. Null throughout when the store cannot be read: /// each setting then resolves from the versioned/global tiers alone. const RepoConfig = struct { owned: ?logstore.OwnedConfig, fn load(gpa: std.mem.Allocator, io: std.Io, root_dir: Io.Dir) RepoConfig { return .{ .owned = cmd_sync.readRepositoryConfig(gpa, io, root_dir) }; } fn ptr(self: *const RepoConfig) ?*const logstore.Config { return if (self.owned) |*o| &o.config else null; } fn deinit(self: *RepoConfig, gpa: std.mem.Allocator) void { if (self.owned) |*o| o.deinit(gpa); self.* = undefined; } }; /// 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, sparse: ?*const zit.cone.Cone, hidden_roots: []const []const u8, 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. Control files are never version-controlled (B-add-7). Checked before // stat because they exist as real files and would otherwise add. The // manifest build stamps this checkout is writing join them for `add` // only (zit#174 item 11, fossil add.c folds them into // `fossil_reserved_name`): they are generated files while the flags // select them, and fossil refuses them by name for the same reason. if (isControlFile(key) or isHiddenRoot(key, hidden_roots)) 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 => { // Sparse gate (RFC section 4.1): a directory argument is a request // for its subtree; it stands when the subtree intersects the cone // (the sweep then adds exactly the in-cone part) and refuses when // it cannot contain a single trackable path. if (sparse) |c| { if (!c.intersectsSubtree(key)) return .{ .outside_cone = key }; } return .{ .directory = key }; }, .file => { // Sparse gate: an explicit file outside the cone refuses by name. if (sparse) |c| { if (!c.covers(key)) return .{ .outside_cone = key }; } }, // 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: the registry's three names /// (`.zit`, `.zit-checkout`, `.zit-cache`) and the `.zit-tmp-` scratch prefix /// — names `zit` creates, never user content (B-extras-5). fn isControlFile(key: []const u8) bool { return extras.isControlName(key); } /// Whether `key` names one of the manifest build stamps a checkout is currently /// writing — a ROOT name, matching the sweep's `Filter.hidden_roots` rule, so /// `sub/manifest` stays ordinary content. Bounded by `hidden_roots.len`, which /// its producer caps at `extras.max_hidden_roots`. fn isHiddenRoot(key: []const u8, hidden_roots: []const []const u8) bool { assert(hidden_roots.len <= extras.max_hidden_roots); for (hidden_roots) |name| { if (std.mem.eql(u8, key, name)) return true; } return false; } /// 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-checkout 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 "DELETED "; const disposition: []const u8 = if (parsed.hard) " (deleted from disk)" else ""; 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("DELETED {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-checkout 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, allow_root: bool, ) error{OutOfMemory}!MvResolved { const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) { .outside => return .{ .refuse = "is outside the repository" }, .root => if (allow_root) return .{ .key = "" } else return .{ .refuse = "is the repository root" }, .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.) const MvPair = struct { old: []const u8, new: []const u8 }; /// zit#164: `mv OLD... NEW|DIR`. A tracked file moves to NEW, or into DIR /// (an existing directory, a tracked directory prefix, or the root); a /// tracked directory moves as a unit, every file beneath it recorded as its /// own rename. Legality is checked for the whole request before anything /// moves; the disk moves first, then the managed set is written once, and a /// failed write moves everything back. `--soft` records without touching the /// tree; `-n` only names the moves. pub fn runMv( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, verb_args: []const []const u8, streams: cli.Streams, ) cli.Exit { const parsed = switch (cli.parseMvArgs(gpa, verb_args) catch return mvFail(streams, "", error.OutOfMemory)) { .ok => |p| p, .unknown_option => |tok| return cli.usageError(streams, "mv", .unknown_option, tok), }; defer gpa.free(parsed.operands); if (parsed.help) { streams.out.writeAll(mv_help) catch {}; return .ok; } const n = parsed.operands.len; if (n < 2) return mvMissingOperand(streams); 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); var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e| return mvFail(streams, "", e); defer set.deinit(); const keys = arena.alloc([]const u8, n) catch return mvFail(streams, "", error.OutOfMemory); for (parsed.operands, 0..) |arg, i| { keys[i] = switch (mvResolveKey(arena, root.abs, cwd_abs, arg, i + 1 == n) catch return mvFail(streams, "", error.OutOfMemory)) { .key => |k| k, .refuse => |ph| return mvRefuse(streams, arg, ph), }; } const dest = keys[n - 1]; const into_dir = dest.len == 0 or n > 2 or mvIsDir(io, root_dir, dest) or mvTrackedUnder(&set, dest); var pairs: std.ArrayList(MvPair) = .empty; // every tracked file that moves var dirs: std.ArrayList(MvPair) = .empty; // directories that move as a unit on disk for (keys[0 .. n - 1]) |src| { const target = if (into_dir) mvJoin(arena, dest, std.fs.path.basenamePosix(src)) catch return mvFail(streams, "", error.OutOfMemory) else dest; if (set.contains(src)) { pairs.append(arena, .{ .old = src, .new = target }) catch return mvFail(streams, "", error.OutOfMemory); } else if (mvTrackedUnder(&set, src)) { dirs.append(arena, .{ .old = src, .new = target }) catch return mvFail(streams, "", error.OutOfMemory); for (set.iterate()) |entry| { if (!mvUnder(entry, src)) continue; // The set frees its entry on remove; the pair keeps its own copy. const old = arena.dupe(u8, entry) catch return mvFail(streams, "", error.OutOfMemory); const moved = mvJoin(arena, target, entry[src.len + 1 ..]) catch return mvFail(streams, "", error.OutOfMemory); pairs.append(arena, .{ .old = old, .new = moved }) catch return mvFail(streams, "", error.OutOfMemory); } } else { streams.err.print("zit mv: '{s}' is not tracked\n", .{src}) catch {}; return .failure; } } { var stored_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) { error.OutOfMemory => return mvFail(streams, "", error.OutOfMemory), else => return mvFail(streams, "", error.Storage), }; defer if (stored_cone) |*c| c.deinit(gpa); if (stored_cone) |*c| { for (pairs.items) |pr| { if (c.covers(pr.new)) continue; streams.err.print( "zit mv: '{s}' is outside this checkout's sparse cone\n" ++ "widen it with 'zit update --cone={s}' or return to dense with 'zit update --dense'\n", .{ pr.new, pr.new }, ) catch {}; return .failure; } } } for (pairs.items) |pr| { if (set.contains(pr.new)) { streams.err.print("zit mv: '{s}' is already tracked\n", .{pr.new}) catch {}; return .failure; } } if (!parsed.soft) { for (dirs.items) |d| if (mvCheckDisk(io, root_dir, d.old, d.new, streams)) |exit| return exit; for (pairs.items) |pr| { if (mvInsideDirMove(dirs.items, pr.old)) continue; // rides with its directory if (mvCheckDisk(io, root_dir, pr.old, pr.new, streams)) |exit| return exit; } } if (parsed.dry_run) { for (pairs.items) |pr| streams.out.print("would rename {s} -> {s}\n", .{ pr.old, pr.new }) catch {}; return .ok; } var done: std.ArrayList(MvPair) = .empty; // disk moves made so far, for rollback if (!parsed.soft) { for (dirs.items) |d| { root_dir.rename(d.old, root_dir, d.new, io) catch |e| { mvUndoMoves(io, root_dir, done.items, streams); return mvRenameFail(streams, d.old, d.new, e); }; done.append(arena, d) catch return mvFail(streams, "", error.OutOfMemory); } for (pairs.items) |pr| { if (mvInsideDirMove(dirs.items, pr.old)) continue; root_dir.rename(pr.old, root_dir, pr.new, io) catch |e| { mvUndoMoves(io, root_dir, done.items, streams); return mvRenameFail(streams, pr.old, pr.new, e); }; done.append(arena, pr) catch return mvFail(streams, "", error.OutOfMemory); } } for (pairs.items) |pr| { std.debug.assert(set.remove(pr.old)); const inserted = set.insert(pr.new) catch |e| { mvUndoMoves(io, root_dir, done.items, streams); return mvFail(streams, pr.new, e); }; std.debug.assert(inserted); set.recordRename(pr.old, pr.new) catch |e| { mvUndoMoves(io, root_dir, done.items, streams); return mvFail(streams, pr.new, e); }; } set.write(io, root_dir) catch |e| { mvUndoMoves(io, root_dir, done.items, streams); return mvFail(streams, "", e); }; for (pairs.items) |pr| streams.out.print("RENAME {s} {s}\n", .{ pr.old, pr.new }) catch {}; return .ok; } /// `path` lies strictly beneath directory `dir` (`dir/...`). fn mvUnder(path: []const u8, dir: []const u8) bool { return path.len > dir.len + 1 and std.mem.startsWith(u8, path, dir) and path[dir.len] == '/'; } fn mvTrackedUnder(set: *const managed.ManagedSet, dir: []const u8) bool { if (dir.len == 0) return false; for (set.iterate()) |entry| if (mvUnder(entry, dir)) return true; return false; } fn mvInsideDirMove(dirs: []const MvPair, path: []const u8) bool { for (dirs) |d| if (mvUnder(path, d.old)) return true; return false; } fn mvJoin(arena: std.mem.Allocator, dir: []const u8, name: []const u8) error{OutOfMemory}![]const u8 { if (dir.len == 0) return arena.dupe(u8, name); return std.fmt.allocPrint(arena, "{s}/{s}", .{ dir, name }); } fn mvIsDir(io: std.Io, dir: std.Io.Dir, key: []const u8) bool { const st = dir.statFile(io, key, .{ .follow_symlinks = false }) catch return false; return st.kind == .directory; } /// The on-disk preconditions of one move: NEW absent, OLD present, NEW's /// parent present. Reports the first failure and returns its exit. fn mvCheckDisk(io: std.Io, root_dir: std.Io.Dir, old_key: []const u8, new_key: []const u8, streams: cli.Streams) ?cli.Exit { 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; } 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; } /// Best-effort reversal of the disk moves made so far, newest first, when a /// later step failed and nothing was recorded. fn mvUndoMoves(io: std.Io, root_dir: std.Io.Dir, done: []const MvPair, streams: cli.Streams) void { var i = done.len; while (i > 0) { i -= 1; root_dir.rename(done[i].new, root_dir, done[i].old, io) catch |e| { _ = mvRollbackFail(streams, done[i].old, done[i].new, e); }; } } fn mvMissingOperand(s: cli.Streams) cli.Exit { s.err.writeAll("zit mv: at least two operands required: OLD... NEW|DIR\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-checkout 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-checkout 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, header: bool, amb: cli.Ambient, ) cli.Exit { // 1. Parse (B-status-1). --help wins over everything (B-status-14). const parsed = switch (cli.parseStatusArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "status", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "status", .extra_argument, tok), }; if (parsed.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, .io = io, .root_dir = root_dir, .args = parsed, .merge_pending = ck.merge_parent.len != 0, }; defer sink.report.deinit(gpa); const branch = if (ck.branch.len == 0) "trunk" else ck.branch; // Fossil's status block leads (zit#170); `changes` is the same report // without it, as in Fossil. -v is accepted and adds nothing now. if (header) { if (unborn) { sink.report.print(gpa, "repository: {s}/.zit\nlocal-root: {s}/\ncheckout: none yet\ntags: {s}\n", .{ root.abs, root.abs, branch }) catch return stateOom(streams, "status"); } else { statusHeader(gpa, io, arena, streams, root.abs, ck.version, &sink.report); } } if (ck.merge_parent.len != 0) { sink.report.print(gpa, "MERGED_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). // zit#163: --extra / --differ append the untracked files, the same sweep // `zit extras` runs, as `extra <path>` rows. if (parsed.extra) { var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{ .access_sub_paths = true, .iterate = true, .follow_symlinks = false, }) catch return sweepFail(streams, "status", error.Storage); defer iter_root.close(io); var status_config: RepoConfig = .load(gpa, io, root_dir); defer status_config.deinit(gpa); var status_filter: SweepFilter = loadFilter(gpa, io, arena, root_dir, amb, status_config.ptr(), parsed.dotfiles, true, false); defer status_filter.deinit(); var swept = extras.sweepIgnoring(gpa, io, iter_root, &set, status_filter.filter()) catch |e| return sweepFail(streams, "status", e); defer swept.deinit(); var stored_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "status"), else => return managedFail(streams, "status", error.Storage), }; defer if (stored_cone) |*c| c.deinit(gpa); for (swept.items()) |e| { if (stored_cone) |*c| if (!c.covers(e.path)) continue; sink.row("extra", "EXTRA", e.path) catch return stateOom(streams, "status"); } } // A clean tree says so under `status`; `changes` prints nothing, as // Fossil's does. if (sink.changes == 0 and header) { 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, io: std.Io, root_dir: Io.Dir, args: cli.StatusArgs, /// A merge is recorded, so an edited file may be a conflict (zit#163). merge_pending: bool, 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 switch (entry.inspection) { .same => return, // clean: no row .added => try self.row("added", "ADDED", entry.path), .modified => { if (self.merge_pending and self.hasConflictMarker(entry.path)) { try self.row("conflict", "CONFLICT", entry.path); } else { try self.row("edited", "EDITED", entry.path); } }, // Absent on disk, whether or not the check-in carried it. .none, .deleted => try self.row("missing", "MISSING", entry.path), .io_error => return error.Storage, .oom => return error.OutOfMemory, .corrupt => return error.Corrupt, } } /// One row, subject to the kind filters and the --classify spelling. fn row(self: *StatusSink, word: []const u8, upper: []const u8, path: []const u8) error{OutOfMemory}!void { const a = self.args; if (a.filtered()) { const wanted = (a.added and std.mem.eql(u8, word, "added")) or (a.edited and std.mem.eql(u8, word, "edited")) or (a.missing and std.mem.eql(u8, word, "missing")) or (a.conflict and std.mem.eql(u8, word, "conflict")) or (a.extra and std.mem.eql(u8, word, "extra")); if (!wanted) return; } const before = self.report.items.len; // Fossil's words in Fossil's column, by default (zit#170); the // lower-case `word` keys the filters above. try self.report.print(self.gpa, "{s: <10} {s}\n", .{ upper, path }); std.debug.assert(self.report.items.len > before); // a row is never empty self.changes += 1; } /// Whether the working file carries merge3's conflict envelope at a line /// start — the same test `commit` refuses on. fn hasConflictMarker(self: *StatusSink, path: []const u8) bool { const bytes = self.root_dir.readFileAlloc(self.io, path, self.gpa, .limited(1 << 26)) catch return false; defer self.gpa.free(bytes); if (std.mem.startsWith(u8, bytes, merge3.conflict_sentinel)) return true; return std.mem.indexOf(u8, bytes, "\n" ++ merge3.conflict_sentinel) != null; } }; /// The -v header block (zit#163): Fossil's `repository / checkout / parent / /// tags / comment` lines, from the checkout's check-in. Silent on any read /// fault — the header is a courtesy, never a reason for status to fail. /// Fossil's checkout block — repository, local-root, checkout, parent, tags, /// comment — with twelve-character names and the weekday in the times /// (zit#170). Shared by `status` and bare `info`. pub fn statusHeader(gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, root_abs: []const u8, version: []const u8, report: *std.ArrayList(u8)) void { var repo: Repository = undefined; var repo_root: Io.Dir = undefined; const quiet: cli.Streams = .{ .out = streams.err, .err = streams.err }; if (main.openRepo(gpa, io, arena, quiet, "status", null, false, true, &repo, &repo_root) != .ok) return; defer repo_root.close(io); defer repo.deinit(gpa); const rid = repo.resolve(version, .checkin) catch return; report.print(gpa, "repository: {s}/.zit\nlocal-root: {s}/\n", .{ root_abs, root_abs }) catch return; var om = (repo.manifest(gpa, rid) catch return) orelse return; defer om.deinit(gpa); var tbuf: [32]u8 = undefined; report.print(gpa, "checkout: {s} {s}\n", .{ cli.shortHash(version), cli.humanTime(repo.state().mtimeOf(rid) orelse 0, &tbuf) }) catch return; if (repo.state().primaryParentOf(rid)) |p| { var pbuf: [32]u8 = undefined; report.print(gpa, "parent: {s} {s}\n", .{ cli.shortHash(repo.hashOf(p)), cli.humanTime(repo.state().mtimeOf(p) orelse 0, &pbuf) }) catch return; } report.print(gpa, "tags: {s}", .{repo.branchOf(rid)}) catch return; const tags = repo.state().tagsOf(gpa, rid) catch return; defer gpa.free(tags); for (tags) |t| { if (t.tagtype != .singleton or !std.mem.startsWith(u8, t.name, main.sym_prefix)) continue; report.print(gpa, ", {s}", .{t.name[main.sym_prefix.len..]}) catch return; } report.append(gpa, '\n') catch return; const comment: []const u8 = if (om.manifest.c) |c| blk: { const buf = arena.alloc(u8, c.comment.len) catch break :blk c.comment; break :blk artifact.decodeEscapes(c.comment, buf); } else ""; const user = if (om.manifest.u) |u| u.login else ""; report.print(gpa, "comment: {s} (user: {s})\n", .{ main.firstCommentLine(comment), user }) catch return; } /// 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, amb: cli.Ambient, ) cli.Exit { // 1. Parse (B-extras-11/-12/-13). --help wins over any trailing token. const parsed = switch (cli.parseExtrasArgs(verb_args)) { .ok => |a| a, .unknown_option => |tok| return cli.usageError(streams, "extras", .unknown_option, tok), .extra_argument => |tok| return cli.usageError(streams, "extras", .extra_argument, tok), }; if (parsed.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 extras_config: RepoConfig = .load(gpa, io, root_dir); defer extras_config.deinit(gpa); var extras_filter: SweepFilter = loadFilter(gpa, io, arena, root_dir, amb, extras_config.ptr(), parsed.dotfiles, true, false); defer extras_filter.deinit(); var result = extras.sweepIgnoring(gpa, io, iter_root, &set, extras_filter.filter()) catch |e| return sweepFail(streams, "extras", e); defer result.deinit(); // Sparse-cone RFC section 4.1: an uncovered on-disk path is NOT an extra. // Filtered at render, so the sweep machinery stays cone-blind. var stored_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "extras"), else => return managedFail(streams, "extras", error.Storage), }; defer if (stored_cone) |*c| c.deinit(gpa); // 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| { if (stored_cone) |*c| { if (!c.covers(e.path)) continue; } 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, /// compute ONE selection, then branch on it. Un-forced is a gate that refuses /// (B-clean-1/-2) and deletes only what `clean-glob` named (zit#174 item 4); /// `--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, amb: cli.Ambient, ) 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. // ONE store open for the whole verb: the ignore set and the keep list are // both settings, and clean used to pay a second, sidecar-refreshing open // for the second of them. var clean_config: RepoConfig = .load(gpa, io, root_dir); defer clean_config.deinit(gpa); var clean_filter: SweepFilter = loadFilter(gpa, io, arena, root_dir, amb, clean_config.ptr(), args.dotfiles, true, false); defer clean_filter.deinit(); var result = extras.sweepIgnoring(gpa, io, iter_root, &set, clean_filter.filter()) catch |e| return sweepFail(streams, "clean", e); defer result.deinit(); // Sparse-cone RFC section 4.1: an uncovered on-disk path is not an extra, // and clean deleting one would be silent data loss under that rule. The // cone filters the preview, the gate count, and the deletion identically. var stored_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "clean"), else => return managedFail(streams, "clean", error.Storage), }; defer if (stored_cone) |*c| c.deinit(gpa); const sparse: ?*const zit.cone.Cone = if (stored_cone) |*c| c else null; // #83 — `keep-glob`: files clean must never delete. Distinct from // `ignore-glob`, which removes them from the extras set entirely: a kept // file is still an extra, still listed by `zit extras`, and merely not // deletable. That distinction is the setting's whole reason to exist — // "keep telling me this is here, never remove it" — for local config and // scratch an operator wants to notice but not lose. // // It matters more here than it does in fossil: fossil's `clean` is undoable // by default and zit's is not, so a protection list is the only thing // standing between a mistyped glob and permanent loss. var keep = loadKeeps(gpa, io, arena, root_dir, amb, clean_config.ptr()); defer if (keep) |*k| k.deinit(); const kept: ?*const zit.ignore.Set = if (keep) |*k| k else null; // zit#174 item 4 — `clean-glob`: files clean deletes WITHOUT --force. // fossil's clean skips its prompt for them (checkin.c), so the operator has // already declared these disposable; keep-glob still wins over the // declaration. Null on any failure to read, which is the safe direction // here: nothing extra is deleted and the gate still refuses. var cleanable = loadCleans(gpa, io, arena, root_dir, amb, clean_config.ptr()); defer if (cleanable) |*c| c.deinit(); const cleaned: ?*const zit.ignore.Set = if (cleanable) |*c| c else null; // zit#174 item 5 — the names `--emptydirs` must never remove. const empty_dirs: []const u8 = blk: { const r = cmd_sync.localSetting(io, arena, root_dir, amb, clean_config.ptr(), .empty_dirs) catch break :blk ""; break :blk r.value; }; // ONE selection for every arm, so the preview, the gate and the deletion // can never disagree about what clean would remove. --dry-run runs it with // `force` set: the preview lists every non-kept candidate and deletes none. const sel = selectClean(arena, &result, sparse, kept, cleaned, args.dry_run or args.force) catch return stateOom(streams, "clean"); // 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 (sel.delete) |path| streams.out.print("{s}\n", .{path}) catch {}; noteKept(streams, sel.kept); return .ok; } if (sel.refuse != 0) { // Without --force only the clean-glob matches go. The note about what // WAS deleted comes FIRST: the irreversible half of this run is // announced before the refusal, so the output never reads as if // nothing happened. const exit = cleanDelete(streams, io, iter_root, sel.delete); noteCleaned(streams, sel.by_glob); noteKept(streams, sel.kept); pruneEmptied(io, iter_root, sel.delete, empty_dirs, args.emptydirs and exit == .ok); _ = cleanRefuse(streams, sel.refuse); return .failure; } if (sel.delete.len == 0) { // Nothing to guard and nothing to delete (B-clean-8). noteKept(streams, sel.kept); return .ok; } // --force: delete exactly the swept set (B-clean-5..10), cone- and keep-scoped. // Also the un-forced run whose every candidate was a clean-glob match: with // nothing to refuse, it deletes them and exits 0. noteKept(streams, sel.kept); const exit = cleanDelete(streams, io, iter_root, sel.delete); noteCleaned(streams, sel.by_glob); pruneEmptied(io, iter_root, sel.delete, empty_dirs, args.emptydirs and exit == .ok); return exit; } /// What one `clean` run decided, computed ONCE before the arms fork so the /// preview, the gate and the deletion cannot disagree. `delete` borrows the /// sweep's paths and is in the sweep's order (ascending). const Selection = struct { delete: []const []const u8, /// Candidates held back for want of `--force`; always 0 when `force`. refuse: usize, /// Candidates keep-glob protected. kept: usize, /// How many of `delete` are in it because clean-glob named them. by_glob: usize, }; /// The whole of clean's policy, as a pure function over the swept set. /// fossil's order, and its precedence: a path outside the sparse cone is not a /// candidate at all (RFC 4.1), keep-glob wins next (checkin.c tests it first /// and `continue`s), then clean-glob makes a file deletable without --force, /// then everything else needs --force. The caller passes `force = true` for /// --dry-run as well, which is what makes the preview list every non-kept /// candidate while deleting none. fn selectClean( arena: std.mem.Allocator, result: *const extras.Extras, sparse: ?*const zit.cone.Cone, kept: ?*const zit.ignore.Set, cleaned: ?*const zit.ignore.Set, force: bool, ) error{OutOfMemory}!Selection { const items = result.items(); // Bound: at most one entry of the finite swept set, allocated once. const buf = try arena.alloc([]const u8, items.len); var out: Selection = .{ .delete = &.{}, .refuse = 0, .kept = 0, .by_glob = 0 }; var n: usize = 0; for (items) |e| { if (sparse) |c| { if (!c.covers(e.path)) continue; } if (kept) |k| { if (k.matches(e.path)) { out.kept += 1; continue; } } const by_glob = if (cleaned) |c| c.matches(e.path) else false; if (!by_glob and !force) { out.refuse += 1; continue; } if (by_glob) out.by_glob += 1; buf[n] = e.path; n += 1; } std.debug.assert(n <= items.len); out.delete = buf[0..n]; return out; } /// zit#164 --emptydirs: directories the deletions emptied go too, bottom up; a /// directory that still holds anything simply refuses. `run` is false when the /// flag is absent or a deletion failed, so the walk has one guard rather than /// one per arm. /// /// zit#174 item 5: a directory the `empty-dirs` setting names is never removed /// — the project asked for it to exist with nothing in it, and fossil excludes /// those names from its own directory pass. The walk stops there, since every /// ancestor above a surviving directory is non-empty by construction. fn pruneEmptied( io: std.Io, root: std.Io.Dir, deleted: []const []const u8, empty_dirs: []const u8, run: bool, ) void { if (!run) return; // Bound: one pass over the deleted set, each walking a finite parent chain. for (deleted) |path| { var parent = mvParentDir(path); while (parent) |d| : (parent = mvParentDir(d)) { if (isEmptyDirsName(empty_dirs, d)) break; root.deleteDir(io, d) catch break; } } } /// Whether `dir` is one of the literal names in an `empty-dirs` value — the /// same reading `ensureEmptyDirs` gives the list, so what update created is /// what clean spares. Fossil's directory pass hands the list to its scan as a /// second ignore glob (checkin.c `clean_cmd`, vfile.c `vfile_dir_scan`); since /// its update creates each entry as a literal directory too, a glob there /// protects nothing it could have created, and the literal comparison is the /// one both readings agree on. Neither tool normalizes: a value spelled /// `build/` or `./build` does not protect `build`. fn isEmptyDirsName(empty_dirs: []const u8, dir: []const u8) bool { if (empty_dirs.len == 0) return false; var it: zit.ignore.PatternIter = .init(empty_dirs); // Bound: `PatternIter` advances at least one byte per pattern. while (it.next()) |pattern| { if (std.mem.eql(u8, pattern, dir)) return true; } return false; } /// #83 — the effective `keep-glob` for this checkout, resolved through the /// same tiers `zit settings` reports (versioned file, repository, global). /// Null on any failure to read a source: a protection list that cannot be read /// must not silently protect LESS, so the caller treats null as "no /// protection" and the gate still refuses without --force. The safe direction /// here is the loud one, not the permissive one. fn loadKeeps( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: Io.Dir, amb: cli.Ambient, cfg: ?*const logstore.Config, ) ?zit.ignore.Set { const r = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .keep_glob) catch return null; return zit.ignore.fromPatterns(gpa, r.value); } /// zit#174 item 4 — the effective `clean-glob` for this checkout, resolved /// over the config frame the verb already holds. Null on any failure to read: /// a list that cannot be read must not make clean delete MORE than it would /// have, so the gate simply keeps refusing. fn loadCleans( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, root_dir: Io.Dir, amb: cli.Ambient, cfg: ?*const logstore.Config, ) ?zit.ignore.Set { const r = cmd_sync.localSetting(io, arena, root_dir, amb, cfg, .clean_glob) catch return null; return zit.ignore.fromPatterns(gpa, r.value); } /// Say how many files clean-glob took without being asked for --force. The /// deletion is irreversible and was not gated, so it is never silent. fn noteCleaned(s: cli.Streams, count: usize) void { if (count == 0) return; s.err.print( "note: {d} untracked {s} deleted by clean-glob\n", .{ count, if (count == 1) @as([]const u8, "file") else "files" }, ) catch {}; } /// Say how many files the keep list protected. Silence would be wrong in both /// directions: an operator who expects protection needs to see it working, and /// one who does not expect it needs to learn why `clean` left files behind. fn noteKept(s: cli.Streams, count: usize) void { if (count == 0) return; s.err.print( "note: {d} untracked {s} kept by keep-glob and not deleted\n", .{ count, if (count == 1) @as([]const u8, "file") else "files" }, ) catch {}; } /// 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 paths `selectClean` chose (B-clean-5), through `root` — /// the same ITERABLE handle the sweep enumerated, so no path outside the swept /// set is ever reachable and tracked/control files stay untouched BY /// CONSTRUCTION (B-clean-7: no membership re-check). The cone and keep-glob /// guards live in `selectClean` and nowhere else, which is what makes the /// preview and the deletion impossible to disagree about. A failed unlink is /// reported to stderr and the pass 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 cleanDelete(s: cli.Streams, io: std.Io, root: std.Io.Dir, paths: []const []const u8) cli.Exit { var failed = false; // Bound = the precomputed selection (B-clean-18): no re-walk, no recursion. for (paths) |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, amb: cli.Ambient, ) 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 ar_config: RepoConfig = .load(gpa, io, root_dir); defer ar_config.deinit(gpa); var ar_filter: SweepFilter = loadFilter(gpa, io, arena, root_dir, amb, ar_config.ptr(), args.dotfiles, true, true); defer ar_filter.deinit(); var extra_paths = extras.sweepIgnoring(gpa, io, iter_root, &set, ar_filter.filter()) catch |e| return sweepFail(streams, "addremove", e); defer extra_paths.deinit(); // Sparse-cone RFC section 4.1: addremove's add half tracks only in-cone // extras (the remove half needs no filter -- the managed set is in-cone // by construction, so a missing tracked file is already in-cone). var stored_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |e| switch (e) { error.OutOfMemory => return stateOom(streams, "addremove"), else => return managedFail(streams, "addremove", error.Storage), }; defer if (stored_cone) |*c| c.deinit(gpa); // 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; // Sparse filter: an uncovered on-disk file is not an extra (RFC 4.1); // silent like the symlink skip -- a non-transition, not a report line. if (stored_cone) |*c| { if (!c.covers(path)) continue; } 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-checkout 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; } pub 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; // Same §5.1/§5.2 resolution the current check-in gets in // `CheckinBaseline.open`. A delta manifest's BASELINE can itself be // S-bearing, and a baseline whose file set was read as a bare `.f` run // would under-report exactly the paths the tree carries. if (self.manifest.?.s.len != 0) { Repository.flattenTreeFromLog(self.gpa, self.log, &self.manifest.?) catch |err| { self.failure = if (err == error.OutOfMemory) .oom else .corrupt; return null; }; } return .{ .baseline_hash = if (self.manifest.?.b) |b| b.hash else null, .files = self.manifest.?.flattened orelse 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; } /// The working-tree stat memo (#118): per tracked file, the (mtime, size) /// observed the last time a scan hashed it and the artifact name that hash /// produced. A later scan whose stat matches trusts the recorded name and /// skips the read entirely, so `status`/`diff` cost O(stat) instead of /// O(bytes-in-tree) on an unchanged tree. /// /// TECHNIQUE: fossil's vfile mtime+size heuristic (Fossil SCM, `vfile.c`, /// D. Richard Hipp) — including its accepted blind spot: an edit that /// preserves both size and mtime is invisible until the entry is evicted. /// The race guard below is fossil's too: never record a file whose mtime is /// within the clock's granularity of "now", so a write landing in the same /// tick as the scan cannot freeze a stale hash. /// /// Advisory tier (`.zit-cache/treestat`, via `repocache`): any malformation /// parses as an empty memo, and deleting the file costs one full re-hash, /// never an answer. pub const TreeStat = struct { const Entry = struct { mtime_ns: i96, size: u64, hash: []const u8 }; const magic = "zTS1"; /// Wider than any real filesystem timestamp granularity (FAT: 2s). const race_window_ns: i96 = 2 * std.time.ns_per_s; arena: std.heap.ArenaAllocator, map: std.StringHashMapUnmanaged(Entry) = .empty, dirty: bool = false, now_ns: i96, /// Never fails: absence, unreadability, or malformation is an empty memo. pub fn load(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir) TreeStat { var self: TreeStat = .{ .arena = std.heap.ArenaAllocator.init(gpa), .now_ns = Io.Timestamp.now(io, .real).nanoseconds, }; const blob = zit.repocache.tryLoadTreeStat(gpa, io, root_dir) orelse return self; defer gpa.free(blob); self.parse(blob) catch self.map.clearRetainingCapacity(); return self; } pub fn deinit(self: *TreeStat) void { self.arena.deinit(); self.* = undefined; } fn parse(self: *TreeStat, blob: []const u8) error{ OutOfMemory, Corrupt }!void { if (blob.len < 8 or !std.mem.eql(u8, blob[0..4], magic)) return error.Corrupt; const payload = blob[8..]; if (std.mem.readInt(u32, blob[4..8], .little) != zit.crc32.Crc32.hash(payload)) return error.Corrupt; const a = self.arena.allocator(); var rest = payload; while (rest.len != 0) { if (rest.len < 2) return error.Corrupt; const path_len: usize = std.mem.readInt(u16, rest[0..2], .little); rest = rest[2..]; if (rest.len < path_len + 12 + 8 + 1) return error.Corrupt; const path = rest[0..path_len]; rest = rest[path_len..]; const mtime_ns = std.mem.readInt(i96, rest[0..12], .little); rest = rest[12..]; const size = std.mem.readInt(u64, rest[0..8], .little); rest = rest[8..]; const hash_len: usize = rest[0]; rest = rest[1..]; if (hash_len != 40 and hash_len != 64) return error.Corrupt; if (rest.len < hash_len) return error.Corrupt; const hash = rest[0..hash_len]; rest = rest[hash_len..]; try self.map.put(a, try a.dupe(u8, path), .{ .mtime_ns = mtime_ns, .size = size, .hash = try a.dupe(u8, hash), }); } } /// The recorded artifact name for `path`, iff the recorded (mtime, size) /// match `st` exactly. A future-dated mtime is never trusted. fn lookup(self: *const TreeStat, path: []const u8, st: File.Stat) ?[]const u8 { const e = self.map.get(path) orelse return null; if (e.size != st.size or e.mtime_ns != st.mtime.nanoseconds) return null; if (e.mtime_ns >= self.now_ns) return null; return e.hash; } /// Record the hash a scan just computed. Silently declined for a file /// whose mtime is inside the race window, and on any allocation failure /// (the memo is advisory; the fallback is the re-hash it replaces). fn note(self: *TreeStat, path: []const u8, st: File.Stat, name: []const u8) void { if (self.now_ns - st.mtime.nanoseconds < race_window_ns) return; if (path.len > std.math.maxInt(u16)) return; const a = self.arena.allocator(); const entry: Entry = .{ .mtime_ns = st.mtime.nanoseconds, .size = st.size, .hash = a.dupe(u8, name) catch return, }; if (self.map.getPtr(path)) |e| e.* = entry else { const key = a.dupe(u8, path) catch return; self.map.put(a, key, entry) catch return; } self.dirty = true; } /// Persist iff a note landed this scan. Best-effort, atomic via /// `repocache`; a failed write leaves the previous memo standing. fn persist(self: *TreeStat, io: std.Io, root_dir: std.Io.Dir) void { if (!self.dirty) return; const a = self.arena.allocator(); var out: std.ArrayList(u8) = .empty; out.appendSlice(a, magic) catch return; out.appendSlice(a, &.{ 0, 0, 0, 0 }) catch return; var it = self.map.iterator(); while (it.next()) |kv| { const path = kv.key_ptr.*; const e = kv.value_ptr.*; var int_buf: [12]u8 = undefined; std.mem.writeInt(u16, int_buf[0..2], @intCast(path.len), .little); out.appendSlice(a, int_buf[0..2]) catch return; out.appendSlice(a, path) catch return; std.mem.writeInt(i96, int_buf[0..12], e.mtime_ns, .little); out.appendSlice(a, int_buf[0..12]) catch return; std.mem.writeInt(u64, int_buf[0..8], e.size, .little); out.appendSlice(a, int_buf[0..8]) catch return; out.append(a, @intCast(e.hash.len)) catch return; out.appendSlice(a, e.hash) catch return; } std.mem.writeInt(u32, out.items[4..8], zit.crc32.Crc32.hash(out.items[8..]), .little); zit.repocache.storeTreeStat(io, root_dir, out.items); } }; /// 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. /// `treestat`, when non-null, may answer the content comparison from the /// stat memo instead of reading the file, and learns every hash this /// inspection does compute. fn inspectDiffFile( gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, path: []const u8, baseline: ?artifact.FCard, treestat: ?*TreeStat, ) 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); // Stat-memo fast path (#118): a recorded hash for this exact (mtime, // size) answers the content comparison from the no-follow stat alone — // no open, no read. An algorithm mismatch with the baseline's name // falls through to the full inspection. memo: { const ts = treestat orelse break :memo; const f = baseline orelse break :memo; const cached = ts.lookup(path, path_stat) orelse break :memo; if (cached.len != f.hash.?.len) break :memo; const memo_mode: artifact.FileMode = if (executable(path_stat.permissions)) .executable else .regular; if (memo_mode != f.mode) return .modified; return if (std.mem.eql(u8, cached, f.hash.?)) .same else .modified; } 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; // Deliberately generic, unlike commit's `too_large`. Reaching this means a // file that WAS committable (so was at or under the ceiling) has since grown // past it in the working tree — diff cannot hash it to compare, and refusing // is right, but carrying a distinct cause to the operator would mean // widening the shared `error{OutOfMemory, Storage, Corrupt}` through every // signature between here and the renderer. Commit is where an operator meets // this limit; this path is the rare aftermath. 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); if (treestat) |ts| ts.note(path, st, name); 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. pub 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, /// The checkout's stored sparse cone, owned here so `walk.sparse` has a /// stable address for the baseline walk's lifetime. Null = dense. sparse_cone: ?zit.cone.Cone = null, /// 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. pub 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; // An unborn checkout has no baseline to open; a rejection, not an // assert, now that `open` is a pub entry point (release-assert rule). if (version.len == 0) return self.fail(.current_corrupt); self.gpa = gpa; self.repo_file = cli.openStoreRead(io, root_dir) 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); // #120 — adopt the persisted scan index. The plain `openReadOnly` // header-walks the whole log, which put an O(store) floor (~0.2s on a // 148k-artifact store) under every diff and status invocation. self.log = cmd_sync.openLogAdoptingIndex(gpa, io, root_dir, self.fs.storageFile(), .refresh_on_scan) 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); // draft-fossil-submanifests-00 §5.1/§5.2 — an S-bearing check-in carries // its file set in sub-manifests, not in its own F run, so `f` alone is // NOT the file set. Resolving it is the same step `commit` performs // before carry-forward; skipping it here made every tracked file look // absent from the current check-in, and `status`/`diff` reported all of // them as `added` on any tree-shaped repository -- identical output // whether or not anything had changed (issue #25). // // §5.1's "downstream MUST NOT distinguish" is exactly this: every reader // of a file set has to go through the derivation seam, and a raw `.f` // read is how a reader silently opts out of it. if (self.current.manifest.s.len != 0) { Repository.flattenTreeFromLog(gpa, &self.log, &self.current.manifest) catch |err| switch (err) { error.OutOfMemory => return self.fail(.{ .baseline = .oom }), else => 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.flattened orelse 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 }), }; // Sparse-cone RFC section 4.1: the baseline walk is cone-scoped so it // compares against exactly the membership a sparse materialization // wrote -- a dense walk here reports every out-of-cone file removed. self.sparse_cone = zit.update.readStoredCone(gpa, io, root_dir) catch |err| switch (err) { error.OutOfMemory => return self.fail(.{ .baseline = .oom }), else => return self.fail(.current_corrupt), }; if (self.sparse_cone) |*c| self.walk.sparse = c; self.opened = true; // last: every fallible step above is behind us } /// §8.4 single-name search over the check-in's effective set — what /// stash's restore path and clean-proof read files by. Null when the /// path is not in the check-in (or the baseline faults; the caller's /// posture is refusal either way). pub fn findFile(self: *CheckinBaseline, path: []const u8) ?artifact.FCard { const result = artifact.lookup(.{ .baseline_hash = if (self.current.manifest.b) |b| b.hash else null, .files = self.current.manifest.flattened orelse self.current.manifest.f, }, path, .{ .ctx = &self.resolver, .resolveFn = DiffBaselineResolver.resolve, }) catch return null; return switch (result) { .found => |f| f, .not_found => null, }; } /// Is the working-tree `path` byte-identical (content and mode) to its /// checked-in version? The clean-target proof `stash apply` runs before /// touching anything. pub fn pathCleanAgainstCheckin(self: *CheckinBaseline, gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, path: []const u8) bool { const fc = self.findFile(path) orelse return false; return inspectDiffFile(gpa, io, root_dir, path, fc, null) == .same; } pub fn deinit(self: *CheckinBaseline, io: std.Io) void { std.debug.assert(self.opened); // never call after a failed `open` if (self.sparse_cone) |*c| c.deinit(self.gpa); 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. pub 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, /// The check-in side's F-card hash for this path, when it has one — what /// a hunk-rendering sink fetches the old content by. Null for a path the /// check-in does not carry (an addition, or the unborn state). baseline_hash: ?[]const u8 = null, }; pub 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. pub 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` // maintains that — but the record is a repository-local FILE, so an // out-of-order pair is data damage and is REJECTED as such rather than // asserted (release-assert rule: an assert is not a rejection). One // compare per adjacent pair, beside per-file stat+read cost. for (paths[0..paths.len -| 1], 1..) |p, i| { if (std.mem.order(u8, p, paths[i]) != .lt) return error.Corrupt; } const path_total = paths.len; // the loop invariants below are about THIS walk's own cursors const w = walk orelse { for (paths) |path| { try sink.emit(.{ .path = path, .inspection = inspectDiffFile(gpa, io, root_dir, path, null, null), .tracked = true, }); } return; }; // The stat memo lives for exactly one walk: loaded once, consulted per // file, persisted only when the walk finishes cleanly (a walk that // errored out learned nothing worth trusting). var treestat = TreeStat.load(gpa, io, root_dir); defer treestat.deinit(); // 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 < path_total 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, null), .tracked = true, }); path_index += 1; }, .eq => { try sink.emit(.{ .path = path.?, .inspection = inspectDiffFile(gpa, io, root_dir, path.?, baseline.?, &treestat), .tracked = true, .baseline_hash = baseline.?.hash, }); path_index += 1; baseline = nextEffectiveFile(w); }, .gt => { try sink.emit(.{ .path = baseline.?.filename, .inspection = .deleted, .tracked = false, .baseline_hash = baseline.?.hash, }); 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 <= path_total); } treestat.persist(io, root_dir); } 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, amb: cli.Ambient, ) cli.Exit { var help = false; var brief = false; var ignore_ws = false; var invert = false; var numstat = false; var by = false; var context: usize = zit.textdiff.default_context; var from_name: ?[]const u8 = null; var to_name: ?[]const u8 = null; var checkin_name: ?[]const u8 = null; var branch_name: ?[]const u8 = null; var filter_args: std.ArrayList([]const u8) = .empty; { var i: usize = 0; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { help = true; } else if (std.mem.eql(u8, tok, "--brief")) { brief = true; } else if (std.mem.eql(u8, tok, "-w") or std.mem.eql(u8, tok, "--ignore-whitespace") or std.mem.eql(u8, tok, "-Z") or std.mem.eql(u8, tok, "--ignore-all-space")) { ignore_ws = true; } else if (std.mem.eql(u8, tok, "--invert")) { invert = true; } else if (std.mem.eql(u8, tok, "--numstat")) { numstat = true; } else if (std.mem.eql(u8, tok, "--by")) { by = true; } else if (std.mem.eql(u8, tok, "-N") or std.mem.eql(u8, tok, "--new-file")) { // An added file's hunks already carry its whole content. } else if (std.mem.eql(u8, tok, "-c") or std.mem.eql(u8, tok, "--context")) { i += 1; if (i >= verb_args.len) return cli.usageError(streams, "diff", .unknown_option, tok); context = std.fmt.parseInt(usize, verb_args[i], 10) catch return cli.usageError(streams, "diff", .unknown_option, verb_args[i]); if (context > 10_000) return cli.usageError(streams, "diff", .unknown_option, verb_args[i]); } else if (std.mem.eql(u8, tok, "--from") or std.mem.eql(u8, tok, "--to") or std.mem.eql(u8, tok, "--checkin") or std.mem.eql(u8, tok, "--branch")) { const slot = if (std.mem.eql(u8, tok, "--from")) &from_name else if (std.mem.eql(u8, tok, "--to")) &to_name else if (std.mem.eql(u8, tok, "--checkin")) &checkin_name else &branch_name; if (slot.* != null) return cli.usageError(streams, "diff", .unknown_option, tok); i += 1; if (i >= verb_args.len) return cli.usageError(streams, "diff", .unknown_option, tok); slot.* = verb_args[i]; } else if (tok.len > 1 and tok[0] == '-') { return cli.usageError(streams, "diff", .unknown_option, tok); } else { filter_args.append(arena, tok) catch return stateOom(streams, "diff"); } } } if (help) { return writeDiffOutput(streams, diff_help); } // Rev-to-rev is the PAIR: `--from A --to B` compares two check-ins with no // working tree in the picture. One without the other has no defined second // operand here (the worktree form below is spelled with no flags at all). if ((from_name == null) != (to_name == null)) { streams.err.writeAll("zit diff: --from and --to must be given together\n") catch {}; return .usage; } if (@as(u8, @intFromBool(from_name != null)) + @as(u8, @intFromBool(checkin_name != null)) + @as(u8, @intFromBool(branch_name != null)) > 1) { streams.err.writeAll("zit diff: --from/--to, --checkin and --branch name one comparison each; give one\n") catch {}; return .usage; } var render: DiffRender = .{ .opts = .{ .context = context, .ignore_whitespace = ignore_ws }, .brief = brief and !by, .invert = invert, .numstat = numstat and !by, .html = by, .html_stamp = amb.now_epoch, }; if (by) { var env_map = amb.environ.createMap(gpa) catch return stateOom(streams, "diff"); defer env_map.deinit(); if (env_map.get("TMPDIR")) |t| render.html_dir = arena.dupe(u8, std.mem.trimEnd(u8, t, "/")) catch return stateOom(streams, "diff"); } // PATH operands narrow the report (zit#164). Inside a checkout they are // taken relative to the current directory; with -R they are repo keys. if (filter_args.items.len != 0) { var filters: std.ArrayList([]const u8) = .empty; const here = if (amb.repo_override == null) cli.discoverRoot(arena, io) catch return stateOom(streams, "diff") else null; const cwd_abs = if (here != null) cli.currentPathAlloc(arena, io) catch return stateOom(streams, "diff") else null; for (filter_args.items) |arg| { if (here != null and cwd_abs != null) { switch (cli.repoRelative(arena, here.?.abs, cwd_abs.?, arg) catch return stateOom(streams, "diff")) { .key => |k| filters.append(arena, k) catch return stateOom(streams, "diff"), .root => filters.append(arena, "") catch return stateOom(streams, "diff"), .outside => { streams.err.print("zit diff: '{s}' is outside the repository\n", .{arg}) catch {}; return .failure; }, } } else { filters.append(arena, std.mem.trimEnd(u8, arg, "/")) catch return stateOom(streams, "diff"); } } if (here) |h| { var d = h.dir; d.close(io); } render.filters = filters.items; } if (from_name) |from| return diffCheckins(gpa, io, arena, streams, amb.repo_override, .{ .names = .{ from, to_name.? } }, render); if (checkin_name) |v| return diffCheckins(gpa, io, arena, streams, amb.repo_override, .{ .checkin = v }, render); if (branch_name) |bn| return diffCheckins(gpa, io, arena, streams, amb.repo_override, .{ .branch = bn }, render); // The worktree comparison reads the checkout the cwd names; `-R` names a // repository with no working tree, so only the check-in pair above can // honor it (#69) -- the same diagnostic the global lift used to emit. if (amb.repo_override != null) { streams.err.writeAll("zit diff: -R/--repo needs --from/--to, --checkin or --branch; the worktree comparison works in a checkout\n") catch {}; return .failure; } 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(); // The walk always sees the whole managed set — narrowing it would make // every unlisted baseline file look deleted; PATH operands filter the // report at the sink instead. const paths = set.iterate(); if (brief and !ignore_ws and !invert and !numstat and !by) { var sink: DiffSink = .{ .gpa = gpa, .filters = render.filters }; defer sink.report.deinit(gpa); if (ck.version.len == 0) { walkWorkingTree(gpa, io, root_dir, paths, null, &sink) catch |err| return compareFail(streams, "diff", err); return finishDiff(gpa, io, streams, render, 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 finishDiff(gpa, io, streams, render, sink.report.items); } var sink: HunkSink = .{ .gpa = gpa, .io = io, .root_dir = root_dir, .log = null, .render = render, }; defer sink.report.deinit(gpa); if (ck.version.len == 0) { walkWorkingTree(gpa, io, root_dir, paths, null, &sink) catch |err| return compareFail(streams, "diff", err); return finishDiff(gpa, io, streams, render, 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); sink.log = &baseline.log; walkWorkingTree(gpa, io, root_dir, paths, &baseline.walk, &sink) catch |err| return compareFail(streams, "diff", err); return finishDiff(gpa, io, streams, render, sink.report.items); } /// `zit diff --from A --to B` — revision-to-revision, name-status only, the /// same A/M/D record report as the worktree form: A for files only in B, D /// for files only in A, M for a content-hash or mode change. Both file sets /// come through `Repository.files` (§9.3), so delta and tree manifests /// resolve exactly as every other verb resolves them; both are in §4.5.3 /// canonical order, so one linear merge walk compares them. fn diffCheckins( gpa: std.mem.Allocator, io: std.Io, arena: std.mem.Allocator, streams: cli.Streams, repo_override: ?[]const u8, what: DiffPair, render: DiffRender, ) cli.Exit { // The zero-parse image path, like every other read verb (#141). This // comparison needs two names resolved and two file lists — exactly what // `ls` needs, and no adjacency or tag table beyond it. Opening `.full` // instead re-derived the whole view per invocation, a cost proportional to // the STORE rather than to the change: 0.35s against 0.04s for a one-line // diff on a 302MB store, and identical whether the diff was 13 lines or // 4126. Reaching for `Repository.open` directly is what made that // invisible — this is the funnel every other verb passes through, and it // is where the mode is chosen. var repo: Repository = undefined; var root_dir: std.Io.Dir = undefined; const rc = main.openRepo(gpa, io, arena, streams, "diff", repo_override, false, true, &repo, &root_dir); if (rc != .ok) return rc; defer root_dir.close(io); defer repo.deinit(gpa); const brief = render.brief; const ignore_ws = render.opts.ignore_whitespace; const opts = render.opts; var rids: [2]Repository.Rid = undefined; switch (what) { .names => |names| for (names, &rids) |name, *rid| { rid.* = diffResolve(streams, &repo, name) orelse return .failure; }, .checkin => |name| { // zit#164 --checkin V: V against its primary parent. rids[1] = diffResolve(streams, &repo, name) orelse return .failure; rids[0] = repo.view.state.primaryParentOf(rids[1]) orelse { streams.err.print("zit diff: check-in '{s}' has no parent to compare against\n", .{name}) catch {}; return .failure; }; }, .branch => |name| { // zit#164 --branch B: from the check-in B forked from to B's tip — // walk primary parents while they still sit on the branch. rids[1] = diffResolve(streams, &repo, name) orelse return .failure; const bname = repo.branchOf(rids[1]); var cur = rids[1]; var steps: usize = 0; while (std.mem.eql(u8, repo.branchOf(cur), bname)) : (steps += 1) { cur = repo.view.state.primaryParentOf(cur) orelse { streams.err.print("zit diff: branch '{s}' has no fork point; it reaches the root check-in\n", .{name}) catch {}; return .failure; }; if (steps > 1_000_000) return main.repoReadFail(streams, "diff"); } rids[0] = cur; }, } if (render.invert) std.mem.swap(Repository.Rid, &rids[0], &rids[1]); var lists: [2]Repository.FileList = undefined; var opened: usize = 0; defer for (lists[0..opened]) |*l| l.deinit(gpa); for (&rids, &lists) |*rid, *list| { list.* = (repo.files(gpa, rid.*) catch return main.repoReadFail(streams, "diff")) orelse return main.repoReadFail(streams, "diff"); opened += 1; } var report: std.ArrayList(u8) = .empty; defer report.deinit(gpa); var it_a = lists[0].iterator(); var it_b = lists[1].iterator(); var a = it_a.next(); var b = it_b.next(); while (a != null or b != null) { const Emit = struct { mark: u8, path: []const u8, old_hash: ?[]const u8, new_hash: ?[]const u8 }; const emit: ?Emit = blk: { if (a == null) break :blk .{ .mark = 'A', .path = b.?.filename, .old_hash = null, .new_hash = b.?.hash }; if (b == null) break :blk .{ .mark = 'D', .path = a.?.filename, .old_hash = a.?.hash, .new_hash = null }; switch (artifact.compareBytes(a.?.filename, b.?.filename)) { .lt => break :blk .{ .mark = 'D', .path = a.?.filename, .old_hash = a.?.hash, .new_hash = null }, .gt => break :blk .{ .mark = 'A', .path = b.?.filename, .old_hash = null, .new_hash = b.?.hash }, .eq => { const changed = a.?.mode != b.?.mode or !std.mem.eql(u8, a.?.hash orelse "", b.?.hash orelse ""); break :blk if (changed) .{ .mark = 'M', .path = a.?.filename, .old_hash = a.?.hash, .new_hash = b.?.hash } else null; }, } }; if (emit != null and !render.selects(emit.?.path)) { // filtered out by a PATH operand } else if (emit) |e| { if (render.numstat) { var old_owned: ?[]u8 = null; defer if (old_owned) |bytes| gpa.free(bytes); if (e.old_hash) |hex| old_owned = repo.blob(gpa, hex) catch return main.repoReadFail(streams, "diff"); var new_owned: ?[]u8 = null; defer if (new_owned) |bytes| gpa.free(bytes); if (e.new_hash) |hex| new_owned = repo.blob(gpa, hex) catch return main.repoReadFail(streams, "diff"); appendNumstat(gpa, &report, e.path, old_owned orelse "", new_owned orelse "", opts) catch return stateOom(streams, "diff"); } else if (brief and !(ignore_ws and e.mark == 'M')) { appendDiffRecord(gpa, &report, e.mark, e.path) catch return stateOom(streams, "diff"); } else { var old_owned: ?[]u8 = null; defer if (old_owned) |bytes| gpa.free(bytes); if (e.old_hash) |hex| { old_owned = repo.blob(gpa, hex) catch return main.repoReadFail(streams, "diff"); } var new_owned: ?[]u8 = null; defer if (new_owned) |bytes| gpa.free(bytes); if (e.new_hash) |hex| { new_owned = repo.blob(gpa, hex) catch return main.repoReadFail(streams, "diff"); } const old_side: ?[]const u8 = if (e.old_hash == null) null else (old_owned orelse ""); const new_side: ?[]const u8 = if (e.new_hash == null) null else (new_owned orelse ""); if (brief) { // -w + --brief: the only way to know a modification is // whitespace-only is to run the comparison, so run the // SAME one the hunk path runs and emit the record only if // it found something. var probe: std.ArrayList(u8) = .empty; defer probe.deinit(gpa); zit.textdiff.unified(gpa, old_side orelse "", new_side orelse "", opts, &probe) catch return stateOom(streams, "diff"); if (probe.items.len != 0) { appendDiffRecord(gpa, &report, e.mark, e.path) catch return stateOom(streams, "diff"); } } else { appendFileReport(gpa, &report, e.path, old_side, new_side, render) catch return stateOom(streams, "diff"); } } } // Advance whichever side(s) the comparison consumed. if (a != null and (b == null or artifact.compareBytes(a.?.filename, b.?.filename) != .gt)) { if (b != null and artifact.compareBytes(a.?.filename, b.?.filename) == .eq) b = it_b.next(); a = it_a.next(); } else { b = it_b.next(); } } return finishDiff(gpa, io, streams, render, report.items); } /// The byte cap on a file read for hunk rendering; a larger file is treated /// as binary (no human reads a gigabyte hunk, and the cap bounds memory). /// Which two trees `diff` compares when it is not the working tree. const DiffPair = union(enum) { names: [2][]const u8, checkin: []const u8, branch: []const u8, }; /// How the report is shaped, shared by the worktree and check-in modes. const DiffRender = struct { opts: zit.textdiff.Options = .{}, brief: bool = false, /// Swap the two sides (zit#164 --invert). invert: bool = false, /// Per-file added/removed line counts instead of hunks (zit#164 --numstat). numstat: bool = false, /// Repo-relative PATH operands: a file, or a directory prefix; "" is the root. filters: []const []const u8 = &.{}, /// zit#165 --by: render side-by-side HTML into a file and open it. html: bool = false, html_dir: []const u8 = "/tmp", html_stamp: i64 = 0, fn selects(self: DiffRender, path: []const u8) bool { if (self.filters.len == 0) return true; for (self.filters) |f| { if (f.len == 0 or std.mem.eql(u8, f, path)) return true; if (path.len > f.len and std.mem.startsWith(u8, path, f) and path[f.len] == '/') return true; } return false; } }; fn diffResolve(streams: cli.Streams, repo: *const Repository, name: []const u8) ?Repository.Rid { return repo.resolve(name, .checkin) catch |e| { switch (e) { error.NotFound => streams.err.print("zit diff: check-in '{s}' not found\n", .{name}) catch {}, error.Ambiguous => streams.err.print("zit diff: name '{s}' is ambiguous\n", .{name}) catch {}, error.OutOfMemory => streams.err.writeAll("zit diff: out of memory\n") catch {}, } return null; }; } /// One `ADDED<TAB>REMOVED<TAB>PATH` line (git's numstat shape); binary files /// show `-` for both counts; a file with no textual change is omitted. fn appendNumstat( gpa: std.mem.Allocator, report: *std.ArrayList(u8), path: []const u8, old: []const u8, new: []const u8, opts: zit.textdiff.Options, ) error{OutOfMemory}!void { if (zit.textdiff.isBinary(old) or zit.textdiff.isBinary(new)) { try report.appendSlice(gpa, "-\t-\t"); try report.appendSlice(gpa, path); try report.append(gpa, '\n'); return; } var hunks: std.ArrayList(u8) = .empty; defer hunks.deinit(gpa); try zit.textdiff.unified(gpa, old, new, opts, &hunks); if (hunks.items.len == 0) return; var added: usize = 0; var removed: usize = 0; var lines = std.mem.splitScalar(u8, hunks.items, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; switch (line[0]) { '+' => added += 1, '-' => removed += 1, else => {}, } } try report.print(gpa, "{d}\t{d}\t{s}\n", .{ added, removed, path }); } /// Hunks, or (zit#165 --by) a side-by-side table under a file heading. fn appendFileReport( gpa: std.mem.Allocator, report: *std.ArrayList(u8), path: []const u8, old_bytes: ?[]const u8, new_bytes: ?[]const u8, render: DiffRender, ) error{OutOfMemory}!void { if (!render.html) return appendFileHunks(gpa, report, path, old_bytes, new_bytes, render.opts); const old = old_bytes orelse ""; const new = new_bytes orelse ""; try report.appendSlice(gpa, "<h2>"); try zit.textdiff.htmlEscape(gpa, report, path); if (old_bytes == null) try report.appendSlice(gpa, " <small>(added)</small>"); if (new_bytes == null) try report.appendSlice(gpa, " <small>(deleted)</small>"); try report.appendSlice(gpa, "</h2>\n"); if (zit.textdiff.isBinary(old) or zit.textdiff.isBinary(new)) { try report.appendSlice(gpa, "<p>Binary files differ.</p>\n"); return; } if (old_bytes != null and new_bytes != null and std.mem.eql(u8, old, new)) { try report.appendSlice(gpa, "<p>Mode changed; content identical.</p>\n"); return; } try zit.textdiff.sideBySideHtml(gpa, old, new, render.opts, report); } /// Deliver the report: stdout, or (zit#165 --by) a page in $TMPDIR whose /// path is printed and, on a terminal, opened in the browser. fn finishDiff(gpa: std.mem.Allocator, io: std.Io, streams: cli.Streams, render: DiffRender, body: []const u8) cli.Exit { if (!render.html) return writeDiffOutput(streams, body); var page: std.ArrayList(u8) = .empty; defer page.deinit(gpa); page.appendSlice(gpa, "<!doctype html>\n<meta charset=\"utf-8\">\n<title>zit diff</title>\n" ++ "<style>body{font:13px/1.35 ui-monospace,monospace;margin:1.5em}h2{font:600 15px system-ui,sans-serif}" ++ "table.sbs{border-collapse:collapse;width:100%;table-layout:fixed;margin-bottom:1.5em}" ++ "table.sbs td{padding:0 .4em;white-space:pre-wrap;word-break:break-all;vertical-align:top;border-left:1px solid #ddd}" ++ "table.sbs td.n{width:3.5em;text-align:right;color:#888;user-select:none}" ++ "tr.del td{background:#fde8e8}tr.ins td{background:#e6f5e6}tr.chg td{background:#fff6d6}</style>\n") catch return stateOom(streams, "diff"); if (body.len == 0) page.appendSlice(gpa, "<p>No differences.</p>\n") catch return stateOom(streams, "diff"); page.appendSlice(gpa, body) catch return stateOom(streams, "diff"); var name_buf: [64]u8 = undefined; const name = std.fmt.bufPrint(&name_buf, "zit-diff-{d}.html", .{render.html_stamp}) catch unreachable; var dir = Io.Dir.cwd().openDir(io, render.html_dir, .{}) catch { streams.err.print("zit diff: cannot open '{s}' for the page\n", .{render.html_dir}) catch {}; return .failure; }; defer dir.close(io); dir.writeFile(io, .{ .sub_path = name, .data = page.items }) catch { streams.err.print("zit diff: cannot write '{s}/{s}'\n", .{ render.html_dir, name }) catch {}; return .failure; }; streams.out.print("wrote {s}/{s}\n", .{ render.html_dir, name }) catch {}; if (std.Io.File.stdout().isTty(io) catch false) { var full_buf: [4096]u8 = undefined; const full = std.fmt.bufPrint(&full_buf, "{s}/{s}", .{ render.html_dir, name }) catch return .ok; const opener: []const u8 = if (@import("builtin").os.tag == .macos) "open" else "xdg-open"; var child = std.process.spawn(io, .{ .argv = &.{ opener, full } }) catch return .ok; _ = child.wait(io) catch {}; } return .ok; } const hunk_file_cap: usize = 1 << 28; /// Render one file's textual diff: `---`/`+++` headers, unified hunks, the /// binary short-circuit, and the mode-only note when contents are identical /// but the classification said modified. Shared verbatim by the worktree and /// rev-to-rev modes so their output cannot drift. pub fn appendFileHunks( gpa: std.mem.Allocator, report: *std.ArrayList(u8), path: []const u8, old_bytes: ?[]const u8, new_bytes: ?[]const u8, opts: zit.textdiff.Options, ) error{OutOfMemory}!void { const old = old_bytes orelse ""; const new = new_bytes orelse ""; if (zit.textdiff.isBinary(old) or zit.textdiff.isBinary(new)) { try report.appendSlice(gpa, "Binary files differ: "); try report.appendSlice(gpa, path); try report.append(gpa, '\n'); return; } var hunks: std.ArrayList(u8) = .empty; defer hunks.deinit(gpa); try zit.textdiff.unified(gpa, old, new, opts, &hunks); if (hunks.items.len == 0) { // No hunks has TWO causes once -w exists: identical bytes (the change // is the mode) or a difference the comparison was told to ignore. Only // the first is a mode change, so claim it only on real byte equality. if (old_bytes != null and new_bytes != null and std.mem.eql(u8, old, new)) { // Classified modified with identical bytes: the change is the mode. try report.appendSlice(gpa, "mode changed: "); try report.appendSlice(gpa, path); try report.append(gpa, '\n'); } return; } // Fossil's banner (zit#170): `Index:`, a rule, then the file named as // is on both sides; `/dev/null` where a side has no content. try report.appendSlice(gpa, "Index: "); try report.appendSlice(gpa, path); try report.appendSlice(gpa, "\n" ++ ("=" ** 66) ++ "\n--- "); try report.appendSlice(gpa, if (old_bytes != null) path else "/dev/null"); try report.appendSlice(gpa, "\n+++ "); try report.appendSlice(gpa, if (new_bytes != null) path else "/dev/null"); try report.append(gpa, '\n'); try report.appendSlice(gpa, hunks.items); } /// `diff`'s hunk-rendering sink over the working-tree walk: old content by /// the entry's baseline F-card hash out of the LOG, new content read from /// the working tree, both handed to the one shared renderer. const HunkSink = struct { gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, /// The baseline's open log, or null in the unborn state (no old side). log: ?*logstore.LogStore, render: DiffRender = .{}, /// Emit the compact A/M/D record instead of hunks. `--brief` only routes /// here when -w is set: deciding whether a modification is whitespace-only /// needs the CONTENT that plain `--brief` deliberately never reads, and /// running the same `unified` call the hunk path runs is what keeps the /// two from ever disagreeing about whether a file changed. report: std.ArrayList(u8) = .empty, fn emit(self: *HunkSink, entry: WorkingTreeEntry) WalkError!void { if (!self.render.selects(entry.path)) return; switch (entry.inspection) { .none, .same => return, .io_error => return error.Storage, .oom => return error.OutOfMemory, .corrupt => return error.Corrupt, .added, .modified, .deleted => {}, } var old_owned: ?[]u8 = null; defer if (old_owned) |b| self.gpa.free(b); if (entry.baseline_hash) |hex| { const log = self.log orelse return error.Corrupt; // a baseline hash implies a baseline log var digest: [logstore.max_hash_len]u8 = undefined; const raw = decodeDiffName(hex, &digest) orelse return error.Corrupt; const rid = log.lookup(raw) orelse return error.Corrupt; old_owned = (log.content(self.gpa, rid) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return error.Storage, }) orelse return error.Corrupt; } var new_owned: ?[]u8 = null; defer if (new_owned) |b| self.gpa.free(b); if (entry.inspection != .deleted) { new_owned = self.root_dir.readFileAlloc(self.io, entry.path, self.gpa, .limited(hunk_file_cap)) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.FileNotFound => null, else => return error.Storage, }; } // An addition has no old side even when classification raced; a // deletion has no new side by construction. var old_side: ?[]const u8 = if (entry.inspection == .added) null else (old_owned orelse if (entry.inspection == .deleted) "" else null); var new_side: ?[]const u8 = if (entry.inspection == .deleted) null else (new_owned orelse ""); if (self.render.invert) std.mem.swap(?[]const u8, &old_side, &new_side); if (self.render.numstat) { try appendNumstat(self.gpa, &self.report, entry.path, old_side orelse "", new_side orelse "", self.render.opts); return; } if (self.render.brief) { const mark: u8 = switch (entry.inspection) { .added => if (self.render.invert) 'D' else 'A', .deleted => if (self.render.invert) 'A' else 'D', else => 'M', }; if (mark == 'M') { var probe: std.ArrayList(u8) = .empty; defer probe.deinit(self.gpa); try zit.textdiff.unified(self.gpa, old_side orelse "", new_side orelse "", self.render.opts, &probe); if (probe.items.len == 0) return; // whitespace-only under -w } try appendDiffRecord(self.gpa, &self.report, mark, entry.path); return; } try appendFileReport(self.gpa, &self.report, entry.path, old_side, new_side, self.render); } }; /// Decode a 40/64-hex artifact name into `digest`, or null when malformed — /// the hunk sink's tolerant twin of the plan builders' validated decode. fn decodeDiffName(hex: []const u8, digest: *[logstore.max_hash_len]u8) ?[]const u8 { if (hex.len != 40 and hex.len != 64) return null; const raw = digest[0 .. hex.len / 2]; _ = std.fmt.hexToBytes(raw, hex) catch return null; return raw; } /// `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, filters: []const []const u8 = &.{}, report: std.ArrayList(u8) = .empty, fn emit(self: *DiffSink, entry: WorkingTreeEntry) WalkError!void { if (!(DiffRender{ .filters = self.filters }).selects(entry.path)) return; 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-checkout 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-checkout 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, .io = std.testing.io, .root_dir = std.Io.Dir.cwd(), .args = .{}, .merge_pending = false }; 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, .io = std.testing.io, .root_dir = std.Io.Dir.cwd(), .args = .{}, .merge_pending = false }; 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, .io = std.testing.io, .root_dir = std.Io.Dir.cwd(), .args = .{}, .merge_pending = false }; 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()); } test "#174 item 11: hidden roots come from the checkout record's stored letters" { const io = std.testing.io; const cases = [_]struct { flags: []const u8, want: []const []const u8 }{ .{ .flags = "", .want = &.{} }, .{ .flags = "ru", .want = &.{ "manifest", "manifest.uuid" } }, .{ .flags = "rut", .want = &.{ "manifest", "manifest.uuid", "manifest.tags" } }, .{ .flags = "t", .want = &.{"manifest.tags"} }, }; for (cases) |c| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try checkout.write(io, tmp.dir, .{ .version = "a" ** 64, .branch = "trunk", .merge_parent = "", .special_op = "", .manifest_flags = c.flags, }); var out: [extras.max_hidden_roots][]const u8 = undefined; const n = hiddenRoots(io, tmp.dir, &out); try std.testing.expectEqual(c.want.len, n); for (c.want, out[0..n]) |want, got| try std.testing.expectEqualStrings(want, got); } // No checkout record at all (never opened): nothing is hidden. var bare = std.testing.tmpDir(.{}); defer bare.cleanup(); var out: [extras.max_hidden_roots][]const u8 = undefined; try std.testing.expectEqual(@as(usize, 0), hiddenRoots(io, bare.dir, &out)); } test "#174 item 7: only a directory argument with a dot component needs the exemption" { // The whole root and ordinary directories share the memoized walk. try std.testing.expect(!needsDotExempt(false, null)); try std.testing.expect(!needsDotExempt(false, "sub")); try std.testing.expect(!needsDotExempt(false, "sub/deep")); // A dot component anywhere in the named path, at the root or below it. try std.testing.expect(needsDotExempt(false, ".dir")); try std.testing.expect(needsDotExempt(false, ".fossil-settings")); try std.testing.expect(needsDotExempt(false, "sub/.deep")); // `a.b` is not a dot component: the dot must begin the name. try std.testing.expect(!needsDotExempt(false, "sub/a.b")); // With dotfiles shown there is nothing to lift. try std.testing.expect(!needsDotExempt(true, ".dir")); }