Skip to content

The branch-to-worktree topology is derived from git identity and enforced everywhere

The branch-to-worktree topology is derived from git identity and enforced everywhere

majordomus://adr/adr-0021accepted gitworktreefilesystemagentscoordination

it references

referenced by

21. The branch-to-worktree topology is derived from git identity and enforced everywhere

Context

This repository is worked on by several sessions at once — people and agents, often four or five concurrently — and each needs its own checkout of its own branch. Git's answer is the linked worktree, and git takes the destination as an argument: it has no opinion about where a worktree should live, so every caller has one.

On the day this decision was made the repository had fifty-three registered worktrees in four shapes: thirty-two flat siblings named prismatic-majordomus-<tag> where the tag (cap, i1003, wtm) said nothing about the branch, sixteen under session scratch directories in the system temporary directory, one inside the primary checkout under .claude/worktrees/, and one occupying prismatic-majordomus-wt — the one name a container would want. Eleven of them held uncommitted work. No person and no agent could answer "where is branch X checked out" without running git worktree list and reading, nor "where does a new one go" without inventing an answer; and an agent told to "work on feature/improve-cli" picked a plausible directory, worked for an hour, and left the work where the next session would not look.

An earlier attempt at this decision (the feature/worktree-management branch) put a worktree: section into the policy — a strategy, a suffix, a naming rule that flattened feature/foo into feature-foo — and a second attempt would have added a registry mapping branches to paths. Both add a thing that has to be kept in agreement with git.

Decision

The topology is a function of git identity and the branch name, computed the same way by every surface and stored nowhere:

container(repository)        = parent(primary checkout) / basename(primary checkout) + "-wt"
worktree(repository, branch) = container(repository) / <branch name, one directory per component>

~/dev/prismatic-majordomus and feature/providers/openai-streaming therefore give ~/dev/prismatic-majordomus-wt/feature/providers/openai-streaming. The hierarchy is kept; nothing is flattened, hashed or numbered. The primary checkout hosts the trunk, which is discovered (the remote's HEAD, then init.defaultBranch, then the one conventional name, then the primary checkout's branch) and never hardcoded. Every non-trunk branch that is checked out has exactly one worktree, at that path.

Git is the registry. The identity comes from the common git directory and the main worktree git worktree list --porcelain lists first, so the answer is the same from the primary checkout and from four directories deep inside a linked worktree; the worktrees come from that list; the branches, their upstreams and their worktrees from one for-each-ref. The Rust executable interprets these into one typed RepositoryTopology — every worktree with a standing (primary, canonical, misplaced, detached, ephemeral, missing), every branch with or without a worktree, every diagnostic under a stable code with a remedy — and every surface renders that value: the command line (majordomus worktree), the four worktree.* capabilities (MCP tools and the majordomus://worktrees resource, /api/v1/worktrees*, OpenAPI, Swagger UI), the Cockpit page, the pre-commit guard and the generated reference. No file, section, table or list records a branch's path, and adding a branch requires editing nothing.

Legacy topology is input to a migration, never a second canonical form. A worktree somewhere else is a diagnostic (worktree.path_mismatch), and majordomus worktree migrate brings it home with git worktree move, uncommitted and untracked work included, under one repository-scoped lock, with a fingerprint — branch, HEAD, index, staged and unstaged diffs, untracked files with their content, ignored entries by presence — taken before and after each move and compared; a step is reported as moved only when the two are equal. A worktree occupying the container path is moved out and then in; a move across filesystems is made by copy, git worktree repair, verification against a manifest of every entry of the tree, and only then removal of the original, and only when asked. Nothing is ever reset, stashed, cleaned, checked out or deleted to satisfy the rule.

Enforcement is layered and honest. The doctrine and the project rule state it; the provider bootstraps tell every agent; majordomus worktree create is the way a branch's worktree comes into being; the pre-commit hook asks majordomus worktree guard, which refuses a commit of a feature branch from anywhere but its canonical worktree and of the primary checkout off the trunk; the topology is on every surface, so a wrong one is visible; the crate's suite proves the derivation, the safety and the migration against real git; and a CI gate holds the pieces together. Git hooks can be bypassed with --no-verify, and a CI worker cannot see a contributor's local filesystem: this decision does not claim otherwise. What it claims is that within Majordomus-controlled workflows, wandering off the canonical path is refused, and outside them it is painfully visible.

Alternatives considered

A worktree: policy section (strategy, suffix, naming). Configuration for a fact that has one right answer. Every knob is a way for two checkouts of one repository to derive two containers, and the naming rule that flattened feature/foo to feature-foo was lossy by design, which meant collisions to detect and a mapping to explain.

A registry file mapping branches to paths. The thing git already is, written down a second time, guaranteed to disagree with it within a week. Rejected outright.

.worktrees/ inside the repository. A checkout of branch B inside a checkout of branch A: every build tool, watcher, linter and search walks into it, git has to be told to ignore it, and an rm -rf of the repository takes B's uncommitted work with it.

~/.worktrees/<repo>/ or another global directory. A place to look up rather than a place to derive, that outlives the repositories it indexes and collides on basenames.

Session-scoped temporary directories. What was happening. Convenient for the agent and invisible to everyone else; sixteen of the fifty-three were exactly this, and they are now the ephemeral standing: reported, refused for commits, never migrated unasked, and gone when the session that made them is.

Flat sibling directories with a tag. The other thing that was happening; thirty-two of them, each requiring a person to remember what i1011 was.

Consequences

-wt is written once, as a constant in the Rust crate, and the derivation lives in one module; everything else — every path in every message, page and document — follows from it. The provider bootstraps, the documentation and the CI check name the suffix too, and the check holds them to the constant.

The primary checkout must stay on the trunk. Committing to a feature branch from the primary checkout is refused, which is a change of habit for anyone who used git switch -c there; the remedy is one command.

The topology is answered from git on every call and never cached across calls: it changes outside the process, and a cached answer would be an answer about a repository that no longer exists. That costs a few subprocesses per call and a git status per worktree when uncommitted work is asked for; a guard on the commit path runs one status, never one per worktree.

Detached worktrees have no canonical path and are never moved; a scratch checkout of a session (under the temporary directory or .claude/worktrees/) is reported and left to the session that made it. Bare repositories are refused by name: the topology is defined against a primary checkout.

Creation is one command and removal is nobody's job, and that asymmetry has a bill. The decision leaves cleanup as derived state — worktree cleanup names what is merged and clean, and deletes nothing — so a worktree outlives the branch that justified it until a person remembers. Measured in this repository on 2026-09-08, eighteen hours after the topology landed: six worktrees held 78 GB in Rust target/ directories and nine more, whose branches were already fully merged into the trunk, held 26 GB. The volume reached 124 MiB free of 926 GiB, and the failure did not present as a disk failure. Three subsystems reported content errors instead — rules that do not resolve, a use-case scenario that fails with its detail written to a file that could not be written, a derive that succeeds and a check that then calls its output stale — and several sessions spent hours reading them as contention, memory pressure and flaky gates.

That is the cost of the choice, not an argument against it: a tool that deleted a worktree because its branch merged would eventually delete work somebody had not finished, which is the failure this whole design refuses. But the consequence should be written down rather than discovered. Nothing in the topology reclaims anything, no budget is declared for the container, and the disk is the only thing that says stop — in a vocabulary that belongs to whatever subsystem happens to write next.

This repository dogfooded the decision on the day it was made: thirty-two sibling worktrees, eleven of them dirty, were migrated by the executable in under ten seconds with every fingerprint equal; the exceptions were the sixteen ephemeral scratch checkouts, the agent worktree inside the primary checkout, and one checkout outside every convention that was left to the operator.

Where this comes from

canonical file
.ai/repo/adrs/0021-the-branch-to-worktree-topology-is-derived-from-git-identity-and-enfor.md
identity
adr-0021
also served as
MCP resource majordomus://adr/adr-0021 · majordomus entity show adr/adr-0021 · GET /api/v1/entity?uri=majordomus://adr/adr-0021
in the Cockpit
/cockpit/objects/adr/adr-0021

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

Searches the titles and descriptions of every page this site publishes.