Skip to content
zit Check-in [d8e5e8117d]
Login

Check-in [d8e5e8117d]

Overview
Comment: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.

Timelines: family | ancestors | descendants | both | blockers
Files: files | file ages | folders
SHA3-256: d8e5e8117d6fee3fd37d902724db559975241d1b019df5fe03676fc5464eabf3
User & Date: dmestas on 2026-07-19 15:16:39.711
Other Links: branch diff | manifest | tags
Context
2026-07-19
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

16:36
Merge blockers: `zit tag add NAME CHECKIN` mints a SINGLETON sym card (B6)

Lands the B6 increment (d8e5e811) on trunk. The fix itself is unchanged from that commit and its full reasoning is recorded there; this message covers the landing, because the merge was not a formality.

WHAT B6 FIXES

`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. Unlike the other five blockers this was not a wrong answer — the verb told the truth about what existed — but the capability was simply absent. `zit tag add NAME CHECKIN` now 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. `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: a *branch* sym propagates precisely because descendants must inherit it; a *tag* sym does not, precisely because they must not.

CONFLICT RESOLUTION, STATED

One conflict, src/e2e.zig, positional rather than semantic: trunk's B5 block and B6's block are both appended at the end of the file over an empty common ancestor. Fossil again emitted the five-part conflict rather than the usual four — a "####### SUGGESTED CONFLICT RESOLUTION follows" section carrying its own proposed merge sits between the local copy and the ancestor section. Splitting only on the four familiar markers swallows that separator into the kept text, which then compiles as garbage; B3's landing recorded the same trap and it recurred here verbatim.

The two candidate sections were compared before choosing: the SUGGESTED section and the MERGED IN section are byte-identical (257 lines each), and the common ancestor is a single empty line. The resolution keeps trunk's 8 B5 tests plus exactly one copy of B6's 5-test block. Verified post-resolution: no residual marker of any of the five kinds, 432 tests in the file, no duplicated test name, and the B3 (5), B5 (8) and B6 (7) blocks all present at their expected counts. One further edit was needed — the splice left a doubled blank line at the seam, which `zig fmt` flagged; the reformat is that one blank line and nothing else.

TEST EVIDENCE ON THE MERGED TREE

Re-verified on trunk after the merge, not inherited from the branch: `zig fmt --check .`, `zig build`, `zig build test`, `zig build e2e` (432/432, no filtering), `zig build dst` (6 seeds x 1000 steps) and `zig build smoke` (S1-S6) each exit 0. `fossil extras --dotfiles` is empty.

The blocker repro was also driven by hand against the rebuilt merged binary rather than inferred from the source: `zit tag add v1 tip` exits 0 and prints the artifact hash; after a second commit `zit cat a.txt v1` is the tagged content while `zit cat a.txt tip` is the newer one, and `zit tag list tip` does NOT carry v1 — the singleton pins and is not inherited. `zit tag add v1` alone is a usage error at exit 2, and `zit tag add v2 nosuchcheckin` exits 1 naming the check-in and leaves no tag behind.

CHANGED EXPECTATIONS (all three 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 — cancel/remove semantics stay deferred to 0.2. 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. The diff was audited for other expectation changes and there are none.

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.

No borrowed technique: the singleton-sign argument and the `mintControlArtifact` extraction are original to this change. No performance claim is made. check-in: a5b6db7a8c user: dmestas tags: trunk

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

15:07
add/addremove/commit: refuse symlinks consistently (blocker B3)

A symlink anywhere in the checkout permanently wedged `commit`. `extras.zig` classifies a symlink as an extra (B-extras-9), and the recursive `add DIR` / `addremove` paths reuse that sweep as their add set without re-applying the kind test that explicit `zit add sym.txt` and `commit` both apply. The recursive path therefore TRACKED exactly what the direct path REFUSED: after `zit add .`, every subsequent `zit commit` died with "cannot commit 'sym.txt': not a regular file", `zit status` called an on-disk file missing, and the only way out (`zit rm sym.txt`) was named nowhere. `add .` is the documented whole-tree workflow, so any repo containing a symlink was stuck.

WHAT CHANGED

Consistent refusal: 0.1.0 does not version symlinks, on every path.

1. `extras.Extras` entries widen from a bare path to `Entry{path, is_symlink}`. The sweep already lstat-classified every entry (`resolveKind`); the verdict now rides out on the entry instead of being re-derived by callers. That is the point of the shape: `add` and `addremove` do NOT re-stat swept items, so the two paths cannot drift from the deep module's classification again — which is the mechanism of this bug. Ordering is unchanged (sort is by `path`; `is_symlink` never participates), and every postcondition assert is preserved.

2. The five `items()` consumers split by role. `extras` and `clean` ignore the tag — enumerating verbs do not judge, and a symlink stays a listable, deletable extra. `add`'s directory sweep and `addremove`'s insert loop filter on it.

3. `classifyPath`'s catch-all `else => .not_regular` splits: `.sym_link` gets its own `Outcome` and the wording `symlink (not versioned in 0.1.0)`; a fifo/socket/device keeps "not a regular file". Those are not the same refusal and must not read as one. The wording is one shared const so the explicit refusal and the recursive skip notice cannot diverge.

4. `commit`'s `Capture.not_regular` carries whether the entry was a symlink, and `commitNotRegular` renders the recovery for that case: "run 'zit rm PATH' to stop tracking it". This is how repos already wedged by a pre-fix `add .` get out; there is no migration code and none is needed.

REPORTING DECISION (stated, not implied)

`add` prints each skip to stderr, alongside its existing already-tracked notice: add's contract is per-argument feedback. `addremove` withholds symlinks SILENTLY. Its report is a two-channel ADDED/REMOVED ledger of state transitions, and a skipped symlink is a non-transition; growing a third notice channel there would change the shape of a report other tooling reads. `zit extras` is the verb that shows the symlink is still present and untracked.

`status` needed no change: post-fix the symlink is simply untracked.

TESTS

Five e2e regressions, marker `// REGRESSION B3:`: the breakdown doc's repro chain verbatim (init/add ./commit/status/extras); an `addremove` variant with symlinks sorting BOTH before and after the regular file the sweep also yields, so a filter that latched off the previous item's kind fails one of the two; explicit `add sym.txt` refusing with the symlink wording and tracking nothing; the wedge-recovery chain (pre-tracked symlink -> commit names `zit rm` -> rm -> commit succeeds -> status clean); and a `clean --dry-run` contract-partner guard that was green before the fix and stays green through it, proving clean still lists a symlink as an extra after the `Extras` type change. A unit assertion in `extras.zig` pins the tag itself, and `commitNotRegular`'s renderer test covers both branches.

Two pre-existing e2e expectations changed, both because they encoded the bug or its wording:

- `cli addremove B-addremove-2` asserted the ADDED set was byte-identical to `zit extras`, symlink included. That identity IS the defect. It now asserts the add set is the extras set MINUS symlinks, and additionally that the symlink remains an extra. - `cli add B-add-5` asserted the fifo-generic wording for a named symlink. It now asserts the symlink-specific wording.

Four `commit` invocations inside the new B3 tests use `runCommitEnv` with an explicit user rather than bare `runZit`; the isolated e2e environment has no resolvable author, which is a harness requirement, not a behavior change.

TEST EVIDENCE

In this worktree at the committed tree: `zig fmt --check .`, `zig build`, `zig build test`, `zig build e2e` (417 tests) and `zig build dst` each exit 0 — full battery, no filtering. Pre-fix the four B3 repro tests failed and the `clean --dry-run` guard passed, as designed.

No borrowed technique: the entry-tag shape is original to this change. No performance claim is made. check-in: 1aeb531cac user: dmestas tags: blockers

Changes