Skip to main content
New and experimental. File bugs and suggestions to aspect-build/aspect-cli/issues.
Create a git worktree in a pooled slot and check out <branch> in it. The slot is a path the pool reuses, so Bazel’s server, analysis cache and external/ tree are often already there.
Which branch you get follows git worktree add’s rules, so what you know about that command still holds; the comparison below covers its flags one by one. Entering it in one step:
--output=path writes only the directory to stdout, which is what $(...) captures. The report below still goes to stderr, so it stays on the terminal, minus the ways to enter the slot, since that is what the one-liner is doing.

What it reports

A created branch tracks nothing until you say what. git push -u makes the branch of the same name on the remote and tracks it; git branch --set-upstream-to=<branch> tracks an existing branch, and the one it started from is suggested when it is a branch, local or remote — not a tag or a commit, which git will not track. When that is a local branch with an upstream of its own, tracking that upstream is offered too, and one with no upstream is said to have none. An existing branch that tracks nothing gets the same two commands, with <branch> left for you to fill in. The created … used line is the slot’s record rather than this worktree’s: how long it has existed, and how many worktrees it has held before yours. A brand-new slot says new slot: this is the first worktree it has held instead. It is the pool’s claim stated as a number — nine reuses is nine cold starts not paid for — and aspect worktree list --verbose breaks it down into which branch and which agent session had the slot each time.

Which slot you get

add scores each free slot your clone owns on how much of its Bazel state a build of the branch would reuse, best first: Any usable free slot beats creating one, however it scores: a bad reuse pays the same external/ rebuild a new slot would, and skips a new server and one more output base on disk. So the pool grows only when every slot is in use. A slot is unusable while it still holds uncommitted work or a checkout git cannot read, or when a score_slot hook returns 0 or less for it. A repo can replace the scoring with a score_slot hook on the Worktrees trait; the guide has an example and the fields a hook is given. The warmth line is read from disk, not assumed:

Branches

<branch> is resolved as git worktree add <path> <branch> resolves it: --create makes the branch, as git’s -b does, and refuses a name that already exists here or on a remote. Bare, it starts from the HEAD of the directory you run in; --create=<ref> starts from that ref. For fresh work, fetch and start from the remote’s main:
A created branch has no upstream, deliberately, and add says so. Cut from origin/main with git’s default tracking, it would record origin/main as its upstream, and under push.default=upstream a bare git push would land there. The first push names the branch instead:
A branch that already tracks one of a different name gets an upstream_elsewhere warning for the same reason.

Detached

--detach checks out any commit-ish — a tag, a SHA, origin/main — on no branch, for building or reading something without working on it. Git allows any number of worktrees at one commit, so this never conflicts with a branch checked out elsewhere.
With no branch to be named by, the slot is addressed by its id — aspect worktree path 1135398e6688, aspect worktree release 1135398e6688 — or by the ref it was taken at, while no other slot is at it. Listings show it as (detached at origin/main), as git words a detached HEAD. A commit only its HEAD reaches is refused by release rather than dropped. Those commits are found in the slot’s HEAD reflog. It is read through git, so a reftable repository is covered; on an unborn branch, where git will not read it, the files backend’s logs/HEAD is read directly; and a reflog that exists but cannot be read counts as work, with the count unknown. Every slot is checked out with core.logAllRefUpdates=always so that the reflog exists whatever the repository sets. A commit that any ref outside the per-worktree namespaces (refs/worktree/, refs/bisect/, refs/rewritten/) reaches does not count, and neither does one another worktree’s HEAD is at. A commit git cannot count counts as work. The refusal names the fewest tips whose branches keep them all (git merge-base --independent), the first 20 in the message and every one in tips. In a list of work these are the DH lines.
A branch can only be checked out in one worktree at a time. Asking for one that is already in use names where it is, before anything is changed. What it says depends on who is holding it. Your own session is pointed back at the slot it already has:
Another session gets named, and is told the slot by its id, not its path: the checkout is the holder’s to work in. A running one is left to whoever drives it, with no command to paste:
A subagent asking for a sibling’s branch is told the same way:
The same applies to worktrees the pool does not manage, including your main checkout:

Compared with git worktree add

add takes a branch where git takes a path, since the path is the pool’s to choose. The rest maps flag for flag, and where it differs, the difference is the point: --take-over and the agent flags have no git counterpart: they are about the lease, which git does not have.

Who took it

The lease records the agent session that took the worktree, so aspect worktree list can name it later. Nothing needs configuring under a harness the CLI recognizes — Claude Code exports a session id, and otherwise the process tree is walked until a known harness turns up. For anything else, set the vendor-neutral variables once in a wrapper:
or pass them per call:
Flags beat the environment, field by field, and both beat detection — see agent detection. A harness that reports nothing is simply unlabelled. A slot whose holder is no longer running, has been given a day to come back, and whose checkout is clean is taken back rather than the pool growing a new slot: its checkout is removed and yours is checked out fresh in the same slot. The day — Worktrees.abandoned_grace_hours, 24 by default — counts from the slot’s last activity: the lease being taken, Bazel using its output base, or a git operation in the checkout. Until then the refusal for its branch says how long it is kept, and release of it by anyone else takes --force=all. A resumed session — claude --resume, say — runs in a new process the lease does not record; when it runs add of a branch that is already leased, path for a slot it may enter, or inspect anywhere in the clone or its slots, every lease of that session, its subagents’ included, whose recorded process is no longer running moves to the caller’s live process, so the session reads as running and keeps its slots. Leases are matched by the session id the harness reported, never by an id a caller chose, so a session whose harness reports none has no leases moved. A lease with no session id, or whose process was never captured, is never moved. See agent detection. 0 takes such a slot back at once; a negative value is refused with invalid_config, so a stray minus cannot switch the protection off. A clean checkout is the condition that never bends: commits survive git worktree remove because they are in the branch, so a clean tree has nothing in it to lose. Files git ignores do not count: what a repository ignores is by its own account disposable. Clean does mean nothing git status leaves out: a populated submodule, whose repository lives inside the worktree; edits hidden by --assume-unchanged or --skip-worktree; refs only that worktree has; a merge, rebase, cherry-pick, revert or bisect in progress, a multi-commit cherry-pick or revert waiting between picks included; and a worktree git lists inside that one (NW), under an ignored .claude/worktrees/ say, which removing it would delete. A slot whose checkout is gone from its directory — moved with mv, deleted, or the directory emptied — keeps its lease and its git registration while git still keeps commits only its HEAD reflog reaches, refs only it has, an operation in progress or a staged index for it (VG): it is not taken back, and --take-over refuses it. Another clone’s leases are kept while that clone’s git says it holds work for them. Repository settings cannot narrow the check — status.showUntrackedFiles=no and submodule.<name>.ignore are overridden, and an exported GIT_DIR is ignored. When in doubt the slot is kept, and the refusal for its branch names what kept it.
That is add of the branch the stopped session held. When another branch’s add reclaims the slot instead, the line reads INFO: reclaimed the slot held by fix/login: its claude-code session … is not running and the checkout was clean. Either way the JSON carries a lease_reclaimed warning naming the previous holder, so a caller reading it knows the lease it now holds was somebody else’s. A warm slot already shaped for the work beats a cold new one, so this is preferred to expanding the pool — an agent restarting the task it died in lands back on the analysis cache built for it. If that checkout is dirty, there is something to lose and add refuses instead, naming the holder and both ways forward:
Resuming is offered first because a session that has stopped is usually a closed terminal, with work in it waiting to be picked up rather than finished with. --take-over re-assigns the lease and leaves the checkout exactly as it is. The slot already holds that branch, so nothing is removed or re-checked-out: whatever the previous session had staged or untracked is still there, and now belongs to you.
Without the flag, the refusal says the session is running and to ask whoever drives it. --take-over does not refuse a running session, and its warning does not repeat that, so it is yours to use carefully. It does refuse a checkout whose directory is gone while git still keeps work for it (VG), with worktree_dirty, as release does: the message says the slot “holds a checkout gone from its directory, with work git still keeps for it”, lists that work, and says git worktree repair <its new path> brings a moved one back, and that aspect worktree release <branch> --force=all discards it. One with nothing kept is freed and allocated as usual.

Pool housekeeping

Three things happen on the way through, so that the pool stays bounded without a separate cleanup habit. Stale slots are dropped. Free slots nothing has touched in 30 days are deleted along with their output bases, one line each:
A slot someone is working in is never touched, nor is one a live Bazel server still holds, nor one whose checkout still holds uncommitted work or that git cannot read (UR), nor one whose directory is gone while git still keeps work for it (VG). A repository of its own at a slot’s path — a .git directory, not the .git file a linked worktree has — counts as one git cannot read, since nothing here can vouch for its branches and history; so do a plain file at the slot’s path, and a checkout whose git reads another directory as its work tree (core.worktree set in the worktree’s own config). A free slot in either state is also skipped when choosing where your worktree goes, with a warning naming it. Run aspect worktree prune to do this on demand or at a tighter threshold. Several agents can add at once. The pool lock is held while a slot is chosen and recorded, not for the checkout — though choosing includes aging out any free slot unused for 30 days, which deletes its output base and can take a while the first time it happens — so adds that start together run their git worktree add in parallel rather than taking turns. release and prune do hold it while they remove a checkout, which on a large repository takes seconds, so many releases at once queue behind each other. pool_busy arrives after about a minute of waiting, and is worth one retry. The lock is staged with its owner record and renamed into place, so it is never seen without an owner. A lock whose holder has exited is reclaimed by the next call, with a warning; one with no owner record — made by hand, say — is reclaimed once it is 10 seconds old. A lock moved aside while its holder still runs is put back before anyone takes the lock — unless it is the caller’s own, which is dropped rather than restored — and staged or moved-aside lock directories whose owner has exited are removed. The pool’s limit is advisory. Worktrees.max_slots (8 by default) is a disk budget, not a concurrency limit, so at the limit with everything in use you still get a worktree and a warning. A slot is free only once its release has finished removing the checkout, so adds that run while releases are still in progress can grow the pool by a slot or two, which later adds then reuse:
Free slots that could not be reused — holding work, or a directory that would not go, each warned about above it — are counted apart from the slots in use:
The limit, and the other Worktrees settings, are set in .aspect/config.axl, which has to load the trait first:
.aspect/config.axl
Refusing would send you back to git worktree add, which costs a cold build and strands an output base. That is worse than using the disk. The warning names the way back under the budget: release what you have finished with, then prune deletes free slots left idle. It offers no ready-to-paste --force, because deleting slots other sessions rely on is the user’s decision, not an agent’s. prune on its own is not enough at that moment anyway — it only ever deletes free slots holding no work, and the warning fires when there are none it could reuse — which is why release comes first.

Flags

string
required
Branch to check out: one this clone has, or one only a remote has, which is checked out tracking it. A name neither has is refused; --create makes it. With --detach, any commit-ish.
string
default:"off"
Create the branch, refusing if one by that name already exists here or on a remote. Bare, it starts from the HEAD of the directory you run from — so running this inside one worktree branches from whatever that worktree has checked out, not from your main clone’s branch. --create=<ref> starts from that ref; --create=origin/main for fresh work.The new branch has no upstream. The exception is --create=<remote>/<branch> for a branch several remotes have, which picks that remote’s and tracks it.
boolean
default:"false"
Check out <branch> — or any commit, tag or remote branch — at a detached HEAD, on no branch. The slot is then addressed by its id. Refused alongside --create or --take-over, which are both about a branch.
string
default:""
Name of the agent harness taking this worktree, recorded on the lease. Detected automatically for recognized harnesses, or set ASPECT_AGENT_KIND.
string
default:""
Session identifier of the agent taking this worktree, so a slot can be traced back to the session holding it. Detected automatically where the harness exports one, or set ASPECT_AGENT_ID.
boolean
default:"false"
Claim a slot this pool already leases for the branch, re-assigning it to this session. The checkout is left exactly as it is, so anything the previous session had uncommitted is still there. Refused without this flag, because the other session may only be suspended and resumable.
text | json | path
default:"text"
text writes a human-readable report to stderr and leaves stdout empty. json writes one document to stdout. path writes only the worktree directory, so a shell can step into it.

JSON output

branch is empty for a --detach checkout, and detached names the commit-ish it is at instead. created says whether this call made the branch, base what it was made from as you named it, and base_sha the commit that was — which a later fetch does not move; upstream is empty for a branch that tracks nothing, which is how a created one starts. warnings, each with a code and a message: reused_from is the branch the slot last held, empty for a new slot. score is the reuse heuristic’s own number and is advisory — a repo that installs a score_slot hook decides what it means. server_pid is 0 when no server is holding the slot. agent is the session the lease was recorded for, with source naming which rung of the detection ladder answered — flag, env or ancestry. lease_count includes the lease just handed over, so a brand-new slot reports 1; the text output subtracts it and talks about prior use instead. output_base is where Bazel will put its state for this slot, derived rather than configured, which is what makes it the same directory every time this slot is used.

Refusals

Every refusal honours --output=json and carries a stable error token, so a harness branches on the token rather than on prose. These are the ones every command that changes the pool can raise: And these come from add in particular: The other commands raise these: Worth retrying: pool_busy and branch_pending, which exit 75; slot_setting_up, which exits 1 with retryable: true and clears once the add setting the slot up finishes; reservation_lost, which exits 1 with retryable: true; and slot_warm, whose own first suggestion is to wait for Bazel’s idle timeout. Everything else is a decision to make rather than a wait. Two failure shapes are not refusals at all. A bad flag or a missing argument is rejected by the argument parser before the task runs, so it exits 2 and --output=json has nothing to write — a leading-dash branch name lands here, since the CLI reads it as a flag. And an error token always arrives on stdout as a document under --output=json, never only as prose.
message is escaped as text output is: a character a terminal would act on or hide is written as \uXXXX, or \UXXXXXXXX above U+FFFF, with line breaks and tabs kept. The facts beside it keep their raw values. 75 is EX_TEMPFAIL. Retry when the JSON says retryable: true — exit 75, slot_setting_up and reservation_lost — and treat any other non-zero exit as a real error. slot_warm is retryable too, but clears only when Bazel’s idle timeout fires, which can be hours: report it rather than wait. A refusal is the pool declining a request it understood. An unexpected failure — git itself erroring, a workspace the pool cannot read — is reported as a plain message and a non-zero exit with no document, because there is no contract to offer for it.
Check the exit status. cd "$(aspect worktree add …)" reports success when add fails, because the command substitution is empty and cd "" is a no-op that returns 0 — so a script carries straight on in whatever directory it was already in. Use slot=$(aspect worktree add … --output=path) && cd "$slot", which propagates the failure.