Skip to content
zit Check-in [d7dd9ea30b]
Login

Check-in [d7dd9ea30b]

Overview
Comment:mv: move the working file before recording; failed mv records nothing

mv recorded the rename durably before attempting the filesystem move and rolled nothing back when it failed, leaving the set naming a path that does not exist and commit ready to record a rename that never happened. The reversal mv --help promises -- "run zit mv NEW OLD" -- failed too: it saw the old path still occupied and refused the collision.

mvCommit now renames first, then records; if the record fails the file is renamed back and nothing is persisted. Move-first beats record-first because undoing a durable write needs a second durable write, which can itself fail, nesting a failure inside the failure handler. Renaming a file back cannot realistically fail -- the source directory and name are still free. Build the guarantee on the rollback that cannot fail.

mvCheckLegal gains a dest-parent-exists check: stat of a path under a missing directory fails, so the old collision check read "absent" and waved it through. Residual window: a crash between move and record leaves the file at NEW with the set naming OLD, recoverable with add NEW + rm OLD.

ENOENT and EACCES were both rendered "input/output error".

Timelines: family | ancestors | descendants | both | blockers
Files: files | file ages | folders
SHA3-256: d7dd9ea30b699786231766b23c5f03cbd318a764f35ced28641128c4d94b00b3
User & Date: dmestas on 2026-07-19 16:38:16.250
Original Comment: mv: move the working file first, then record the rename (blocker B4)

`zit mv` recorded the rename in the managed set and persisted it DURABLY before attempting the filesystem move, and rolled nothing back when the move failed. Every failed mv therefore left the repository in a state the user never asked for: the set naming a path that does not exist, the file still at its old name, `status` reporting the new name as missing, and `commit` ready to record a rename that never happened. The documented recovery in `mv --help` -- "to reverse a rename, run `zit mv NEW OLD`" -- then failed too, because the reverse mv sees the old path still occupied on disk and refuses the collision. The help text promised an escape that the ordering made impossible.

WHAT CHANGED

1. `mvCommit` is reordered: rename the working file FIRST, then swap the managed entry and persist. If the persist fails, the file is renamed back and nothing is recorded. The record/persist half is extracted into `mvRecord`, which returns the first failure's exit with its diagnostic already on stderr, leaving `mvCommit` to own only the ordering and the rollback.

MOVE-FIRST-WITH-ROLLBACK RATHER THAN RECORD-FIRST-WITH-RECORD-ROLLBACK. Undoing a durable record write needs a second durable write, which can itself fail, nesting the failure inside the failure handler. Renaming a file back cannot realistically fail: the source directory that existed a moment ago still exists and the source name is still free. The rollback that can't fail is the one to build the guarantee on.

2. `mvCheckLegal` gains a dest-parent-exists check. This is what turns the blocker's own repro into a clean refusal rather than a rollback: a stat of a path UNDER a missing directory fails, so `mvOnDisk(new_key)` reads false and the collision check waved `nodir/x.txt` through -- the most common way a legal-looking rename dies at the syscall was the one case the gauntlet could not see. Now it is refused pre-mutation, naming the directory the user has to create. The rollback path remains live and is pinned by the EACCES test, where the parent exists and only the rename itself fails.

3. The `mvCommit` doc comment and `mvRenameFail` both encoded and justified the old ordering; both are rewritten. The residual failure window is documented at the site rather than papered over: a crash BETWEEN the move and the record leaves the file at NEW with the set still naming OLD, which `status` shows as OLD missing plus NEW as an extra, recoverable with `zit add NEW` + `zit rm OLD`. Full crash-atomicity across filesystem and record is working-tree crash-consistency work (0.2 / DST), not this handler's job. A new `mvRollbackFail` covers the one case that can still leave the tree changed -- move succeeded, record failed, move-back failed -- and prints where the file actually is so it can be moved by hand.

RIDE-ALONG SF3, SCOPED TO mv (BLOCKER-FIX-PLAN.md section 3)

mv rendered both ENOENT and EACCES as "input/output error", which sent users looking for a disk fault when the real cause was a typo or a permission bit. The rename error is now captured and reported. The error name is printed as-is rather than through a translation table: the global errno-mapping sweep is 0.2 work, and growing a private mapping here would be the thing that sweep then has to unpick. `nodir` reports the missing directory by name; `ro/x.txt` reports AccessDenied.

RED PROOF (no product code changed until RED was proven; marker `// REGRESSION B4:`)

Three of the four e2e cases failed against a fresh build of this tree:

a failed mv into a missing directory records nothing a failed mv into an unwritable directory records nothing a failed mv leaves the source tracked and still movable

The first two failed on `indexOf(stderr, "recorded") == null` -- mv reporting a half-done rename. The third failed at exit 1 where 0 was expected: after the failed mv the source was no longer tracked, so an ordinary mv of it was refused. The two failure causes are the same defect reached by different errnos; identical wrong state in both is what shows the mechanism is the ordering and not one error code.

CONTRACT-PARTNER GUARD, GREEN BEFORE AND AFTER

`zit mv NEW OLD` reverses a successful rename exactly

Both renames in it succeed, so no failure path is entered and the ordering is unobservable -- it is structurally incapable of exhibiting B4 and was green pre-fix. It is the blast-radius check for the reordering, since the fix moves the same `recordRename` bookkeeping: `mv b a` after `mv a b` must net the rename record back out or `commit` would see a phantom change. It is still green.

The failure assertions chain beyond `status`, because the set mv persists is the same set `extras` subtracts from and `commit` walks: after a failed mv the tests assert status clean AND extras empty AND `commit` refusing with "nothing to commit (no changes)".

TEST EVIDENCE

In this worktree at the committed tree, full battery, no filtering: `zig fmt --check build.zig src`, `zig build`, `zig build test` (1797 tests), `zig build e2e` (428 tests), `zig build dst` (6 seeds x 1000 steps) and `zig build smoke` (S1-S6) each exit 0.

The repro of record was also driven by hand against the rebuilt binary, on a committed baseline. `zit mv sub/a2.txt nodir/x.txt` now prints "zit mv: cannot move into 'nodir': no such directory" at exit 1; `status` reports nothing, `extras` is empty, `commit` says "nothing to commit (no changes)", and the file is still at `sub/a2.txt`. The source is still movable: `zit mv sub/a2.txt sub/c.txt` succeeds. The EACCES path prints "zit mv: cannot move 'sub/a2.txt' to 'ro/x.txt': AccessDenied" and leaves the same zero state change. The help text's reversal promise was verified as behavior rather than reread: `mv a2 b` then `mv b a2` returns the tree to the baseline with `commit` finding no change.

NO TEST EXPECTATION WAS CHANGED. No shipped assertion encoded this bug; the only prior reference to the old wording was the message string itself.

BRANCH STATE, STATED

This commit sits on the B6 leaf d8e5e8117d, which has not yet landed on trunk -- that is the tree the worktree was handed over at, and the battery above ran on it. Landing B4 from here would carry B6 with it, so B6 lands first or B4 is re-applied onto trunk; it is not this commit's call to make.

No borrowed technique: the ordering argument and the gauntlet check are original to this change. No performance claim is made.

Other Links: branch diff | manifest | tags
Context
2026-07-19
17:27
mv: a reversal cancels the rename record instead of replacing it

`mv --help` advertises `zit mv NEW OLD` as the way to undo a rename, so it is the sequence a user is most likely to run. It left the pending record replaced rather than cleared: OLD->NEW->OLD queued a §4.7.6 old-name F card asserting OLD was formerly NEW -- a name no manifest has ever carried. The next commit with any real change would write that false claim about the past into a permanent artifact, where it cannot be taken back. A rename and its inverse are a no-op, so the pair now nets the record out.

This survived the guard because the guard's oracle was indirect: commit reports "nothing to commit" on CONTENT alone, so a stranded record passes it silently and surfaces only at the next commit that carries a change. The record is now read back directly.

mv's move errors render through `reasonOf` rather than `@errorName`, so EACCES reads "permission denied" instead of "AccessDenied". This was deferred as errno-sweep work, but the mapping already exists and `init` and `clean` print through it -- reuse, not new scope. A raw Zig error name is an internal identifier, not a diagnostic. closed check-in: 7235a86bcd user: dmestas tags: blockers

16:38
mv: move the working file before recording; failed mv records nothing

mv recorded the rename durably before attempting the filesystem move and rolled nothing back when it failed, leaving the set naming a path that does not exist and commit ready to record a rename that never happened. The reversal mv --help promises -- "run zit mv NEW OLD" -- failed too: it saw the old path still occupied and refused the collision.

mvCommit now renames first, then records; if the record fails the file is renamed back and nothing is persisted. Move-first beats record-first because undoing a durable write needs a second durable write, which can itself fail, nesting a failure inside the failure handler. Renaming a file back cannot realistically fail -- the source directory and name are still free. Build the guarantee on the rollback that cannot fail.

mvCheckLegal gains a dest-parent-exists check: stat of a path under a missing directory fails, so the old collision check read "absent" and waved it through. Residual window: a crash between move and record leaves the file at NEW with the set naming OLD, recoverable with add NEW + rm OLD.

ENOENT and EACCES were both rendered "input/output error". check-in: d7dd9ea30b user: dmestas tags: blockers

15:16
tag: add `zit tag add NAME CHECKIN`, minting a SINGLETON sym card

`tag` was read-only: `tag list` was the only subcommand, so a repository could not be given a name for a check-in at all (blocker B6). The verb told the truth about what existed, but the capability was simply absent.

WHAT CHANGED

`zit tag add NAME CHECKIN` mints one CONTROL artifact carrying a single `T +sym-NAME` card targeting the resolved check-in's 64-hex hash.

THE SIGN IS THE WHOLE CORRECTNESS ARGUMENT. The card is a SINGLETON (`+`), not a propagating (`*`) tag. `repostate.resolveName` (src/repostate.zig:891) picks the max-`event.mtime` check-in among ALL active `sym-` rows, so a propagating `*sym-v1` would be inherited by every descendant and then resolve to the NEWEST of them — the name would silently drift off the check-in the user tagged, which is the same class of wrong answer as B1. `applyTagOps` (src/repostate.zig:727) gates propagation on `op.type != .singleton`, so a `+` card pins the name with ZERO repostate changes. The contrast with a branch is deliberate and now documented at both sites: a *branch* sym propagates precisely because descendants must inherit it; a *tag* sym does not, precisely because they must not.

`amend`'s steps 5-6 — assemble the control manifest, run the runtime-safety round-trip self-check, `putArtifact` + `commit` under the single fsync — are extracted into `mintControlArtifact`, and `tag add` is its second consumer. Both verbs resolve a check-in, compose a T run, then mint; that last step is now written once. amend's existing battery guards the extraction unchanged. The helper's error set is `logstore.OpenError`, the widest of the three the body can produce, so each verb renders it under its own name.

`tag add` closes its read view BEFORE the read-write append (commit's durability pattern), so it owns its repository lifetime rather than sharing the listing path's `defer repo.deinit`. Resolution and the empty-NAME check both precede the mint: a run that cannot resolve its CHECKIN mints nothing, asserted by a test.

`selectInheritedSyms`'s comment said zit mints no singleton syms itself, so the §6.2 exclusion of them from a branch move's cancel set was reachable only via a synced `T +sym-X *`. That is no longer true — the comment is corrected. The behavior needed no change, and its unit test already covered a singleton sym.

RECORDED, NOT ASSUMED: branch creation shares no read-only gap. It is already served by `commit --branch`, which mints the propagating branch/sym pair directly on the check-in manifest (src/cli.zig `composeCommitTags`), so there is no `branch add` to write. Cancel/remove semantics stay deferred to 0.2 — closing "cannot create tags" is the blocker; a full tag lifecycle is not.

RED PROOF (per BLOCKER-FIX-PLAN.md section 2, no product code changed until RED was proven; markers `// REGRESSION B6:`)

End-to-end, src/e2e.zig — all five failed pre-fix at `tag add` with exit 2 ("unknown subcommand 'add'"):

cli REGRESSION B6: tag add creates a tag that tag list and tag list CHECKIN report cli REGRESSION B6: a created tag pins to its check-in and is not inherited by a descendant (tag before commit) cli REGRESSION B6: a created tag pins to its check-in and is not inherited by a descendant (commit before tag) cli REGRESSION B6: a created tag survives pack cli REGRESSION B6: a created tag survives a clone

The pinning pair runs both orderings — tag-then-commit exercises inheritance at commit time, commit-then-tag exercises the propagation walk over already crosslinked descendants. Identical behavior in both is what proves the mechanism rather than luck. pack and clone prove the round trip: a synced artifact that does not survive sync is a worse lie than a missing verb.

Added alongside them: `tag add`'s arity errors, and an unresolvable CHECKIN leaving no tag behind. Unit, src/cli.zig: the parser's add grammar (both positionals required, `--help`/bad option winning over arity, `--` handling), `composeTagAddCards` minting exactly one `+sym-` card at the target, its control round trip through `artifact.parse` proving the sign survives the wire, and leak-freedom under `checkAllAllocationFailures`.

TEST EVIDENCE NOW

In this worktree at the committed tree: `zig fmt --check .`, `zig build`, `zig build test`, and `zig build e2e` each exit 0 — full battery, no filtering.

CHANGED EXPECTATIONS (both encoded the missing capability, nothing else)

cli tag B-tag-7 (src/cli.zig) and cli tag B-tag-13 (src/e2e.zig) used the literal token `add` as their stand-in for "not a subcommand". `add` IS one now, so both use `cancel`, a word that still is not.
cli tag B-tag-11's verbatim `tag_help_expected` gains the `add` usage line and its paragraph, because `tag --help` must teach the new grammar.

No borrowed technique: the singleton-sign argument and the mint extraction are original to this change. No performance claim is made.

SIBLING MERGE

This checkout absorbed the sibling B3 commit (1aeb531cac) before landing. The only conflict was in src/e2e.zig and was positional, not semantic: both changes append a new regression block at the end of the file. Both blocks are kept, one copy each; the two candidate B3 hunks fossil presented were byte-identical, and the full battery above was re-run on the merged tree, not just on mine. check-in: d8e5e8117d user: dmestas tags: blockers

Changes