Skip to content
zit Timeline
Login

Timeline

12 check-ins related to "blockers"

2026-07-19
17:32
Merge blockers: a failed mv changes nothing, and its recovery is exact (B4)

mv recorded a rename in the managed set and persisted it before attempting the filesystem move, with no rollback. A move into a nonexistent directory left the set naming a path that held no file: status invented a missing entry, commit refused over it, and the recovery mv --help documents did not work.

mv now validates that the destination's parent exists and refuses before touching anything, so the failing case records nothing.

Two further defects on the same path. An inverse rename replaced the pending record instead of dropping it, so 'mv a b' followed by 'mv b a' left a b-to-a rename queued; the next commit carrying a real change would then emit an old-name F card asserting the file was formerly named b, a name never committed. That is false provenance in a permanent artifact, produced by the exact sequence the help text advertises as recovery. mvRecord now nets an inverse rename back out.

The error path printed raw Zig error names — a user saw 'AccessDenied' where clean and init already print 'permission denied' through reasonOf. It now uses the same mapping.

A residual window remains: a crash between the move and the record write is not atomic, documented at the site. Full working-tree crash consistency is a separate concern. check-in: 33a2ceb6d1 user: dmestas tags: trunk

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

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:31
Merge blockers: refuse symlinks consistently across add/addremove/commit (B3)

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

WHAT B3 FIXES

A symlink anywhere in the checkout permanently wedged `commit`. `extras.zig` classified a symlink as an extra, and the recursive `add DIR` / `addremove` paths reused 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 tracked exactly what the direct path refused, so after `zit add .` every subsequent `zit commit` died with "cannot commit 'sym.txt': not a regular file" and the only way out (`zit rm sym.txt`) was named nowhere. Consistent refusal is the contract: 0.1.0 does not version symlinks on any path.

WHY IT LANDED SEPARATELY

B3 was committed to `blockers` off the old branch tip and never reached trunk; trunk meanwhile took B1, B5 and a CI change. The sibling B6 commit (d8e5e811) had already absorbed B3 into its own tree, so B3 would eventually have arrived on trunk bundled inside B6. Landing it on its own keeps each increment gated and merged on its own evidence rather than riding in on another's, and it shrinks the diff B6 still has to land.

CONFLICT RESOLUTION, STATED

One conflict, src/e2e.zig, positional rather than semantic: trunk's B5 block and B3's block are both appended at the end of the file, over an empty common ancestor. Both blocks are kept, one copy each.

Worth recording because it is a trap: fossil emitted a five-part conflict, not the usual four. Between the local copy and the ancestor section it inserted a "####### SUGGESTED CONFLICT RESOLUTION follows" section carrying its own proposed merge. Splitting only on the four familiar markers silently swallows that separator line into the kept text, which then compiles as garbage — it surfaced here as `zig fmt` and the compiler both failing at that exact line, not as anything that looked like a merge problem. The resolution keeps the local copy plus fossil's suggested section (B5 then B3) and drops the duplicate merged-in copy, which was verified to be a strict substring of what was kept. Post-resolution the file holds 13 tests in that region with no duplicated test name and no residual marker of any of the five kinds.

TEST EVIDENCE ON THE MERGED TREE

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

The B3 repro was also driven by hand against the rebuilt binary: `zit add .` skips sym.txt and says so on stderr, `zit commit` succeeds, `zit status` reports nothing, `zit extras` lists sym.txt, explicit `zit add sym.txt` refuses with the symlink wording at exit 1, and `addremove` withholds it silently.

No borrowed technique. No performance claim is made. check-in: 01dc9f8e8a 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:13
Merge blockers: an add-only merge registers its file and commits (B5)

A merge whose only effect was an ADDED file was silently lost: the file landed on disk but never in .zit-managed, so status said "nothing to report", extras listed it as junk, and commit refused "nothing to commit (no changes)" while dropping the merge parent. The branch existed on one disk and undo was the only escape.

No new state was needed — CheckoutState.merge_parent already carried the pending merge and commit already consumed it as a two-token P card. The gap was the managed set and two refusals that did not know about it. applyMerge now derives the membership mutation from the same entry stream that drives the filesystem effects, which also fixes the symmetric bug for free (a merge-deleted file used to stay managed and invent a phantom "missing") and covers cherrypick/backout, which share the seam. status names a pending merge/cherrypick/backout. commit's no-op refusal no longer fires over a pending merge, so a convergent-content merge can no longer wedge the tree.

One test expectation changed: the new add-idempotence case asserted the "already tracked" notice on stdout; it has always been a stderr diagnostic (three shipped e2e cases assert it there). The test had the stream wrong, not the product.

Merged tree re-verified on trunk rather than inherited from the branch: zig fmt --check ., zig build, zig build test, full zig build e2e (420 tests, all eight REGRESSION B5 cases green including the contract-partner undo guard) and smoke S1-S6 all exit 0. fossil extras --dotfiles is empty. The blocker repro was also driven by hand against the rebuilt binary end to end.

Landed alongside, not merged here: another session committed B3 (1aeb531cac) to blockers off the OLD blockers tip, leaving that branch with two leaves. This increment branched off trunk, so the two do not interact and B3 remains that session's to land. check-in: 99f4895c04 user: dmestas tags: trunk

15:09
B5: an add-only merge registers its file, shows as pending, and commits

A merge whose only effect was an ADDED file was silently lost. `merge` wrote the incoming file to the working tree but never inserted it into `.zit-managed`, so the file was simultaneously on disk and untracked: `status` said "nothing to report", `extras` listed it as junk, and `commit` refused with "nothing to commit (no changes)" — dropping the merge parent that `merge` had recorded in `.zit-checkout`. The branch existed on exactly one disk and `undo` was the only escape.

No new state was needed. CheckoutState.merge_parent already carries the pending merge (src/checkout.zig), runMerge already records it, and commit already consumes it as a two-token P card and clears it. The gap was entirely in the managed set and in two refusals that did not know about it.

applyMerge now DERIVES the membership mutation from the same entry stream that drives the filesystem effects, rather than accepting a parallel list from the caller: a `.write` registers its path (insert is idempotent, so an ordinary edit-merge is unaffected) and a `.delete` deregisters it. One stream, one truth. Deriving it there rather than in computeAndApplyMerge also fixes the symmetric adjacent bug for free — a merge-deleted file used to stay managed and invent a phantom "missing" that commit then refused over — and covers cherrypick and backout, which share the same seam. The pre-merge set joins the in-process rollback images so a mid-apply failure restores membership along with the files and the checkout; the undo journal already snapshotted `.zit-managed`, so abort and undo deregistration fall out unchanged.

The set is read twice rather than rebuilt from `old.iterate()`: the on-disk record carries a pending `mv` rename beyond the bare membership, and rebuilding would have silently dropped it.

status now names a pending merge/cherrypick/backout in its header block. A merge whose file effects are already materialized was otherwise indistinguishable from a clean tree, with no way to see that a second parent was queued.

commit's no-op refusal no longer fires while a merge or special op is pending. A convergent-content merge — both sides making the same edit — composes to F cards identical to the parent's, so `unchanged` went true and the pending merge wedged with no commit path at all. The P/Q card is itself the change.

Test-expectation change: the new "zit add of a merge-added file is idempotent" case asserted the "already tracked" notice on stdout. That notice has always been a stderr diagnostic (three shipped e2e cases assert it there); the test had the stream wrong, not the product. The assertion's intent — add sees the merge-registered file as already tracked and does not re-add it — is unchanged.

No technique was borrowed here: the mechanism rides zit's existing applyMerge, ManagedSet and journal seams. Fossil's vmerge/vfile registration was consulted as an analog for the SHAPE of the problem only, and none of its code or algorithm is reproduced.

Verified in this worktree: fmt, build, zig build test, full zig build e2e (420 tests, all eight REGRESSION B5 cases green, contract-partner undo guard still green), and smoke S1-S6. The blocker repro was also driven by hand against the rebuilt binary: merge reports 1 file changed, status shows "pending merge with <hash>" and "added feat.txt", extras is empty, commit lands, and info tip shows both parents. closed check-in: de05152541 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

14:32
Merge blockers: resolve same-mtime ties by DAG ancestry (B1/B2)

Three commits inside one second made 'zit cat f.txt tip' return the root's content and 'trunk' the middle's, while 'zit leaves' named the newest — the binary contradicting itself, exit 0, no warning.

The diagnosis that opened the campaign was wrong and is worth recording: it blamed a single lexicographic-hash tiebreak in mergeBaseOf. Building the equal-mtime chain in BOTH hash orderings produced identical failures, which ruled hash order out as the mechanism. There were three independent tie sites — tipForm settled on events insertion order, resolveName on tagxref iteration order, mergeBaseOf on artifact hash. A fix confined to the originally-blamed line would have left tip and trunk still wrong.

Equal event.mtime is now settled by ancestry: a descendant beats its ancestor. Genuinely concurrent nodes keep a deterministic least-hash fallback — hash order between unrelated leaves was never the defect. Timeline gets a second pass re-ordering equal-mtime runs descendants-first.

Ride-alongs on the same surface: 'update' accepts symbolic names (SF1), and a 'tip' naming a non-leaf is now an in-binary assertion (SF5) — the tripwire that would have caught this originally.

Merged tree re-verified on trunk: fmt, build, test, e2e, dst 6x1000 all green. Remote CI is unavailable (self-hosted runner offline); the local battery is the gate per operator direction. check-in: bc7b914316 user: dmestas tags: trunk

11:56
repostate: break same-mtime resolution ties by DAG ancestry, not hash order

Three commits made inside the same second made `zit` return the WRONG file content for `tip` and `trunk`, and made the binary contradict itself: `zit info tip` reported "leaf: no" while `zit leaves` named the same check-in a leaf. `zit cat f.txt tip` returned the ROOT's content and `zit cat f.txt trunk` returned the MIDDLE's, on a three-commit chain whose newest content was `v3`. A version-control system that silently hands back an old revision under a name that means "newest" is disqualifying, so this lands before anything else.

WHAT WAS WRONG — three independent tie sites, not one

The originally-recorded diagnosis blamed a single lexicographic artifact-hash tiebreak in `mergeBaseOf`. That was wrong for the reported symptom: the RED-proof gate built the equal-mtime chain in BOTH hash orderings and observed IDENTICAL failures, which rules out hash order as the mechanism. Fixing only that line would have left `cat tip` and `cat trunk` still returning wrong answers. The real picture:

- `tipForm` — `mtime > best_mtime`, a strict `>` with no tiebreak at all, so the first entry in `events` INSERTION order won. Symptom: `tip` -> ROOT. - `resolveName` — ties settled by `tagxref` hash-map ITERATION order. Symptom: `trunk` -> MID. - `mergeBaseOf` — lexicographic artifact hash. A genuine defect, proven RED at unit level, but not the cause of the tip/trunk symptom and unreachable from the e2e merge path.

WHAT CHANGED

Equal-`event.mtime` ties are now settled by DAG ancestry: a descendant beats its ancestor. Genuinely concurrent nodes — same mtime, no ancestry relation — keep a deterministic least-artifact-hash fallback, documented at each site; hash order between UNRELATED leaves was never the bug. The shared helper (`MtimeMaxPick`) is used by `tipForm`, `resolveName`, `openLeafOf` and `dateResolve`, so every symbolic-name surface settles ties one way.

Timeline ordering gets a second pass that re-orders each equal-mtime RUN descendants-first, deliberately overriding the hash tiebreak. It is a per-run repeated-maximal-element extraction, NOT a sort predicate: "descends-from, else hash" is not a strict weak order, and handing it to `std.mem.sort` would be undefined behavior. Runs are bounded by the check-ins sharing one millisecond.

Settling a tie by ancestry allocates, so `resolveName`/`resolve`/`openLeafOf` gained `OutOfMemory`. Every caller switches it as an ABORT, never as a mismatch or "already correct" verdict — reporting an allocation failure as the exit-0 up-to-date message is exactly the lie those switches exist to prevent. `repository_test`'s closed-error-set assertion moved 2 -> 3 for that reason: it encoded the pre-fix set, not an inconvenient expectation.

Two ride-alongs on the same resolution surface:

- SF1 — `zit update trunk` used to fail as "invalid check-in ID": update accepted ONLY canonical 40/64-hex. Its argument is a check-in name like every other verb's, so it now runs the shared 8.3 procedure. A canonical ID still short-circuits, leaving update's cold path byte-for-byte as it was. - SF5 — a `tip` that names a non-leaf is now an in-binary assertion, not just a test. It is legitimate ONLY under a non-monotone clock (a child stamped strictly earlier than its parent); a child that TIES its parent means the ancestry tiebreak failed to fire, which is the defect itself. This tripwire would have caught the original bug.

RED PROOF (recorded by the RED-proof gate, 2026-07-19; per BLOCKER-FIX-PLAN.md section 2, no product code changed until RED was proven)

Unit, `src/repostate_test.zig`, deterministic equal-mtime chains built directly in BOTH hash orderings — no wall-clock dependence:

repostate REGRESSION B1: tip resolves to the descendant on an equal-mtime chain (head has the greatest hash) repostate REGRESSION B1: tip resolves to the descendant on an equal-mtime chain (head has the least hash) repostate REGRESSION B1: resolveName picks the descendant on an equal-mtime chain (head has the greatest hash) repostate REGRESSION B1: resolveName picks the descendant on an equal-mtime chain (head has the least hash) repostate REGRESSION B1: tip and resolveName agree with the leaf set in both hash orderings repostate REGRESSION B1: mergeBaseOf picks the deepest common ancestor when every candidate shares an mtime [failed pre-fix: expected 2, found 1]

End-to-end, `src/e2e.zig`, same-second commits with NO sleep between them — same-second IS the point:

cli REGRESSION B1: same-second commits resolve tip and trunk to the newest check-in cli REGRESSION B1 (SF1): update accepts a symbolic name and lands on the newest check-in cli REGRESSION B1: open materializes the newest same-second check-in into a fresh clone cli REGRESSION B1: a same-second two-branch merge picks the correct base and is never up to date

All of the above failed against pre-fix trunk; the two hash-ordering pairs failed IDENTICALLY, which is the evidence that retired the hash-order theory.

TEST EVIDENCE NOW

In this worktree at the committed tree: `zig fmt --check .`, `zig build`, and `zig build test` each exit 0 — full battery, no filtering. The only pre-existing expectation changed is the `repository_test` error-set count noted above, and it changed because it encoded the old set.

Markers `// REGRESSION B1:` are stable so state probes can grep trunk for them.

No borrowed technique: the ancestry tiebreak and the per-run topological micro-ordering are original to this change. check-in: bdb49cc5ca user: dmestas tags: blockers

01:58
Merge release-0.1: merge3_properties algebraic-property fuzz (P4-F7)

Lands the last of the seven 0.1.0 release blockers. merge3 — the three-way merge — had no fuzz target, and silent merge corruption is the worst failure a version-control system can have, because it produces a tree that looks right.

The merged tree was re-verified on trunk after the merge, not inherited from the branch: zig fmt --check ., zig build, and zig build test all exit 0. The merge reported no conflicts and touched four files; fossil extras is empty, so nothing referenced went unadded. The conflict-marker sequences a repo-wide grep reports in src/merge3.zig and src/e2e.zig are that module's own sentinel constants and the tests asserting on them, not merge residue.

Landed by hand rather than by the release workflow: its TigerStyle gate spent its three-round budget on this diff and halted without a confirming pass. The final round's two blockers were real — a zig fmt failure and an oracle hole where conflict spans were validated individually but never as ordered and disjoint, which would have let one widened span exempt every line from oracle 4 — and both were fixed by the round that followed. Verified here before landing: fmt clean, the ordered/closes_once checks present, and merge3_properties running clean at seed 1 over 2000 rounds. check-in: 08e7c6d6f1 user: dmestas tags: trunk