Skip to content
zit Timeline
Login

Timeline

50 ancestors of 7a0735c41501a0b2

2026-07-19
22:39
Merge p5a-help: pin that help names every dispatched verb

Help already listed all dispatched verbs, but nothing held it that way: a verb added later could ship without a help line and no test would notice. An end-to-end pin now drives off the Verb enum itself and fails, naming the offender, if any dispatched verb is absent from zit help -- so the audit cannot silently rot as the command set grows. check-in: 7a0735c415 user: dmestas tags: trunk

22:35
Merge iss7b: read a fossil-legacy baseline instead of refusing files/merge

The #7 fix taught the timeline read path to tolerate fossil's 2008 cvs2fossil-era check-ins, whose F cards carry a dangling trailing separator the strict grammar has no production for. The baseline read path had the same defect: parseBaseline used the strict parser, so files and merge on a delta whose baseline is one of those legacy manifests failed as an unresolvable baseline. It now reads through parseForImport, the same tolerance, and threads the normalized copy the manifest borrows into the owned baseline so it is freed exactly once. The strict parser still runs first and remains the sole grammar oracle; any other malformation is still refused. check-in: cd63fa1766 user: dmestas tags: trunk

22:30
Read fossil's legacy delta baselines instead of dropping their files

The files/ls path eagerly reads a delta check-in's B-card baseline and parsed it through the strict reference parser. fossil's own 2008 cvs2fossil-era manifests carry F cards with a dangling trailing separator after the hash -- a space where a perm token would go -- which the section 4.2 grammar has no production for, so the strict parser rejects them as corrupt. When such a manifest is a delta's baseline, the rejection surfaced as BaselineUnresolvable and the check-in's whole file set vanished.

The target-manifest read path already tolerates this shape through parseForImport; the baseline path now does too. The strict parser still runs first and stays the sole grammar oracle; tolerance engages only when a well-framed, Z-valid artifact is rejected, strips exactly one trailing separator per F card, re-seals the checksum, and re-runs the strict parser -- any other malformation is still refused. The tolerated normalized copy is owned by the parsed baseline and freed with it. closed check-in: 7bb5af134e user: dmestas tags: iss7b

22:13
Guard that every dispatched verb appears in `zit help`

Add an end-to-end check that runs `zit help` and asserts every member of the dispatched Verb enum is listed as a command, keyed off the enum itself rather than a hand-copied list. A verb wired into the dispatcher without a help line now fails the build instead of shipping undiscoverable.

The match is anchored on the command column, so a name that appears only inside another verb's description — "sync" in serve's line, "stat" within "status" — cannot satisfy it; a dropped command entry is caught by name.

The shipped help already names all verbs, so this changes no output; it pins that state against future drift. closed check-in: 64b9a0ee71 user: dmestas tags: p5a-help

21:52
Merge iss7: read fossil's legacy check-ins instead of dropping history

timeline stopped partway through a large fossil-origin repository and lost every check-in at and beyond that point. The cause was not an I/O fault, as the shared "input/output error" wording suggested, but a strict parse: fossil's own 2008 cvs2fossil-era check-ins carry F cards with a dangling trailing separator after the hash, which the §4.2 grammar has no production for and the reference parser rejects as corrupt. Repository.manifest -- the timeline read path -- used that strict parser, so the first such check-in and all older ones vanished.

It now reads through parseForImport, the same read-path tolerance the diff baseline already relies on. The strict parser still runs first and stays the sole grammar oracle; tolerance engages only when a well-framed, Z-valid artifact is rejected, strips exactly one trailing separator per F card, re-seals the checksum, and re-runs the strict parser -- so any other malformation is still refused. Complete history renders again, down to the repository's 2007 root. check-in: 4e8d402bb6 user: dmestas tags: trunk

21:36
timeline: read fossil-legacy check-in manifests instead of dropping them

Repository.manifest parsed check-in manifests through the strict reference parser, which rejects fossil's own 2008 cvs2fossil-era manifests: their F cards carry a dangling trailing space after the hash, where the §4.2 perm token (1*tchar) permits none. On a large fossil-origin repository this made `zit timeline` stop at the first such check-in and exit non-zero, silently losing that check-in and every older one — a functional loss of history, surfaced as a misleading "input/output error".

Parse through parseForImport, the same read-path interop the diff baseline already relies on: the strict parser still runs first and stays the sole grammar oracle; the trailing-separator tolerance engages only when an otherwise well-framed, Z-valid artifact is rejected. OwnedManifest now also owns the normalized copy the manifest borrows when that tolerance fires. closed check-in: b503efb9f2 user: dmestas tags: iss7

21:01
Merge iss12: -R/--repo and -n on the read verbs, in one pass

A read verb could only reach a store by walking up from the cwd, and timeline/log could only emit the whole history. -R/--repo names a store directly, so timeline, log, leaves, branch, tag, info, ls, cat, artifact and annotate run against it with no working tree; a verb that reads or writes the checkout refuses -R by name rather than silently operating on the cwd's store. -n N caps timeline and log at the N most recent check-ins (0 means all), ordered by the same DAG the full listing uses. The -n spelling stays --dry-run on add/rm/clean/addremove, a per-verb overload matching fossil, so those verbs reject -n N and keep their boolean sense.

stat is left out of the -R grant on purpose: it interrogates only the store, but its handler takes no redirected repository, so it refuses -R by name rather than read the cwd's store while appearing to honor the flag. check-in: 43468196c2 user: dmestas tags: trunk

20:50
Merge iss6: open --manifest writes the working-tree manifest files

Repositories that enable fossil's manifest setting expect manifest, manifest.uuid and manifest.tags in the checkout, and their builds read them to stamp a version. A zit checkout could not produce them, so such a build failed or, worse, succeeded with no version embedded. zit open --manifest writes them; the bare flag means ru, and a value picks any combination of r (the check-in artifact), u (its hash) and t (its branch and symbolic tags). The choice is recorded in the checkout record, so every later update refreshes the files; absent flag writes nothing. The selection is explicit rather than read from the cloned repository because the two places fossil keeps it can disagree and only one syncs, so a checkout reading the setting could diverge from a fossil checkout carrying a local override while claiming fidelity. Contents match fossil byte for byte, including the sterilizing comment it appends to manifest. check-in: b38b3be769 user: dmestas tags: trunk

20:46
Refuse -R/--repo on amend

amend rewrites history, but it was in the -R accept arm alongside the read verbs, so `zit amend -R STORE trunk --comment X` from any directory minted a check-in in the named store — a write to a redirected repository the operator never had a checkout of. That is the exact surprise the -R refusal exists to prevent, and amend is not one of the authorized read verbs.

Move amend into the reject arm so -R on amend fails by name with the same "not supported here" diagnostic every other checkout-dependent verb already emits. amend keeps working normally in a checkout; it just no longer accepts -R at all. closed check-in: 4b5dca852c user: dmestas tags: iss12

20:43
Merge iss4: zit stat, the store's own byte accounting and artifact roll

The store could not be interrogated from outside a checkout: no way to ask how many artifacts it holds, which ones, or how many of its bytes are content rather than framing. zit stat answers from one pass over the frames -- store-bytes, content-bytes, frame overhead, artifact and delta counts. Content is summed from each frame's payload during the walk, never derived from the .zit file size, which also carries the store header, a per-frame header and padding; a size-derived figure would look like a result and be a fabrication, so the two stay separate fields and the overhead between them stays checkable. --artifacts lists the stored hashes for comparison across stores. The frame walk and its accounting live in storestat, shared with the storage benchmark, so the verb inherits the benchmark's exact-accounting test and the two cannot drift. check-in: 921678c019 user: dmestas tags: trunk

20:37
Merge iss8: pull converges past the server's unclustered window

A fossil server folds artifacts older than its ~100-entry unclustered window into clusters and stops announcing those members with igot -- the M cards are the announcement. The client stored a received cluster as ordinary content and read nothing from it, so a pull past that window fetched only the cluster and then terminated, silently, having gained nothing. Cluster membership now folds into the phantom set, so the next round gimmes the members. A pull that reaches a clean terminal still lacking artifacts the peer announced now exits nonzero, rather than reporting a success that leaves a repository quietly out of date. check-in: 8d3ebc0c9d user: dmestas tags: trunk

19:18
stat: the content-bytes e2e gets an oracle it shares no code with

The honesty test asserted that content-bytes below store-bytes and that the byte fields sum to the file. Both hold for anything of the form `size - k`: one because a subtraction is smaller, the other because it is that subtraction's identity. The test named the defect it existed to catch and could not see it.

`statOracle` now totals frames from the store's per-rid index -- `payloadLenOf` for content, `frameSpanOf` less payload for overhead -- and the test pins the printed fields to it. It shares nothing with `storestat.walk`, so `size - header_len` for content now fails, as does overhead taken across all frames. One fabrication stays invisible and the test says so: subtracting the walk's own overhead and config from the size IS the measurement.

`stat_help` sat between `status_help` and its doc comment, so that comment documented the wrong constant and claimed an e2e transcription `stat` lacked. Restored, documented, and written.

A repeated `--type` was silent last-wins; a usage error now. runStat claimed "one pass" over two traversals; the claim goes, not the open, which verifies what the walk trusts. closed check-in: 0e1f7a719a user: dmestas tags: iss4

19:15
Add -R/--repo and -n N to the read verbs

-R/--repo PATH names the repository to read instead of the one found by walking up from the current directory. It is lifted out of argv before verb dispatch, so one implementation serves every read verb and each verb's grammar is unchanged. Resolution requires only the .zit store, not the .zit-checkout discovery also demands: reading a repository with no working tree is the point of the option. PATH may name the directory holding .zit or the .zit file itself. Verbs that read or write a working tree refuse -R by name; accepting it and silently operating on the current checkout is the failure worth a diagnostic to prevent. The spelling matches fossil.

-n N (alias --limit N) shows the N most recent entries on timeline and log; 0 means all, as in fossil. This deliberately overloads -n, which remains --dry-run on add and rm, just as fossil overloads it between timeline and clean. A non-numeric or negative value is refused.

An expectation asserting log rejected -n is inverted: it recorded the absence of the limit, not a rule about the grammar.

timeline still defaults to the whole listing, not fossil's 20 entries. check-in: b25e01be95 user: dmestas tags: iss12

19:14
sync: absorb cluster membership so pull converges past the unclustered window

A fossil server folds artifacts past its ~100-entry unclustered window into cluster artifacts and stops announcing them with igot: the M cards become the announcement. The client stored a received cluster as ordinary content and read nothing from it, so no phantom appeared, the 3.7(a) continuation saw an empty table, and the exchange terminated after one round having fetched only the cluster. Repository size, not delta size, was the trigger; clone converges on the 8.2 RID cursor, not on gimme, so it was unaffected.

Client.absorbCluster folds the members into the phantom set with the disposition an igot for each would produce, so the next round gimmes them. Membership is validated before any is applied; a clone skips it for the reason it announces no igot. On the cfile path the container inflates into a scratch buffer, not through store.content, which would memoise plaintext.

Client.unresolvedPhantoms counts announced-but-unobtained artifacts; pull exits nonzero when a run reaches a clean terminal with any outstanding, so a pull that stops short of what the peer holds is no longer reported as success. closed check-in: 5535de0f45 user: dmestas tags: iss8

19:07
open: add --manifest[=r,u,t] for the working-tree manifest files

Repositories that enable fossil's `manifest` setting — fossil, althttpd, pikchr — expect `manifest`, `manifest.uuid` and `manifest.tags` in the checkout; their builds read them to stamp a version. A zit checkout could not produce them, so such a build failed or, worse, succeeded with no version embedded.

`zit open --manifest` writes them; the bare flag means `ru`, a value picks any combination of `r` (the check-in artifact), `u` (its hash) and `t` (its branch and symbolic tags). The choice is recorded in the checkout record, so every later `update` refreshes the files. Absent flag writes nothing.

The selection is explicit rather than read from the cloned repository because the two places fossil keeps it disagree and only one syncs: althttpd's versioned setting says `on`, its local config `rtu`. A checkout reading the setting could diverge from a fossil checkout carrying a local override while appearing to promise fidelity.

Contents match fossil byte for byte, including the sterilizing comment it appends to `manifest`. A tracked file of one of those names is never replaced. closed check-in: dc36496c3b user: dmestas tags: iss6

18:46
Add `zit stat`: the store's own byte accounting and artifact roll

The store could not be interrogated about its contents: no way to ask how many artifacts it holds, which ones, or how many of its bytes are artifact content rather than framing. `zit stat` answers that from the frames themselves, emitting `field value` lines: store-bytes, header-bytes, content-bytes, frame-overhead-bytes, config-bytes, artifacts, full-form, deltas. The byte fields account for the file exactly.

content-bytes and store-bytes stay separate on purpose. Content is measured by summing each artifact frame's payload length; it is never derived from the `.zit` size, which also carries the store header, a header per frame, and alignment padding. A size-derived figure would look like a result and be a fabrication, so the overhead separating the two is reported alongside and stays checkable.

`--artifacts` lists the stored hashes one per line in ascending order, so two stores' rolls compare with `comm`; `--type=full|delta` narrows by storage form, read from the index the open already built.

The frame walk and its accounting live in `storestat`, shared with the storage benchmark, so the two cannot drift. check-in: 7c2262c837 user: dmestas tags: iss4

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:25
CI: fuzz sweep at 100 rounds per target, not 2000

The sweep could not finish inside its own 10-minute step timeout. Per-target cost varies by roughly 276x across the corpus: artifact_parse_garbage runs 100 rounds in 0.18s, while logstore_pack_same_path_round_trip takes 49s because it performs a full store pack every round. At 2000 rounds that single target needs about 16 minutes on its own.

The original figure was extrapolated from one cheap target on the assumption that per-round cost was roughly uniform. It is not, and the assumption was never measured. 100 rounds is measured: the full 27-target sweep takes about 76s.

This makes CI fuzzing a regression check — every target still runs clean on every push — rather than a bug hunt. Finding new defects needs depth, and depth belongs in an out-of-band run with its own budget, where a long sweep costs nobody a merge. check-in: 861b223e3c 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

01:54
Add merge3_properties: algebraic-property fuzzing over the three-way merge

Silent merge corruption is the worst failure a version-control system can have — it produces a tree that looks right — and merge3 had no fuzz target. This adds one, driving the merger with generated base/current/other triples and asserting the properties that must hold for any input.

Oracle 4 (no invented lines) needs a carve-out, because the merger is supposed to fabricate lines inside a conflict envelope. That carve-out is only sound if the recorded spans really are envelopes, so expectNoInventedLines validates the claim before subtracting anything: each span must be in-bounds, open at a line boundary with conflict_sentinel, close with conflict_terminator, start at or after the previous span's end, and carry no second terminator inside it.

The ordering and interior-terminator checks are load-bearing, not belt-and- braces. inAnySpan matches on overlap, so an over-long span exempts every output line up to its end. End-pinning alone admits a single span stretched from the first envelope's start to the last one's end — it opens and closes correctly and overlaps nothing — which would exempt the clean lines between two envelopes from the oracle entirely. closes_once catches that case on the first envelope's terminator. merge3 asserts ordering itself, but only under runtime_safety, which a ReleaseFast sweep — the build a fuzz run uses — erases; this loop holds in every optimize mode.

merge3.zig gains one export: conflict_terminator, the closing counterpart of the existing conflict_sentinel, with a comptime tripwire pinning its shape. Additive only, no behavior change — it lets the oracle pin both envelope ends against the module that produces them instead of re-typing the literal, so the two cannot drift apart.

Battery green in the worktree: zig fmt --check ., zig build, zig build test, zig build e2e, zig build dst (6 seeds x 1000 steps). The new target runs clean at seed 1 over 2000 rounds; no bug was found, so no regression seed is pinned. closed check-in: 21ca364fb3 user: dmestas tags: release-0.1

01:09
CI: sweep every seeded fuzz target, and bump checkout to v5

Two gaps the first green run exposed.

The repository carried 26 fuzz targets that CI never fuzzed. They reached CI only through their fixed-seed smoke tests inside `zig build test`, so the fuzzing investment was half-realized: the targets compiled and ran a handful of canned cases, but nothing swept them. `zig build fuzz` runs one target per invocation, so the new step loops. The target list is read from the step's own usage output rather than kept in the workflow — a target added to fuzz_tests.zig is swept automatically, with no second list to drift. Seed and rounds are fixed (1, 2000), not random: a red run has to be reproducible verbatim on a developer machine. Bare `zig build fuzz` exits non-zero because it is a usage error, so the enumeration tolerates that; every real invocation still fails the job. Verified by running the identical block under bash — 26/26 targets pass at seed 1.

src/ci_test.zig moves with it. `fuzz` leaves not_in_ci (its stated reason was that wiring a bounded CI invocation belonged to a later step — that step is this one) and a new ci_parameterized table records it as covered-in-a-loop, carrying the exact anchor that proves the invocation exists. The anchor is verified, not trusted: an entry whose anchor drifts out of the workflow fails like any other gap. That distinction is kept separate from not_in_ci on purpose — conflating 'runs, in a loop' with 'deliberately skipped' is how a step silently stops being covered while a stale excuse keeps the pin green.

actions/checkout v4 -> v5 clears the Node 20 deprecation warning the green run reported. check-in: 394b4efa27 user: dmestas tags: trunk

00:29
CI: target the self-hosted macOS runner instead of a hosted Linux one

GitHub-hosted runners are unavailable to this repository: it is private, and private-repo Actions minutes are billed, so the first hosted run never started — 'recent account payments have failed or your spending limit needs to be increased', zero steps executed. The gate now runs on the self-hosted mac-mini-zit (macOS/ARM64), which is online and already carries the toolchain.

- runs-on names the specific machine ([self-hosted, mac-mini-zit]) rather than bare self-hosted, so a second runner joining the account cannot silently pick this job up on a different OS or architecture. - The Zig tarball is the aarch64-macos build with its published digest from ziglang.org/download/index.json. macOS ships shasum rather than GNU sha256sum, so the digest is compared explicitly; a swapped tarball still fails the job rather than building silently. - fossil is verified rather than apt-installed — the runner is a developer machine carrying 2.28, newer than what apt ships. The step hard-fails when fossil is absent, because a skipped smoke step would silently stop exercising the conformance claim the release rests on.

src/ci_test.zig moves in lockstep: the runs-on line, the digest constant, the digest-check form, and the fossil-guard pin all now assert the macOS shape. Pins and workflow must change together or zig build test goes red, which is the point of having them. check-in: c8cccb2723 user: dmestas tags: trunk

00:17
Merge release-0.1: Add transport_frame_garbage and serve_request_garbage seeded fuzz targets

Lands P4-F6, closing the last unfuzzed attack surface in the release scope. transport.zig and serve.zig are the only two files a remote peer can reach with bytes it fully controls, and neither had a fuzz target: the sync_parsers_adversarial target covers the layer above them, so the HTTP framing itself — the first code to touch untrusted input — was exercised only by hand-written smoke cases. These two targets hit that framing directly.

The increment: src/transport_fuzz.zig (205 L) and src/serve_fuzz.zig (303 L), registered in the fuzz_tests.zig dispatch table alongside the existing 24 and pinned by that file's registration test. serve.zig's driveHttp is promoted to pub with a comment stating why — it is the receiveHead to handleHttpRequest seam the smoke tests and the fuzz target both need, and duplicating it would have meant fuzzing a copy of the request path rather than the real one. Both sides stay in-memory, so neither target binds a port and neither can flake in CI. dst.zig picks up the two targets plus fuzz_tests.zig itself, the latter so the registration pin has a test step to run in: zig build fuzz has none.

Evidence, re-verified on the merged tree rather than on the branch, since trunk had advanced: zig fmt --check ., zig build, and zig build test each exit 0. Both new targets run clean seeded — invoking zig build fuzz with each target name and --seed 1 exits 0 for transport_frame_garbage and for serve_request_garbage — and found no crash, leak, or UB to fix, so this step carries no pinned-seed regression. The merge reported no conflicts; a repo-wide grep for conflict markers is clean, and fossil extras is empty, so both added files are tracked and nothing referenced went unadded. check-in: b8db5ecabd user: dmestas tags: trunk

00:10
Add transport_frame_garbage and serve_request_garbage seeded fuzz targets

The 24 existing fuzz targets cover the delta, artifact, sync-parser and logstore layers, but not the two CLI-layer modules that consume bytes an untrusted remote peer chose. `sync_parsers_adversarial` covers the layer above; the HTTP framing itself was unfuzzed. These are the last two wire surfaces on the release bar, and they are what a hostile peer reaches first.

What landed:

- `src/transport_fuzz.zig` — `frameGarbage`, registered as `transport_frame_garbage`. Client-side §4/§4.1 framing and redirect policy: the round-trip law (whatever `frame` produces, `unframe` returns exactly those plaintext bytes), mutation of framed containers (bit damage never survives as *different* plaintext — it either fails the container's Adler-32/zlib checks or, in the length-field range the §4.1 compression-length erratum makes a capacity bound, decodes back to the same bytes), and garbage `Location` headers through `resolveRedirect`. Totality is the asserted contract: every input yields a value whose postconditions hold or a declared member of `TransportError`.

- `src/serve_fuzz.zig` — `requestGarbage`, registered as `serve_request_garbage`. Server-side HTTP request path: raw bytes into `std.http.Server.receiveHead`, then `handleHttpRequest`'s dispatch (`classifyXfer` -> body read -> responder -> reply framing). It does not re-feed `handleXfer` in isolation — that decoding is already fuzzed twice — it reaches it only as the tail of a full-path dispatch, which is the point. Asserted contract: any `receiveHead` error is a clean rejection, `handleHttpRequest` returns void, and any produced response begins with an HTTP/1.1 status line.

- `src/serve.zig`: `driveHttp` becomes `pub`. It is the request-path seam two callers need and neither should reimplement — the §4 smoke tests and this fuzz target. In-memory reader/writer on both sides, so the target binds no port and cannot flake in CI.

- `src/fuzz_tests.zig`: both targets registered in the `targets` table, and `targets` becomes `pub` for a new structural registration pin. An unregistered target compiles, passes review, and is then never run by any invocation; the pin asserts every target the release bar names is actually reachable from the runner, so the registration cannot rot.

- `src/dst.zig`: aggregates both new files plus `fuzz_tests.zig` into `zig build test`. This root is the one that reaches serve.zig and transport.zig relatively while carrying the `zit` dep those two import; `zig build fuzz` has no test step, so it is also the only place the registration pin can run.

No bug found, so no fix and no pinned-seed regression in this commit — both surfaces held over the seeded runs below. The targets are the standing check, not a one-time sweep.

Test evidence (this worktree, after `fossil update` absorbed the sibling `zit open` commit 5ba418801d and its 8 modified files):

- `zig build fuzz -- transport_frame_garbage --seed 1 --rounds 2000` — clean, no crash/leak/UB. - `zig build fuzz -- serve_request_garbage --seed 1 --rounds 2000` — clean, no crash/leak/UB. - `zig build test` — green, including the new registration pin and the fixed-seed smoke tests at the foot of both new files. - `zig build` and `zig fmt --check .` — green.

No borrowed technique in this change. check-in: cec4afaf89 user: dmestas tags: release-0.1

2026-07-18
23:19
Merge release-0.1: zit open — the clone-to-working-tree path (P3-F3)

Lands the last of the three P3 CLI cliffs. A clone previously produced only an artifact log: there was no verb that turned one into a working tree, so clone-to-usable-checkout was unreachable and unproven by any test. `zit open` closes that path, resolving the trunk tip and materializing the check-in into the target directory.

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

Landed separately from the sibling status increment (3b7fe2fbe1) rather than folded into one commit: the two are independent release steps and each keeps its own subject and evidence. check-in: 91c682fd06 user: dmestas tags: trunk

23:14
Merge release-0.1: status: report edited files by hashing tracked files against the current check-in

Lands release step P3-F5-status on trunk, completing the B-status-16 deferral.

The increment: `status` previously classified only `added` and `missing`, so a tracked file that was present but modified produced no line at all — the gap was documented in a comment, in `--help`, and in an e2e test that pinned the silence. Documenting a hole is not the same as not having one, and "your working copy looks clean" is the wrong thing for a version control system to say about an edited file. `status` now resolves the current check-in and compares every tracked file against it, emitting an `edited` row when the bytes or the file mode differ; a file whose contents match is still not listed.

The comparison is not a second implementation. The check-in resolution, the legacy trailing-separator F-card tolerance, the delta-manifest walk, and the merge against the sorted managed set were all machinery `diff` already had; that machinery is extracted into `CheckinBaseline` and `walkWorkingTree`, and both verbs now run through it, with each verb supplying only a sink. `diff` is a pure refactor here — same output, less code. Dirtiness remains a non-failure: every `added`/`edited`/ `missing` row exits 0 (B-status-15); only a genuine comparison failure exits 1. `--help` no longer defers edited-file detection and now narrows the deferral text to the two cases that genuinely remain out of scope.

This lands the full detection called for by P3-F5, not the fallback. The plan's fallback of record required an explicit operator decision; none was taken and none was needed.

Merge evidence: `fossil merge release-0.1` reported no conflicts, updating only src/e2e.zig and src/main.zig. The tree carries no conflict markers — the three open-marker occurrences a grep turns up in src/e2e.zig are string literals inside assertions pinning zit's own merge3 conflict sentinel, not merge residue.

Verification evidence: the merged tree — not merely the branch — was re-verified on trunk after the merge. `zig fmt --check .`, `zig build`, and `zig build test` each exit 0. Behavioral evidence rides in the branch commit's new e2e coverage, which pins each acceptance criterion: an edited tracked file is reported as edited; a rewrite with identical bytes (mtime bump, same content) is not reported; an executable-bit-only change is reported; a file deleted from the check-in but no longer tracked is still not reported; and a delta checkout with an absent B-card baseline fails cleanly rather than reporting a false clean tree. The e2e that pinned the old deferral is replaced by those tests, and the help-text e2es compare against a hand-written expected string, so the `--help` change is pinned too.

`fossil extras` is empty, so the merge adds no file it fails to track.

No performance claim is made here. No borrowed techniques in this change, so no attribution is owed. check-in: 3b7fe2fbe1 user: dmestas tags: trunk

23:11
Add zit open: clone→working-tree path, proven by e2e chain and fossil smoke scenario

`zit clone` builds a repository file and nothing else. The checkout it left behind was the same UNBORN record `init` writes (`version == ""`, branch `trunk`), with no managed set and no files, and there was no way to turn it into a usable tree: `zit update` accepts only a literal 40/64-hex check-in ID, so a freshly cloned tree could not name its own tip. Clone was a dead end.

`zit open` is the second half of fossil's own clone+open UX. It resolves the tip of the main branch out of this repository's derived state and hands materialization to the SAME `update.applyCheckin` engine every other working-tree move rides — journal-armed, transactional, rollback-safe. Tip resolution is the only work that is open's own; everything else is wiring.

What changed:

- `cli.parseOpenArgs` / `OpenArgs` / `OpenResult`: an operand-free surface. `-h`/`--help` is the only accepted token and wins over every other token; a bare `-x` is an unknown option, anything else an extra argument. The verb takes no operand by design — it derives its target from the repository. - `main.runOpen` plus the `open` entries in the `Verb` enum, the dispatch switch, and the `verb_help` table, so the verb is added in one place and the total switch stays total. `runOpen` writes NO checkout record and NO managed set of its own: `init` already wrote the unborn record, an absent managed set already reads as the empty set, and the update engine stays the single writer of the born record. A second writer of that bootstrap state would only be a second thing to keep in sync. - `main.openApplyFail`: one arm per `update.ApplyError` variant and no `else`, so a new variant fails the compile and forces a decision, mirroring `updateApplyFail`. `SymlinkUnsupported` / `InvalidPath` / `ReservedPath` are raised while planning the TARGET manifest and are reachable on a perfectly intact repository, so they get honest prose instead of a corruption claim. - `update.planUpdate`: an UNBORN record now takes the same arm as an absent one. Unborn is the normal state of every fresh repository, so without this the first `update` after a clone handed `""` to `buildPlan`, failed `artifact.validateName("")`, and reported `CurrentStateInvalid` — a clean, correct tree diagnosed as broken. A record that names no check-in but claims a merge parent or a pending special operation stays incoherent and is still `CurrentStateInvalid`. - BORN-ness, not presence, is the "already open" signal. A presence-based gate would have rejected a clone followed by an open on every clone, the primary path the verb exists to serve.

Test evidence (all run in this worktree after absorbing the sibling P3-F4 commit 281c0ed815, which arrived during this step and was merged by hand in `src/e2e.zig` — both sides had appended the identical P3-F4 block, so the duplicate was dropped):

- `zig build test` — green, including new `update_test.zig` coverage of the unborn-record arm and its incoherent-record rejections. - `zig build e2e` — green, including the P3-F3 block: clone → open → byte-identical tree → update → commit round-trip; a plain clone's unborn checkout tolerated by `update`; an untracked file in the way failing the open and materializing nothing; re-opening an already-open tree refused with no change; a repository with no check-in reported rather than failed; outside a repository failing with the not-a-repo diagnostic (exit 1); unknown option and positional as usage errors (exit 2); and the verb present in top-level help with one help text behind both `--help` and `help open`. - `zig build smoke` — green, with the new S6 scenario (§3.7/§4): `zit clone` + `zit open` against a REAL fossil server materializes the committed bytes byte-for-byte. This is the cross-implementation proof that the new path produces a working tree fossil itself agrees with. - `zig fmt --check .`, `zig build`, and `zig build dst` (6 seeds x 1000 steps) also green.

No borrowed technique in this change. No new file: the work lands entirely in existing modules. check-in: 5ba418801d user: dmestas tags: release-0.1

23:08
status: report edited files by hashing tracked files against the current check-in

Completes the B-status-16 deferral. `status` previously classified only `added` and `missing`: a tracked file that was present but modified produced no line at all, and the gap was documented in a comment, in `--help`, and in an e2e test that pinned the silence. Documenting a hole is not the same as not having one, and "your working copy looks clean" is the wrong thing for a version control system to say about an edited file.

What changed:

- `status` now resolves the current check-in and compares every tracked file against it, emitting an `edited` row naming the path when the bytes or the file mode differ. A file whose contents match is still not listed.

- The comparison is not a second implementation. `diff` already resolved the current check-in, tolerated the legacy trailing-separator F-card, walked the delta manifest, and merged that effective file set against the sorted managed set. That machinery is extracted into `CheckinBaseline` (open/resolve) and `walkWorkingTree` (the merge), and both verbs now run through it. `diff` is a pure refactor here -- same output, less code. The `diff*Fail` diagnostic mappers become `compare*Fail` and take the verb as a parameter, the shape `checkoutFail` and `managedFail` already used.

- A verb supplies only a sink with `emit(entry)`. `DiffSink` renders diff records; `StatusSink` renders human rows and drops the deleted-from-check-in class, which `status` still defers because it iterates the managed set. The sink is comptime-generic rather than an erased pointer pair: both sinks are known at compile time, so the call stays direct and type-checked.

- The whole report is buffered and written once, as `diff` already did. A comparison failure part-way through the walk now leaves stdout empty rather than a header above a truncated report. Dirtiness is still never a failure: every `added`/`edited`/`missing` row exits 0 (B-status-15). Only a genuine comparison failure -- unreadable tracked file, corrupt or unresolvable check-in -- exits 1.

- `CheckinBaseline` is self-referential (the walk borrows the resolver, which borrows the log, which reads through the file storage), so it is initialized in place and must not be moved. The open/deinit contract used to rest purely on each call site placing its `defer` correctly; an `opened` flag now makes that invariant assertable, and both `deinit` and the failure path assert it.

- `walkWorkingTree` carries a termination guard against the exact bound (one step per managed path, one per effective file), plus a per-iteration progress assertion, because correctness of the merge rests on every switch arm advancing exactly one cursor. Debug-only, alongside the existing debug-only assertion that the managed set is sorted.

- `--help` no longer defers edited-file detection. It documents the three row classes and narrows the deferral text to the two cases that genuinely remain out of scope: a file deleted from the check-in but no longer tracked, and untracked "extra" files -- pointing at `zit diff` and `zit extras`.

Test evidence: new e2e coverage pins each acceptance criterion -- an edited tracked file is reported as edited in the born state; a rewrite with identical bytes (mtime bump, same content) is not reported; an executable-bit-only change is reported as edited; a file deleted from the check-in but untracked is still not reported; and a current delta checkout with an absent B-card baseline fails cleanly rather than reporting a false clean tree. The pre-existing e2e that pinned the deferral is replaced by the tests above. The help-text e2es compare against a hand-written expected string, so the `--help` change is pinned too. `zig build` and `zig build test` pass in this worktree after absorbing the sibling add/rm recursion commit (281c0ed815), and the commit is proved against a clean `fossil open` checkout running `zig build` then `zig build test`. check-in: 02d993f860 user: dmestas tags: release-0.1

22:52
Merge release-0.1: Implement add/rm directory recursion through the shipped extras.sweep walk

Lands release step P3-F4-addrm on trunk. `zit add DIR` and `zit rm DIR` were hard errors directing the user to name individual files, which made `zit add .` and adding a source subtree dead ends. The increment makes both verbs recurse: `add DIR` expands over the already-shipped `extras.sweep` of the checkout root rather than introducing a second walker, so the exclusions, the no-follow symlink posture, and the tracked-set subtraction are the ones `addremove` and `clean` already use, with the sweep lazy and memoized so multiple directory arguments still perform one enumeration. `rm DIR` expands over the tracked keys via `ManagedSet.iterate` rather than over the disk, so an already-deleted directory remains forgettable. A single containment predicate, `pathUnderDir`, serves both verbs so the prefix-boundary case cannot diverge between them. Help text and exit-status wording now describe recursion instead of the removed directory error.

Merge evidence: `fossil merge release-0.1` reported no conflicts, updating only src/e2e.zig and src/main.zig; the tree carries no conflict markers (the open-marker occurrences that a grep turns up in src/merge3.zig and src/e2e.zig are that module's own conflict sentinel constant and the tests asserting on it, not merge residue). The merged tree — not merely the branch — was re-verified green on trunk after the merge: `zig fmt --check .`, `zig build`, and `zig build test` all exit 0. `fossil extras` is empty, so the merge adds no file it fails to track.

No borrowed techniques in this change, so no attribution is owed. check-in: fca1e073c2 user: dmestas tags: trunk

22:45
Implement add/rm directory recursion through the shipped extras.sweep walk

`zit add DIR` and `zit rm DIR` were hard errors that told the user to name individual files. That made the two most common invocations -- `zit add .` and adding a source subtree -- dead ends, and it was the last place where the CLI asked the user to do work the repository already knows how to do.

What changed:

- `add DIR` now expands to every untracked file beneath the directory. It reuses the ALREADY SHIPPED `extras.sweep` of the checkout root rather than introducing a second walker: the same walk, the same exclusions, the same no-follow symlink posture `addremove` and `clean` use. The sweep is lazy and memoized for the whole invocation, so `zit add a b c` over three directories still performs exactly one enumeration. Because the sweep already subtracts the tracked set and the depth-0 control names, neither can be re-inserted or re-reported by the directory path. - `rm DIR` expands over the TRACKED keys via `ManagedSet.iterate`, never over the disk, so a directory the user has already deleted is still forgettable. Keys are copied into the arena before the first removal, because `iterate()`'s slice and its backing bytes are invalidated by `remove` -- the same discipline `addremove` uses for its remove set. Ascending order is preserved and every expanded key still passes the per-key control-file guard (B-rm-7). - One containment predicate, `pathUnderDir`, serves both verbs, 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. - No new diagnostic class. `zit rm .` with nothing tracked resolves to the existing not-tracked message rather than inventing an empty-expansion error; `zit add .` on a tree holding only control files adds nothing and succeeds. - The `add` and `rm` help text and exit-status wording now describe recursion instead of the removed directory error.

Test evidence: the B-add-5 and B-rm-5 contract tests were rewritten to pin the new behavior (a named symlink is still rejected; a directory now recurses; `.` at the root is never reported as outside the repository), and a new P3-F4 block of end-to-end tests covers nested trees, prefix-boundary siblings, multiple directory arguments sharing one sweep, --dry-run, --hard, and rm of a directory that no longer exists on disk. `zig build test` passes in this worktree after absorbing the sibling packaging commit (3645c03e2d), and the commit is proved against a clean `fossil open` checkout running `zig build` then `zig build test`. check-in: 281c0ed815 user: dmestas tags: release-0.1

22:13
Add scripts/mirror-export.sh: the reproducible export to the private mirror

The mirror is a derived view for running CI, never a source of truth — fossil is the repository, and every push is a replay of fossil history, which is why it pushes --mirror and nothing is ever committed git-side.

Why a script when 'fossil git export' already works: its configuration (mirror directory and autopush URL) is per-checkout state, machine-local and invisible to anyone else cloning this repo. The script documents the one supported way to run the export and fails with the exact 'fossil git export ... --autopush ...' fix line when a checkout has never been configured.

It also re-drives the push explicitly rather than relying on autopush. Autopush is best-effort: the first export of the CI commit hit a transient connection refusal to github.com, left the local mirror ahead of the remote, and 'fossil git status' still reported up-to-date — because that status describes the export, not the push. Re-driving makes the push's exit status the script's.

The mirror is PRIVATE and stays private; the script never touches visibility. check-in: ef4efe16b3 user: dmestas tags: trunk

22:03
Add CI: private git mirror + GitHub Actions running the release battery

CI is the multiplier for the rest of the 0.1.0 sequence: from here every fix lands with fmt/build/test/e2e/dst/smoke gated automatically, and the smoke conformance claim — zit artifacts and deltas validated against the real fossil binary — becomes continuously enforced instead of milestone-manual.

- .github/workflows/ci.yml: single ubuntu-latest gating job on push and PR. Zig 0.16.0 pinned by version AND sha256 digest, verified with sha256sum --check --strict before use; fossil installed for smoke; the battery runs in order with a 20-minute job ceiling and a 5-minute bound on smoke, which is the one step that can hang without producing output. - src/ci_test.zig: structural pins over the workflow, so the file cannot drift from what it claims. Each pin is anchored to a whole line with its YAML indentation — a loose substring is satisfied by the phrase appearing in a comment while the block that governs the run says something else. The digest pin names ZIG_SHASUM and the tarball together, so the digest this repo pins is provably the digest the workflow checks. - src/packaging_test.zig: a negative-space pin asserting every path build.zig embeds is version-controlled. This diff is why: build.zig came to depend on .github/workflows/ci.yml while that file was untracked, and bare 'fossil extras' does not list dotfiles, so the commit-discipline check reported clean. The pin runs 'fossil ls' and fails on any embed that is not tracked.

The mirror is PRIVATE (github.com/danmestas/zit) and stays private; publishing is a separate decision that is deliberately not part of this release. check-in: 8a003b2ada user: dmestas tags: trunk

21:11
Merge release-0.1: Narrow .paths to docs/rfcs so working docs never ship in a fetched package

Second increment of the 0.1.0 release sequence, landed on trunk as it completes rather than accumulating to a big-bang merge at the end.

The increment: build.zig.zon .paths named the whole docs directory. A Zig package is cut from the working tree, not from what fossil tracks, so the ignore-glob does not govern it -- a bare docs entry ships whatever sits under docs/ at package time, including the ignore-glob'd docs/plans/*.md working documents, which are internal and must never leave the repository. .paths now names docs/rfcs directly, which is also what the repo convention already states (0ad7fa92c6: "docs/ is for the RFCs only"); the manifest encodes that convention instead of relying on docs/ happening to stay clean.

Evidence: a new pin in src/packaging_test.zig asserts both halves -- that .paths contains "docs/rfcs", and, as negative space, that a bare "docs" entry is absent, so the ship-everything form cannot drift back unnoticed.

The merged tree was re-verified before this commit: zig fmt --check, zig build, and zig build test all green on the merge result. Green on the branch does not imply green on the merge, since trunk advances with concurrent work.

No borrowed techniques in this change, so no attribution is owed. check-in: 0c22d3a950 user: dmestas tags: trunk

20:52
Narrow .paths to docs/rfcs so working docs never ship in a package

`.paths` named the whole `docs` directory. A Zig package is cut from the working tree, not from what fossil tracks, so the ignore-glob does not govern it: a bare `docs` entry ships whatever sits under docs/ at package time — including the ignore-glob'd docs/plans/*.md working documents, which are internal and must never leave the repository.

Naming docs/rfcs directly is also what the repo convention already states (0ad7fa92c6: 'docs/ is for the RFCs only'); the manifest now encodes that convention instead of relying on docs/ happening to stay clean. A new pin in src/packaging_test.zig asserts it so the bare-docs form cannot drift back. check-in: 3645c03e2d user: dmestas tags: release-0.1

20:30
Merge trunk into release-0.1: resync after the P1 land

P1 landed on trunk (8117eab3b2) per the continuous-integration rule, so the release branch absorbs trunk to keep a single lineage. Content-identical merge — this records the ancestry so later increments do not re-traverse it. check-in: 6d229acebf user: dmestas tags: release-0.1

20:27
Merge release-0.1: BSD-2-Clause LICENSE and the packaging structural pins (P1)

First increment of the 0.1.0 release sequence, landed on trunk immediately per the plan's continuous-integration rule rather than accumulating to a big-bang merge at P6.

- LICENSE at the repo root (BSD-2-Clause, copyright Daniel Mestas 2026). zit is an independent reimplementation from the Fossil RFCs, not a derivation of Fossil source, so the choice was open; BSD-2-Clause matches the Fossil heritage and the systems-Zig ecosystem. - build.zig.zon .paths names LICENSE, so it ships inside a fetched package. - The commit-description-* scratch file is gone and the pattern is in the versioned ignore-glob. - src/packaging_test.zig pins these as executable facts, including a negative-space pin asserting every repo-root path build.zig embeds is also named in .paths — the embeds attach to the test-only module, never to the exported zit module, so a consumer never depends on a file outside .paths.

The merged tree was re-verified before this commit (fmt/build/test green): green on the branch does not imply green on the merge, since trunk advances with concurrent work. check-in: 8117eab3b2 user: dmestas tags: trunk

20:19
Remove docs/RELEASE-0.1.0-PLAN.md from tracking; working docs live under the ignored docs/plans/

The release plan is a working document, not a shipped artifact. Repo convention (0ad7fa92c6) is that docs/ carries the RFCs only, with docs/plans/* already in the versioned ignore-glob; the plan, the readiness assessment, and its earlier draft all belong there. P1 added the plan because its own scope text said to commit it — that instruction is wrong and has been removed from the plan.

The file is unchanged on disk, now at docs/plans/RELEASE-0.1.0-PLAN.md. It remains in the history of 122998a428 — fossil history is immutable and shunning it would be disproportionate for a working doc. check-in: 3179b6805a user: dmestas tags: release-0.1

20:12
Add BSD-2-Clause LICENSE, ship it in .paths, drop scratch file, ignore the pattern

A release without a license grants nobody any rights, so this is the legally gating step of the 0.1.0 sequence. zit is an independent reimplementation from the Fossil RFCs rather than a derivation of Fossil source, so the choice was open; BSD-2-Clause matches the Fossil heritage and the systems-Zig ecosystem.

What changed:

- LICENSE at the repo root, BSD 2-Clause, copyright Daniel Mestas 2026. - build.zig.zon .paths now names LICENSE, so the file actually ships inside a fetched package instead of only existing in the checkout. - The leftover commit-description-413190fb92be991f.txt scratch file is gone and .fossil-settings/ignore-glob carries commit-description-* so the class of file cannot drift back in. - src/packaging_test.zig pins the above as executable facts: LICENSE content, LICENSE named inside the manifest's brace-matched .paths block, a line-anchored ignore-glob pin, and a negative-space pin asserting every repo-root path build.zig embeds is also named in .paths. These acceptance criteria are file-structure facts, which rot silently because nothing executes them; the battery alone cannot catch a .paths regression. - build.zig grows build_unit_test_module: the pinned repository files attach as anonymous imports to a test-only compile of root.zig, never to the exported zit module. Attaching them to zit made every consumer's build depend on files outside .paths and a fetched package failed on a missing .fossil-settings/ignore-glob before compiling a line. The negative-space pin exists to catch exactly that recurrence. - docs/RELEASE-0.1.0-PLAN.md, the plan of record for the 0.1.0 release, is committed alongside the first step it describes.

Test evidence: zig build and zig build test green in the working checkout. This commit is additionally gated on a clean-checkout proof — fossil open into a fresh temp directory, then zig build and zig build test there — which is the check that catches a file referenced but never added.

No borrowed techniques in this change, so no attribution is owed. check-in: 122998a428 user: dmestas tags: release-0.1

19:29
Create new branch named "release-0.1" check-in: be86f4a5e6 user: dmestas tags: release-0.1
17:21
Remove docs/plans/bench benchmark-run JSON from tracking; ignore docs/plans

These four bench-run outputs (baseline / step2-pickbase / step3-pack-redeltify / step4-samepath) were measurement artifacts committed under docs/plans/bench — they do not belong in version control (docs/ is for the RFCs only). Remove them from the tip and add docs/plans/* to the versioned ignore-glob so the pattern cannot recur. They remain in the historical check-ins that added them (9aaf485847 and the integration merges) — Fossil history is immutable; purging them from provenance entirely would require fossil shun, deliberately not done. check-in: 0ad7fa92c6 user: dmestas tags: trunk

17:08
Merge cli: the cli-verbs-2 survey slice — seven new verbs

Lands amend, artifact, bisect, log PATH, annotate, merge --cherrypick/--backout, and addremove — the VCS-verb-survey recommendations — onto trunk, already integrated with the storage branch's pack re-deltification.

- amend: mint display-metadata T cards, never rewrite history (also corrects the git-reflex table's wrong immutability lesson). - artifact: the plumbing window (whatis + raw dump) over the one parser. - bisect: DAG binary search (bad/good/next/status/reset), state in .zit-bisect. - log PATH: per-file history folded into log (fossil finfo). - annotate: per-line provenance; the version chain is ordered by TOPOLOGY (matching fossil's generation walk), not mtime, so a parent is never misattributed to its child on a timestamp collision. - merge --cherrypick/--backout: alternate-baseline special merges recording a 4.7.14 Q card, rendered by info; the plain merge surface is byte-identical. - addremove: one sweep, add every extra and drop every missing.

Built decomposed (focused implementers + adversarial review) after the monolithic workflow thrashed on API instability. 1678 unit + 376 e2e + dst 6x1000 green. check-in: e5fffafdb8 user: dmestas tags: trunk