Skip to content

Worktrees

the branch-to-worktree topology: `<repo>-wt/<branch>` derived from git identity and never registered, the standings and diagnostic codes, the commands, the lifecycle, the layered enforcement, the fingerprint-verified migration, failure modes and recovery

Rendered from docs/WORKTREES.md — the same Markdown GitHub shows.

Where every linked git worktree of this repository belongs, how that is decided, and what the tool does about one that is somewhere else. Behaviour as implemented and tested; where this document and the executable disagree, the document is wrong and changes in the same commit. The decision is ADR 21; the rule is project.worktree-topology.

~/dev/prismatic-majordomus                    the primary checkout: hosts the trunk, never moves
~/dev/prismatic-majordomus-wt/                the container: the checkout's sibling, name + "-wt"
~/dev/prismatic-majordomus-wt/feature/x       the one worktree of branch feature/x
~/dev/prismatic-majordomus-wt/fix/a/b         the one worktree of branch fix/a/b — hierarchy kept

The branch name is the relative path. Nothing is flattened (feature-x), hashed (feature-x-a93f) or numbered (worktree-1), and nothing is registered anywhere: given the repository and a branch, there is exactly one path, and it is derived the same way by every surface.

Why

Several sessions — people and agents — work on one repository at once and each needs its own checkout. Git's linked worktree is the mechanism, and git takes the destination as an argument, so every caller decides where one goes. Fifty-three of them had decided differently here: flat siblings named after nothing, session scratch directories, one inside the primary checkout, one occupying the container's own path. The cost was not untidiness. It was that "where is branch X" and "where does a new one go" had no answer that did not involve looking, guessing or inventing, and a session that has to invent a path leaves its work where the next session does not look.

The topology gives the answer once: feature isolation with no decision, parallel agents that cannot collide on a path, a predictable session → branch → worktree mapping that a handover can name and a resumed session can navigate back to, cleanup that is a derived list rather than an archaeology, and a topology that every surface can show because every surface computes it the same way.

How the path is derived

git rev-parse --git-common-dir             which repository this is, from any directory of any worktree
git worktree list --porcelain              its worktrees; the first record is the primary checkout
parent(primary) / name(primary) + "-wt"    the container
container / branch name                    the worktree, one directory per component

The second line is the one that is easy to get wrong. git rev-parse --show-toplevel answers the current worktree, so deriving a container from it inside a linked worktree would produce …-wt/feature/x-wt, a container per worktree, nesting for ever. The primary checkout is read from git's shared metadata instead, so the answer is identical from the primary checkout, from a subdirectory of it, from a linked worktree, and from four directories deep inside one.

A branch name is filesystem-derived input and is treated as such: the name is validated by git's own reference rules (no .., no component beginning with ., none ending in .lock, no control characters, none of ~ ^ : ? * [ \, no @{, no leading -), every component of a valid name is an ordinary directory name, and the derived path is proved on every call to be strictly below the container. Unicode components are ordinary directory names. Two names that differ only by case are reported (worktree.case_collision), because one directory holds both on a case-insensitive filesystem.

The trunk is discovered, never hardcoded: the remote's HEAD (refs/remotes/origin/HEAD), then init.defaultBranch when that branch exists, then whichever of main and master exists alone, then the primary checkout's own branch. Which one decided is reported.

-wt is written once, as CONTAINER_SUFFIX in apps/majordomus-cli/src/worktree/path.rs. There is no configuration: a container that could be configured is a container two checkouts of one repository could disagree about.

The architecture

   git (common dir · worktree list · for-each-ref · status)

   RepositoryIdentity           primary, current, container, trunk — from anywhere inside

   path::expected_path()        container / branch, proved to stay inside the container

   WorktreeService              topology · status · guard · inspect · create · migrate · repair · remove
        ↙        ↓        ↘
   CLI      capability       git hook
             registry ──── MCP tools + majordomus://worktrees · /api/v1/worktrees* · OpenAPI · Swagger UI
                       ──── Cockpit /cockpit/worktrees · docs/generated

Business logic exists once, in apps/majordomus-cli/src/worktree/. Every surface is an adapter that renders or projects what the service answered; none derives a path, decides a standing or judges whether a move is safe. The four read-only questions are capabilities of the registry (worktree.topology, worktree.status, worktree.inspect, worktree.migration_plan), which is what puts them on MCP, HTTP, OpenAPI, the Swagger UI, the Cockpit and the generated reference without any of those carrying a route or a schema of their own. Creating, migrating, repairing and removing are command-line operations of the same service: a capability of the registry never writes to the repository, which is the contract the shared MCP server rests on, and the Cockpit names the exact command for each.

Standings and diagnostics

Every registered worktree has one standing:

standingmeaning
primarythe main worktree; exempt from the path rule, held to the trunk rule
canonicala linked worktree at exactly its branch's path
misplaceda linked worktree somewhere else; migration brings it home
detachedno branch, so no canonical path; never moved
ephemerala session's scratch checkout, under a declared scratch root — the temporary directory, <primary>/.claude/worktrees/ for Claude Code, bb's thread directory under its data directory — as share/providers.yaml declares them; reported, refused for commits, moved only on request
missinga registration whose directory is gone; repair drops it

and every condition has a stable code, the same on every surface, each with a remedy:

codeseveritywhat it means
worktree.path_mismatcherrora linked worktree is not at its branch's canonical path
worktree.container_occupiederrora worktree occupies the container path itself
worktree.nestedwarningit sits inside the primary checkout or another worktree
worktree.destination_conflicterrorthe canonical path is occupied by something else — a registered worktree of another branch, a foreign checkout, a directory, a file, a symbolic link
worktree.missing / worktree.stale_registrationwarningthe directory is gone
worktree.branch_already_checked_outerrorthe branch is checked out somewhere other than its canonical path
worktree.detachedinfono branch
worktree.ephemeralwarninga session's scratch checkout holding a branch
worktree.primary_on_non_trunkerrorthe primary checkout holds a branch that is not the trunk
worktree.trunk_in_linked_worktreewarningthe trunk is checked out in a linked worktree
worktree.path_escape / worktree.invalid_branch_nameerrorthe name cannot derive a path
worktree.lockedwarninggit will not move it until it is unlocked
worktree.migration_verification_failederrorthe fingerprint after a move differs from the one before
worktree.trunk_unknownwarningnothing said which branch the trunk is
worktree.case_collisionwarningtwo branch names derive one directory on a case-insensitive filesystem
worktree.cross_deviceinfoa move was made by copy across filesystems

The topology is valid when no error-level diagnostic stands. Detached and ephemeral worktrees do not make it invalid; the guard still refuses a commit from an ephemeral one.

Which directories make a checkout ephemeral is data, not a list in the executable: share/providers.yaml declares the tool's own scratch roots (the temporary directory) and each provider's — <primary>/.claude/worktrees for Claude Code, and for bb, an orchestrator that keeps a worktree per thread, ${BB_DATA_DIR:-~/.bb}/plugins/environment-git-worktree/host-data/worktrees. A root is expanded against the primary checkout and the environment; a root the primary checkout itself lives under is skipped, so a test fixture in the temporary directory has ordinary siblings. The diagnostic names the provider whose root it matched. Somebody else's checkout is never migrated unasked and never cleaned up by the tool: the remedy the guard offers is to continue in the canonical worktree (ADR 0024).

Commands

majordomus worktree                            # where am I, and is that where I belong (exit 10 if not)
majordomus worktree create feature/improve-cli # start: the path is derived, never given
cd "$(majordomus worktree path feature/improve-cli)"
majordomus worktree list                       # every worktree with its standing
majordomus worktree topology --format json     # the whole document the API and the Cockpit render
majordomus worktree doctor                     # every diagnostic with its code and remedy
majordomus worktree migrate --plan             # what would move; changes nothing
majordomus worktree migrate                    # move, verify, report
majordomus worktree cleanup                    # what is merged and clean; deletes nothing
commandwhat it doesexit 10 when
worktree / worktree statusthis worktree: branch, standing, canonical path, uncommitted work, upstream, issuethis worktree is out of place
worktree listevery worktree, one line eachthe topology has an error
worktree topologyrepository, container, trunk, worktrees, branches without a worktree, diagnostics, talliesthe topology has an error
worktree rootthe container, one path
worktree path <branch>the canonical path of a branch, one path; the branch need not existthe name is invalid
worktree inspect <branch>canonical path, whether the branch exists, what occupies the path, what stands in the way
worktree create <branch> [--base REF] [--issue ID]the canonical worktree, the branch created from the trunk when newoccupied, checked out elsewhere, invalid
worktree ensure <branch>the same, answering an existing canonical worktree instead of refusingchecked out elsewhere
worktree migrate [--plan] [--only B]… [--allow-copy] [--include-ephemeral]bring misplaced worktrees home, fingerprint-verifieda step was blocked or failed
worktree validate / worktree doctorthe errors, or every diagnostican error stands
worktree guard [--quiet]may a commit proceed from hereno
worktree repair [--dry-run]drop stale registrations, repair git's links; deletes no directory
worktree remove <branch|path> [--force]remove one linked worktree; never the primary, never a branch, never dirty work unforcedrefused
worktree cleanupbranches merged into the trunk whose worktree is clean or absent, with the commands that would remove them
worktree branches [--without-worktree]every local branch, one per line

wt is an alias for worktree. --format json is available everywhere and is the same typed answer the human form renders. A selector for remove is exact — a branch name or a path — and nothing is matched by prefix or similarity. There is no worktree cd: a child process cannot change its parent shell's directory, so path prints one and the shell does the rest.

worktree create --issue I0042 names the branch feature/I0042-<slug> from the issue's own record, and the topology reads the issue back from any branch that carries an issue id as a path component (feature/I0042-live-pageI0042). Nothing is inferred from similarity: feature/I00420-x names no issue.

Shell completion of the branch arguments reads the live set rather than a list:

# zsh
_mj_wt_branches() { compadd -- ${(f)"$(majordomus worktree branches 2>/dev/null)"} }
compdef '_arguments "1:sub:(status list topology root path inspect create ensure migrate validate doctor guard repair remove cleanup branches)" "2:branch:_mj_wt_branches"' majordomus-worktree

Lifecycle

issue / task                majordomus plan next

branch                      feature/<ID>-<slug>, or any name git accepts

canonical worktree          majordomus worktree create <branch>   → <repo>-wt/<branch>

session / agent             majordomus context, start, checkpoint, handover — in that worktree

commit / PR                 the pre-commit hook asks the guard; push and open the PR from there

cleanup                     majordomus worktree cleanup → worktree remove, git branch -d, by a person

A handover records the branch and the worktree; a session resumed elsewhere derives the worktree from the branch rather than trusting the recorded path, because the path is ephemeral and the branch is not.

The .envrc of a new path

direnv approves an .envrc by path and content, so the repository's own .envrc at a path that did not exist a moment ago is a file direnv has never seen, and the first cd into a fresh worktree was answered with direnv: error .envrc is blocked. Measured on 2026-09-09: 36 of the 37 worktrees that carried an .envrc were blocked, and the one approved was the primary checkout. create, ensure and every moved step of migrate therefore carry the primary checkout's approval to the new path — direnv allow on the worktree's .envrc, at the moment the path exists — and report what happened on an envrc line and in the envrc field of the JSON.

Carried, not granted: the worktree's .envrc must be byte-for-byte the primary checkout's, and the primary checkout's must itself be approved, or nothing is approved and the report says which (differs, not_approved_in_primary). A branch with an .envrc of its own is a file the person has not read, and approving it for them is the one thing direnv allow exists to prevent. Without direnv on the PATH the outcome is direnv_absent and nothing is blocked, because nothing would load the file.

The last step is the one with no mechanism behind it. Creating a worktree is one command; removing one is a decision nobody is prompted to make, and cleanup deliberately deletes nothing — it names what is merged and clean and leaves the act to a person, because a tool that removed a worktree on the strength of a merged branch would eventually remove work somebody had not finished. The cost is real and was paid here on 2026-09-08, eighteen hours after the topology landed: fifteen worktrees between them held 104 GB of Rust target/ directories, nine of those with branches already merged into the trunk, and the volume reached 124 MiB free. Nothing in the topology reclaims anything and no budget is declared for the container, so the disk is what says stop — and it says it in whatever vocabulary the next subsystem to fail happens to use. Three said their content was wrong. See ADR 0021 for why the mechanism is absent rather than missing.

If you hold a worktree whose branch has landed, worktree cleanup tells you; git worktree remove <path> and git branch -d <branch> are the two commands, and the target/ directory inside it is usually most of what it costs.

Enforcement

Layered, and honest about what each layer can do:

layerwhat it does
the doctrineADR 21 and project.worktree-topology say what the rule is
the agent bootstrapsAGENTS.md, CLAUDE.md and the other provider files, generated from the templates, tell every worker to establish the topology before implementing and to correct a mismatch with the tool
the workflows.ai/repo/workflows/task-lifecycle.md starts with worktree status
the command lineworktree create is the way a branch's worktree comes into being; a path is never an argument
the git hook.githooks/pre-commit asks majordomus worktree guard; a feature branch is committed only from its canonical worktree, the primary checkout only on the trunk
the wiring checkthe policy's enforcement list declares the guard, so majordomus doctor proves the hook asks it
the repository entry.envrc renders the repository environment on entry through bin/majordomus-env, which never builds (ENVIRONMENT.md); the worktree standing in that banner is a target
the surfacesthe topology is on the command line, MCP, HTTP, the Swagger UI and the Cockpit, so a wrong one is visible everywhere
the teststhe crate's suite proves the derivation, the safety and the migration against real git; the shell case proves the wiring
the CI gatescripts/ci/worktree-check holds the constant, the hook, the policy, the documents and the case together

A git hook is bypassed with --no-verify, and a CI runner cannot see a contributor's local filesystem; the gate therefore measures the rule's machinery, not this machine's directories. Within Majordomus-controlled workflows a wrong worktree is refused; outside them it is visible on every surface.

Migration

worktree migrate --plan lists every misplaced worktree with a branch: where it is, where it belongs, how it would move, the uncommitted work that moves with it, and what blocks it. The container's occupant, if any, is first — every other destination is inside the container, and while a worktree is the container those destinations would be created inside that worktree. Exceptions — detached worktrees, stale registrations, ephemeral scratch checkouts, the primary checkout off the trunk — are listed with what a person does about each.

worktree migrate recomputes the plan under the repository lock and, for each movable step:

  1. takes a fingerprint of the worktree: branch, HEAD, the index (every tracked path with mode, blob and stage), the staged diff, the unstaged diff, every untracked file with its size and content hash, every ignored entry by path and size;
  2. asks git to move it — git worktree move, one rename, dirty state included; the container's occupant goes out to a staging path and then in; a move across filesystems is refused unless --allow-copy, and then made by copy, git worktree repair, verification against a manifest of every entry of the tree, and only then removal of the original;
  3. takes the fingerprint again at the destination, verifies that git registers it there, and reports the step as moved only when the two fingerprints are equal — otherwise failed, with what differs, and nothing further is touched;
  4. names relative symbolic links that no longer resolve from the new path (node_modules -> ../sibling/node_modules), which the move did not change and does not rewrite.

Nothing is reset, stashed, cleaned, checked out or deleted. A locked worktree is blocked until it is unlocked. A destination that exists — a registered worktree of another branch, a foreign git checkout, a directory empty or not, a file, a link — is a conflict named by kind and never overwritten. If the command was run from a worktree that moved, the report says where to cd.

Failure modes and recovery

situationwhat you seewhat to do
a dirty worktree in the wrong placeworktree.path_mismatch, the dirty counts on the plan stepmajordomus worktree migrate; the work moves with it
the canonical path is takenworktree.destination_conflict naming what is theremove it aside by hand; nothing overwrites it
a detached worktreeworktree.detached, standing detachedgit switch -c <branch> there, then migrate; or leave it
a missing directoryworktree.missing / worktree.stale_registrationmajordomus worktree repair
a locked worktreeworktree.locked, the step blockedgit worktree unlock <path>, then migrate
the wrong branch in a worktreeworktree.branch_already_checked_out on createmigrate the worktree that holds it, or work there
direnv: error .envrc is blocked in a worktreeenvrc differs … or envrc the primary checkout's is not approved … on the create or migrate reportread the file, then direnv allow there; approve the primary checkout's first when that is what the report names. A worktree older than the report: majordomus worktree ensure <branch> carries the approval now
the primary checkout on a feature branchworktree.primary_on_non_trunk; the guard refuseswhen clean, git switch <trunk>; then worktree create <branch>
a scratch checkout of a session on a branchworktree.ephemeral; the guard refusesgit switch --detach there and continue in the canonical worktree, or migrate --include-ephemeral --only <branch>
a move that crossed devicesworktree.cross_device on the step, or a refusalmigrate --allow-copy
a fingerprint mismatchworktree.migration_verification_failed, the differences listedinspect the worktree where git registers it before touching anything; a rename cannot lose content, so something wrote to it during the move
a built executable that was there a minute agoMAJORDOMUS_BIN is not an executable, or a test failing on a branch that was greenan environment event, not a defect in the branch under test: another worker reclaimed the build directory. Rebuild in your own worktree and re-measure. project.reclaim-only-what-you-own says whose it was
a borrowed executable from another worktreeartifacts reported stale on a tree that is current; a derivation that removes thousands of linesestablish it matches before using it — majordomus generate --check must say in sync — and read git diff --stat -- docs/generated site/data before committing. project.a-tool-that-cannot-match-the-tree-refuses

Performance

worktree status and worktree guard read the registrations, the branches and one git status of the current worktree: a handful of subprocesses, no index build, no network, no scan. topology and list add one git status per existing worktree and nothing else. The migration hashes the content of every modified and untracked file of the worktrees it moves, which is the one place that cost is right. Nothing is cached across calls: the topology changes outside the process.

Where things are

the constant and the derivationapps/majordomus-cli/src/worktree/path.rs
identity and trunkapps/majordomus-cli/src/worktree/identity.rs
the typed topologyapps/majordomus-cli/src/worktree/model.rs
the serviceapps/majordomus-cli/src/worktree/service.rs
the migration and fingerprintsapps/majordomus-cli/src/worktree/migrate.rs, fingerprint.rs
the capabilitiesapps/majordomus-cli/src/capability/builtin/worktree.rs
the command lineapps/majordomus-cli/src/commands/worktree.rs, declared in src/cli.rs
the Cockpit page/cockpit/worktrees, apps/majordomus-cli/src/cockpit/pages.rs
the launcher the hook usesbin/majordomus-cli; .envrc goes through bin/majordomus-env, the launcher that never builds, and both find the executable through lib/rust_bin.sh
the hook.githooks/pre-commit; the entry in .ai/repo/policy.yaml enforcement
the gatescripts/ci/worktree-check, gate worktree-topology in .ai/repo/ci/gates.yaml
the testsapps/majordomus-cli/tests/worktree.rs, test/cases/96_worktree_topology.sh
the decision and the ruleADR 21, .ai/repo/rules/project/worktree-topology.v1.md

Claims this document defines

Each links to its own page with implementation, test and where it is used.