Architecture
The workspace separates lifecycle decisions from operating-system adapters. Consumers embed the façade and inject ports; the shipped CLI is one composition root.
Pan the full-size diagram: swipe or scroll, or focus the canvas and use the arrow keys.
The façade never depends on concrete Git, database, CLI, Atlas, or agent runtime behavior. The domain crate performs no I/O. This dependency direction lets an embedded consumer replace process execution and persistence without reimplementing cleanup policy.
Cleanup proof
Pan the full-size diagram: swipe or scroll, or focus the canvas and use the arrow keys.
Dry-run garbage collection traverses the proof without claiming lifecycle state, writing the registry, or removing a worktree; it may refresh remote advertisements and fetch missing objects into the local object database. Apply requires the exact ids reviewed in a preceding assessment. Final observations are repeated before mutation; the proof-bearing removal intent makes an interruption after filesystem removal recoverable.
Remote evidence is derived from ls-remote --refs advertisements, so any advertised branch, tag,
pull-request ref, or custom namespace can qualify. Required missing objects are fetched with
source-only refspecs and blob filtering, then the remote is read again before ancestry is checked.
Local tags and local remote-tracking refs are never proof by themselves. Unknown, offline, dirty,
locked, live, local-only, changed, and ambiguous states retain the tree.
Ancestry is tried first. When no advertised ref contains the exact HEAD, the adapter lists the
commits HEAD adds over every confirmed advertised tip. Each must have exactly one parent and a
non-empty patch. One tip must then pass two checks: Git's --cherry-pick equivalence leaves no
commit on the HEAD side, and git patch-id --verbatim over --binary patches finds every unique
commit's whitespace-exact patch among the tip's own commits. Default branches are tried first. The
resulting proof has kind patch-equivalent and names the unique commits it covers; the final
re-observation before removal repeats the same two-kind check.
Archive proof
A tree whose work may not be published is covered by a local archive instead. The Git adapter lists the commits HEAD adds over the confirmed advertised tips with the same query patch equivalence uses, so the archive and the recovery check cannot disagree about what is unpublished. It bundles them from a scratch bare repository whose objects are the source's alternates, and hashes the tree's files without filters into a scratch object directory and a scratch index. The repository gains no ref and no object beyond the advertised objects the recovery check itself fetches, and the tree's index is not rewritten.
A nested Git repository is listed by Git only as <path>/ on the empty index, so neither the
scratch index nor the patch holds it. The adapter walks each such root without following symlinks,
writes its canonical listing as a GNU tar image with the tar crate, reads the image back and
compares the listing, and records the listing's SHA-256 as the root's fingerprint. worktree_tree
and dirty.patch cover everything outside the imaged roots and the build layout left out.
Cargo build layout is decided in the domain, I/O-free: is_build_layout and layout_target take
a path below a target, the target's TargetFacts (a Cargo-signed tag; a profile, which tmp/
needs) and an injected "is this prefix a profile directory" observation. The Git adapter
recognises the targets with discard-cache's own structural classification (one walk of the
ignored entries, which now also reports each target it recognised) and, because a tracked file
below a target keeps that walk from reaching it, also checks each directory a listed path places a
CACHEDIR.TAG or a profile's .fingerprint/ in against the same structure on disk
(cache::archive_target, used only by archives; discard-cache's deletions are unchanged). It
excludes the paths HEAD or the index tracks and observes profiles on disk. The capture leaves those files out of the
scratch index, so the patch and the fingerprint never see them, and the manifest records them per
target as build_output in format worktree.archive/3.
A Flutter app's output is decided by one classifier in the domain, also I/O-free:
flutter_output_kind decides whether a directory <P>/build or <P>/.dart_tool is one, and
flutter_output finds the outermost one a path lies strictly below, from FlutterFacts its caller
observes: whether <P>/pubspec.yaml is present beside it as anything but a directory, and whether
anything is tracked at the directory's path, and whether the directory is itself a recognised
cargo target (FlutterFacts::cargo_target). A directory that is both is classified by the cargo
rule alone, so a file below it that is not cargo layout stays archived; because the classifier
decides that, archive, discard-cache and the strip agree. The archive and discard-cache
observe the disk (cache::flutter_facts: a real directory, a regular pubspec.yaml, nothing HEAD
or the index tracks at or below it, and cache::archive_target for the cargo target); the strip
observes HEAD's tree and the patch's pure additions, and its own recognised targets. The archive
records the outer of a cargo target and a Flutter output directory (outermost_build_directory),
as the strip does; discard-cache deletes an ignored directory the classifier recognises as
CacheKind::FlutterOutput, after its cargo rules. For every kind, one walk that follows no
symbolic link (cache::holds_git_entry) decides whether a recognised directory holds a .git
(file, directory or symbolic link) at any depth below it, or is or holds a Git directory with no
.git entry (cache::git_directory: HEAD a regular file, objects/ and refs/ real
directories, as a bare repository or a --separate-git-dir has): a repository there may hold commits no
remote does, so the directory is never deleted whole. A whole Cargo target holding one falls to
its parts (each profile, nested target and tmp/ under its own rule; a target is cargo-target
only when nothing in it was retained, so by construction it holds none); a tmp/ holding one is
split into its direct entries, those holding none discarded as cargo-target-tmp and the rest
retained; every other kind holding one is retained whole. The walk runs again immediately before
each deletion, whatever the kind, and an unreadable directory counts as holding one.
Repository content is never layout. The domain's layout_directory asks, besides the cargo and
Flutter rules, whether a path has a .git component or lies at or below a Git directory
(GitDirectoryFacts); the disk side observes that with the one predicate discard-cache uses, and
the strip infers it from the HEAD and objects/ paths a patch purely adds.
Verification of a format 3 archive leaves layout out only below the directories the manifest records, so an archive written before a kind of output was recognised still holds it in its fingerprint and still verifies; layout below an unrecorded directory stays in the fingerprint and refuses, and the refusal names that directory. The discard partitions the untracked files after the index reset into layout below a recorded directory and everything else; the rest must match the archive as before, and each layout file is re-observed as layout below the same recorded directory, recognised now, immediately before it is deleted.
Pan the full-size diagram: swipe or scroll, or focus the canvas and use the arrow keys.
Before either kind of proof counts, the adapter refuses state git status does not report but
removal would destroy: assume-unchanged and skip-worktree entries (ls-files -v), staged content
that differs from both HEAD and the working copy, any .git found by walking the tree's
filesystem below its root except below a nested root the archive a removal relies on images (the
port's hidden_state takes that archive and re-verifies its images before skipping a root), and
refs in the per-worktree namespaces refs/worktree/,
refs/bisect/ and refs/rewritten/.
The pack check indexes the bundle in an isolated bare repository and compares its object list with
rev-list --objects over the same range, because git bundle verify and the bundle's head list
both pass for a bundle that names HEAD but omits its parents' objects.
Archive pruning
prune-archives is the one command that deletes an archive. The decision is the domain's
I/O-free decide_archive_prune, which takes one observation of the directory (its parsed
manifest and every entry, links not followed) and two injected observations, whether the
registered tree path exists and what the remotes hold, and checks in a fixed order. The remotes
are asked only once every local check has passed.
Pan the full-size diagram: swipe or scroll, or focus the canvas and use the arrow keys.
The remote observation is the port's commits_not_on_remote. The Git adapter anchors the removal
proof's own advertised-ref observation at the first recorded commit present locally, then asks plain
ancestry, with replacement objects and grafts disabled, of each recorded commit against the
confirmed tips. Patch equivalence, which removal accepts, is deliberately not consulted: an archive
whose commits are on a remote only as rebased copies is the sole copy of those commit ids.
The façade lists the archive root without following links. A directory below
<root>/<repository>/ whose name does not start with . is an archive directory; every other
entry is reported as skipped. --apply takes exact directory names, re-reads and re-assesses each
immediately before deleting it, and the adapter's delete_archive then requires the manifest on
disk to equal the assessed one, removes each recorded regular file, the manifest last, and finally
the directory with a non-recursive remove_dir, so an entry that appeared meanwhile is kept and
refuses.
Stripping build output from archives
prune-archives --strip-build-output uses the same selection and never deletes an archive. The
domain's decide_archive_strip shares pruning's local checks (InvalidManifest,
UnrecordedContent, TreeStillPresent), then asks whether the repository is gone and only then
for the patch's sections. The Git adapter's scan_archive_patch streams dirty.patch line by
line, hashing it against the recorded digest, and splits it at each diff --git line; a line
in a hunk starts with , +, - or \, and a base85 line holds no space, so neither can open
a section. Each section carries its decoded path (diff_git_path, with Git's C-quoting undone),
whether its extended header holds new file mode, whether the file it adds begins with Cargo's
CACHEDIR.TAG signature, its patch bytes and the size of the file it adds. The adapter returns
them as a ScannedPatch with the paths of HEAD's files and directories, read with git ls-tree
(from the archive's bundle when the repository lacks HEAD); ScannedPatch::against_head, in the
domain, marks each section whose path HEAD holds as a file or directory or below a file. The
adapter only observes: it reads no file contents and decides nothing. The domain's
plan_patch_strip keeps every section of a path named twice, named by a section that is not
new file mode, or tracked, recognises cargo targets from the remaining pure additions alone (a
signed tag or a profile) and Flutter output directories (FlutterOutputKind: <P>/build or
<P>/.dart_tool, where HEAD's files or the pure additions hold <P>/pubspec.yaml and HEAD holds
nothing at the directory's own path), and marks the sections to strip, recording each under the
outermost directory whose rule strips the path that claims it, cargo target or Flutter output. The report's prune verdict afterwards is decide_archive_prune over the
directory as it would be (contents_after_strip).
Pan the full-size diagram: swipe or scroll, or focus the canvas and use the arrow keys.
strip_archive_build_output re-reads the manifest and refuses one that changed, streams the kept
sections into a scratch directory beside the archive while re-verifying the source digest and
section count, reads HEAD's tree through scratch objects (unbundling commits.bundle into them
when the repository no longer holds HEAD), applies the new patch with apply --cached to a
scratch index and records its write-tree as worktree_tree. Only after the manifest is
re-checked does it rename the new patch over dirty.patch (or delete it) and rename the new
manifest into place.
Creation and membership
Activated workspace and managed roots are canonical and disjoint, and profile names are a single path-safe component. A future managed path is canonicalized through its nearest existing ancestor, so symlinks and parent components cannot redirect containment.
Planning resolves a user-facing revision to a full immutable commit id. Execution revalidates the repository root, policy-derived destination, and commit before reservation. Git observations use the repository's worktree inventory: the primary checkout and unrelated paths cannot be mistaken for a removable linked worktree.
Legacy reconciliation
Reconciliation dry-runs may inventory every candidate, but apply requires exact reviewed ids. An
active adopted worktree outside the managed root is migrated with non-forced git worktree move.
The manager records a durable relocation intent, verifies that HEAD is unchanged, and atomically
updates the registry. A later apply can complete an interrupted move when Git reports exactly one
of the recorded source or destination paths.
A finished external legacy worktree is handled differently: exact-id reconciliation may retire it
in place instead of moving it. This is the only removal path outside the ordinary GC root and is
intended for clean legacy trees on another filesystem. The same membership, lease, lock, clean
state, HEAD-stability, live remote-proof, durable-intent, and non-forced-removal gates still apply.
Apply also requires the separate --allow-external-retirement confirmation, preventing a reviewed
migration id from becoming an external deletion when lifecycle state changes before execution.
Finished legacy records stranded by a pre-0.3 relocation intent can take this path only when the
intent's source and HEAD still match and its destination is absent from both Git and the filesystem;
the proof-bearing removal intent retains that topology evidence until successful removal, when
completion clears both intents atomically.
When a registered path is already absent, reconciliation changes registry state only after Git no longer reports the worktree and either a matching removal intent exists or the stored final HEAD is freshly proven recoverable from an advertised remote ref, by ancestry or patch equivalence. A provisioning or failed record can be activated when Git already created the exact linked tree, or tombstoned without a HEAD only when no filesystem or Git artifact exists.
A record whose stored HEAD is reachable from nothing is otherwise stuck forever, so an operator who
has established that the commit is gone for good may say so: a reviewed exact-id apply carrying
--acknowledge-unrecoverable <commit> abandons it. The assertion is checked rather than trusted.
Git must confirm that it holds no such object at all, or holds it with no local branch, tag, or
remote-tracking ref pointing at it and no remote advertising it; a surviving ref, an offline remote,
or any other ambiguous observation refuses. Abandonment removes nothing from disk and touches no
ref, because the tree is already gone, and it records no recovery proof, because it has none. It is
the one reconciliation outcome with no proof behind it, and it is deliberately reachable only by an
operator naming the exact commit being given up.
When the repository itself is gone, there is no Git left to corroborate the assertion. The Git port
reports repository_absent from the filesystem alone — the recorded root does not exist, or is a
directory with no .git — because Git run there would answer for an enclosing repository instead;
the default port never reports it. Such a record is always a tombstone-missing candidate, even
while its tree exists, so a workspace dry-run neither fails on it nor hides it. It is refused as
repository-missing until the operator acknowledges the exact recorded commit, and as
worktree-path-exists while the tree path or any relocation or removal intent path still exists.
Apply records the same reconcile-abandoned tombstone and touches nothing on disk.
Durable state
Activated profiles live under $XDG_CONFIG_HOME/worktree/config.toml. The ownership registry,
leases, lifecycle records, relocation and removal intents, and cleanup evidence live in
$XDG_STATE_HOME/worktree/registry.sqlite3. Managed worktrees default to
$XDG_STATE_HOME/worktree/trees/<profile>/<repository>/<id>.
Lifecycle transitions that race sessions are decided inside SQLite: finish records the final HEAD only when no lease is live, expired-active cleanup atomically claims the tree as finished, legacy migration atomically claims it as relocating, and lease acquisition succeeds only while a record remains active. Removal completion writes lifecycle and event evidence and clears its durable intent in one transaction.
Versioned surfaces
| Surface | Version | Contract |
|---|---|---|
| Non-hook CLI JSON | 3 | Stable success and error envelopes with version and ok; recovery proof carries kind and equivalent_commits. |
| Reconciliation JSON | 3 | Includes provisioning recovery, migration, external retirement, and missing-record actions, with version-3 recovery proof. |
| Inspection report | worktree.inspection/2 | Proven recovery carries kind and equivalent_commits. |
| Lifecycle hooks | 1 | Session start, heartbeat, and session end remain wire-compatible. |
| Configuration and workspace policy | 1 | Existing activated profiles remain on schema version 1. |
More columns: swipe horizontally, or focus the table and use the arrow keys.
Wire-shape changes require a new surface version. The agent skill and its interface metadata are
deterministic generator output from worktree skill, whose source of truth is the CLI generator.
Without --out it prints the skill to standard output and writes no file; --out <dir> writes
SKILL.md and agents/openai.yaml below that directory, and --check compares them. This
repository checks in no copy: the worktree agent plugin ships one.