//! 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;
// 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,
) 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_ignored: ?zit.ignore.Set = if (parsed.force) null else loadIgnores(gpa, io, root_dir);
defer if (add_ignored) |*ig| ig.deinit();
for (parsed.paths) |arg| {
const outcome = classifyPath(arena, io, root.abs, root_dir, cwd_abs, &set, sparse, 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).
const items = sweepRootOnce(gpa, io, root.abs, &set, if (add_ignored) |*ig| ig else null, &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,
ignored: ?*const zit.ignore.Set,
cache: *?extras.Extras,
) extras.SweepError![]const extras.Entry {
if (cache.*) |*done| return done.items();
var iter_root = Io.Dir.cwd().openDir(io, root_abs, .{
.access_sub_paths = true,
.iterate = true,
.follow_symlinks = false,
}) catch return error.Storage;
defer iter_root.close(io);
cache.* = try extras.sweepIgnoring(gpa, io, iter_root, set, ignored);
return cache.*.?.items();
}
/// #64 — the effective ignore set for this checkout: the versioned
/// `.zit-settings/ignore-glob` when the tree carries one, else the local
/// `ignore-glob` setting from the config frame. Null (no exclusions) on any
/// failure to read either source — ignores narrow a listing, so an
/// unreadable pattern source must widen back to everything rather than fail
/// the verb or silently hide more.
fn loadIgnores(gpa: std.mem.Allocator, io: std.Io, root_dir: Io.Dir) ?zit.ignore.Set {
var setting_buf: [zit.logstore.max_config_str]u8 = undefined;
var setting: []const u8 = "";
read_setting: {
var file = cli.openStoreRead(io, root_dir) catch break :read_setting;
defer file.close(io);
const st = file.stat(io) catch break :read_setting;
var fs = zit.storage.FileStorage.init(io, file, st.size);
// #120 — indexed open here too: this config read rode every extras/
// clean/addremove listing, each paying its own full header scan.
// `.read_only`: extras is PINNED tree-untouching (B-extras-16), so
// this path may adopt a sidecar but never mint one.
var log = cmd_sync.openLogAdoptingIndex(gpa, io, root_dir, fs.storageFile(), .read_only) catch break :read_setting;
defer log.deinit(gpa);
var oc = (log.readConfig(gpa) catch break :read_setting) orelse break :read_setting;
defer oc.deinit(gpa);
const glob = oc.config.ignore_glob;
if (glob.len <= setting_buf.len) {
@memcpy(setting_buf[0..glob.len], glob);
setting = setting_buf[0..glob.len];
}
}
return zit.ignore.resolve(gpa, io, root_dir, setting);
}
/// 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,
arg: []const u8,
) error{OutOfMemory}!Outcome {
// 1. Normalize to a repo-relative key. An escape is B-add-6; the root dir
// itself is B-add-5's directory case (e.g. `zit add .` at the root), not
// an escape.
const key = switch (try cli.repoRelative(gpa, root_abs, cwd_abs, arg)) {
.outside => return .outside_root,
.root => return .{ .directory = null },
.key => |k| k,
};
// 2. A key beyond the managed-set bound cannot be tracked (B-add-17
// error.TooLong). Checked here, not via stat: a name-too-long key makes
// the OS stat fail with NameTooLong, which would otherwise mask the honest
// "path is too long" message as "no such file". Same bound
// `ManagedSet.insert` enforces (`managed.max_path_len`).
if (key.len > managed.max_path_len) return .{ .insert_error = error.TooLong };
// 3. The three control files are never version-controlled (B-add-7). Checked
// before stat because they exist as real files and would otherwise add.
if (isControlFile(key)) return .control_file;
// 4. Existence and file-kind (B-add-4/-5). Symlinks are not followed, so a
// symlink is rejected as a symlink, not silently resolved to its target.
const st = root_dir.statFile(io, key, .{ .follow_symlinks = false }) catch
return .no_such_file;
switch (st.kind) {
.directory => {
// 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);
}
/// 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,
) 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 ignored = loadIgnores(gpa, io, root_dir);
defer if (ignored) |*ig| ig.deinit();
var swept = extras.sweepIgnoring(gpa, io, iter_root, &set, if (ignored) |*ig| ig else null) 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,
) cli.Exit {
// 1. Parse (B-extras-11/-12/-13). --help wins over any trailing token.
const help = switch (cli.parseNoPositionalArgs(verb_args)) {
.ok => |a| a.help,
.unknown_option => |tok| return cli.usageError(streams, "extras", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "extras", .extra_argument, tok),
};
if (help) {
streams.out.writeAll(extras_help) catch {};
return .ok;
}
// 2. Discover the checkout root (B-extras-14). OOM → exit 1.
const root = (cli.discoverRoot(arena, io) catch
return stateOom(streams, "extras")) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
// 3. Load the managed set (B-extras-4/-15); an absent file reads as the
// empty set, so a repo with no `zit add` sweeps every on-disk file.
var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
return managedFail(streams, "extras", e);
defer set.deinit();
// 4. Re-open the root ITERABLE (B-extras-18): `discoverRoot`'s handle is
// non-iterable, and the sweep needs to enumerate. No-follow so a symlink
// at the root is never entered.
var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{
.access_sub_paths = true,
.iterate = true,
.follow_symlinks = false,
}) catch return sweepFail(streams, "extras", error.Storage);
defer iter_root.close(io);
// 5. Sweep once (B-extras-1..9). The module owns ordering and bounds.
var ignored = loadIgnores(gpa, io, root_dir);
defer if (ignored) |*ig| ig.deinit();
var result = extras.sweepIgnoring(gpa, io, iter_root, &set, if (ignored) |*ig| ig else null) 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,
/// then branch. Un-forced is a gate that refuses and deletes nothing
/// (B-clean-1/-2); `--dry-run` previews byte-identically to `zit extras`
/// (B-clean-3/-4); `--force` deletes exactly the swept set (B-clean-5..10).
/// Mirrors `runExtras` through the sweep, then diverges only in step 6.
pub fn runClean(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
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.
var ignored = loadIgnores(gpa, io, root_dir);
defer if (ignored) |*ig| ig.deinit();
var result = extras.sweepIgnoring(gpa, io, iter_root, &set, if (ignored) |*ig| ig else null) 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);
defer if (keep) |*k| k.deinit();
const kept: ?*const zit.ignore.Set = if (keep) |*k| k else null;
var visible_count: usize = 0;
var kept_count: usize = 0;
for (result.items()) |e| {
if (sparse) |c| {
if (!c.covers(e.path)) continue;
}
if (kept) |k| {
if (k.matches(e.path)) {
kept_count += 1;
continue;
}
}
visible_count += 1;
}
// 6. Branch. Order encodes the B-clean-11 precedence: --dry-run wins, so it is
// checked before --force and can never be upgraded into a real deletion.
if (args.dry_run) {
// Preview byte-identically to `zit extras` (B-clean-3/-4): one
// root-relative path per line, ascending, deleting nothing. Like
// `extras`, clean is kind-blind: a symlink is a deletable extra, so the
// entry's `is_symlink` tag is ignored (only add/addremove filter on it).
for (result.items()) |e| {
if (sparse) |c| {
if (!c.covers(e.path)) continue;
}
if (kept) |k| {
if (k.matches(e.path)) continue;
}
streams.out.print("{s}\n", .{e.path}) catch {};
}
noteKept(streams, kept_count);
return .ok;
}
if (!args.force) {
// The gate (B-clean-1/-2/-8): an empty set is a silent success no-op with
// nothing to guard; otherwise refuse without deleting, naming the count.
if (visible_count == 0) {
noteKept(streams, kept_count);
return .ok;
}
noteKept(streams, kept_count);
return cleanRefuse(streams, visible_count);
}
// --force: delete exactly the swept set (B-clean-5..10), cone- and keep-scoped.
noteKept(streams, kept_count);
const exit = cleanForce(streams, io, iter_root, &result, sparse, kept);
// zit#164 --emptydirs: directories the deletions emptied go too, bottom
// up; a directory that still holds anything simply refuses.
if (args.emptydirs and exit == .ok) {
for (result.items()) |e| {
var parent = mvParentDir(e.path);
while (parent) |d| : (parent = mvParentDir(d)) {
iter_root.deleteDir(io, d) catch break;
}
}
}
return exit;
}
/// #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,
) ?zit.ignore.Set {
const r = cmd_sync.resolveSetting(gpa, io, arena, root_dir, amb, .keep_glob) catch return null;
return zit.ignore.fromPatterns(gpa, r.value);
}
/// 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 swept extras set (B-clean-5) through `root` — the same
/// ITERABLE handle the sweep enumerated, so no path outside that set is ever
/// reachable and tracked/control files stay untouched BY CONSTRUCTION
/// (B-clean-7: no membership re-check). A failed unlink is reported to stderr
/// and the sweep CONTINUES its siblings (B-clean-9); the run then exits 1
/// (B-clean-10). An already-absent path satisfies the postcondition and counts
/// as success (idempotent, B-clean-6). Full success is silent on stdout and
/// exits 0 (cli-stream-separation).
fn cleanForce(s: cli.Streams, io: std.Io, root: std.Io.Dir, result: *const extras.Extras, sparse: ?*const zit.cone.Cone, kept: ?*const zit.ignore.Set) cli.Exit {
var failed = false;
// Bound = the finite swept count (B-clean-18): no re-walk, no recursion.
for (result.items()) |entry| {
const path = entry.path;
std.debug.assert(path.len > 0); // shape invariant, NOT a membership re-check
// Sparse guard: out-of-cone paths are invisible to clean (RFC 4.1);
// the skip IS the data-loss guard, mirrored in preview and gate.
if (sparse) |c| {
if (!c.covers(path)) continue;
}
// #83 keep-glob: the protection is applied HERE as well as in the
// preview and the gate, so the three can never disagree about what
// clean would remove. `zit clean` has no undo; a file skipped by the
// preview but deleted by the sweep would be unrecoverable.
if (kept) |k| {
if (k.matches(path)) continue;
}
root.deleteFile(io, path) catch |e| switch (e) {
error.FileNotFound => {}, // already gone ⇒ postcondition holds (B-clean-6)
else => {
failed = true;
s.err.print(
"zit clean: '{s}': could not delete: {s}\n",
.{ path, reasonOf(e) },
) catch {};
},
};
}
return if (failed) .failure else .ok;
}
// ==========================================================================
// `zit addremove` — reconcile the tracked set with the working tree in ONE
// pass: every untracked file the extras sweep reports is ADDED, every tracked
// file missing from disk is REMOVED (fossil/hg parity; git spells it `add -A`).
// A composite of the shipped add/rm state transitions over the ONE shipped
// `extras.zig` sweep — it adds NO second tree walker and NO ad-hoc managed-set
// mutation. It mutates `.zit-managed` only (never file contents, never the
// repository); the next `zit commit` records the result. Not journaled — the
// inverse is re-running add/rm (the B-clean posture). (contracts/addremove.md
// B-addremove-1..13.)
// ==========================================================================
/// `zit addremove [--dry-run]` — the thin wiring: parse, discover the checkout
/// root, read the managed set, sweep once through the SHARED `extras.zig` module
/// (the ADD set), derive the REMOVE set as the tracked entries absent from disk,
/// then apply both to the managed set in ONE atomic write. Mirrors `runClean`
/// through the sweep, then diverges: instead of deleting the swept set it folds
/// it into the tracked set, plus the missing-managed removals. The report is
/// WITHHELD until the write is durable (B-addremove-7/-12), so a failed write
/// claims no transition. Per-path add failures report and continue, exiting 1
/// (B-addremove-6). `--dry-run` computes the same report and leaves `.zit-managed`
/// byte-unchanged (B-addremove-4).
pub fn runAddremove(
gpa: std.mem.Allocator,
io: std.Io,
arena: std.mem.Allocator,
verb_args: []const []const u8,
streams: cli.Streams,
) cli.Exit {
// 1. Parse (B-addremove-9/-10). --help wins over any trailing token.
const args = switch (cli.parseAddremoveArgs(verb_args)) {
.ok => |a| a,
.unknown_option => |tok| return cli.usageError(streams, "addremove", .unknown_option, tok),
.extra_argument => |tok| return cli.usageError(streams, "addremove", .extra_argument, tok),
};
if (args.help) {
streams.out.writeAll(addremove_help) catch {};
return .ok;
}
// 2. Discover the checkout root (B-addremove-11). OOM → exit 1.
const root = (cli.discoverRoot(arena, io) catch
return stateOom(streams, "addremove")) orelse {
streams.err.writeAll(not_a_repo_msg) catch {};
return .failure;
};
var root_dir = root.dir;
defer root_dir.close(io);
// 3. Load the current set; an absent file reads as the empty set, so a repo
// with no `zit add` tracks every on-disk file in one addremove.
var set = managed.ManagedSet.read(gpa, io, root_dir) catch |e|
return managedFail(streams, "addremove", e);
defer set.deinit();
// 4. Re-open the root ITERABLE (B-addremove-2), exactly as extras/clean: the
// sweep enumerates through this handle. No-follow so a root symlink is
// never entered.
var iter_root = Io.Dir.cwd().openDir(io, root.abs, .{
.access_sub_paths = true,
.iterate = true,
.follow_symlinks = false,
}) catch return sweepFail(streams, "addremove", error.Storage);
defer iter_root.close(io);
// 5. Sweep once through the SHARED module (B-addremove-2). The extras set IS
// the add set — same sweep, same exclusions, same symlink posture. A
// second walker is a refutation.
var ignored = loadIgnores(gpa, io, root_dir);
defer if (ignored) |*ig| ig.deinit();
var extra_paths = extras.sweepIgnoring(gpa, io, iter_root, &set, if (ignored) |*ig| ig else null) 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;
}
try report.appendSlice(gpa, "--- ");
if (old_bytes != null) {
try report.appendSlice(gpa, "a/");
try report.appendSlice(gpa, path);
} else try report.appendSlice(gpa, "/dev/null");
try report.appendSlice(gpa, "\n+++ ");
if (new_bytes != null) {
try report.appendSlice(gpa, "b/");
try report.appendSlice(gpa, path);
} else try report.appendSlice(gpa, "/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());
}