Skip to main content
New and experimental. File bugs and suggestions to aspect-build/aspect-cli/issues.
Git worktrees are the obvious way to work on two things at once, and on a Bazel repo they are expensive in a way that is easy to miss. This guide covers why, and how aspect worktree makes the second worktree as fast as the first.

The cost nobody budgets for

Bazel keys its server on the workspace directory. A worktree at a new path is a workspace Bazel has never seen, so you get:
  • a new server process, and a new JVM to start it
  • loading and analysis from zero, with no analysis cache to reuse
  • external/ fetched and materialized again
  • an output base that stays on disk after the worktree is gone
On a large repo that is minutes before the first action runs. Do it per branch and it is annoying; do it per agent task and it dominates.
And when you delete that worktree, its output base stays behind. Bazel never collects one, so it sits there until something removes it. aspect gc is what collects them. A machine running agents accumulates one per task, which is the cost a pool avoids rather than defers.

Stop letting the path move

Bazel is worth making faster, and it keeps getting faster. But building an output base from nothing has a floor: the server has to start, loading and analysis have to run, and external/ has to be materialized. Getting under that floor means not handing Bazel a new path in the first place. aspect worktree keeps a pool of slots: directories at fixed paths that hold a worktree for a while, then hold a different one.
Nothing inside the slot has to be an Aspect command. Bazel derives its output base from the workspace directory, so the warmth belongs to the path: bazel, aspect, your IDE and any script in that directory all land on the same state. The pool’s job is finished once it has handed you a path.

One pool per repository, one slot owner per clone

A pool belongs to a repository, keyed on its normalized origin remote and living at ~/.aspect/worktrees/<host>/<org>/<repo>; a fork, or a mirror on another host, gets its own. Two consequences follow, and both are visible in aspect worktree list. Slots are never shared across repositories, because a slot is worth having only for the repository it was built for. Its value is the state behind the path: an output base holding an external/ tree resolved from this repo’s MODULE.bazel.lock and an action cache keyed on this repo’s inputs, and a server holding analysis of this repo’s graph. Handing a monorepo slot to a different project would throw all of it away on the first build and gain nothing, so the pool does not try. Within one repository’s pool, each clone owns its own slots. git worktree add can only create a worktree from the object store it is run in, so a slot made by one clone cannot be leased by another — list shows another clone’s slots and marks them, rather than offering them. Ownership is recorded as the clone’s path, so a clone that is moved, renamed or deleted leaves slots recorded for a path that is gone — tagged clone moved in aspect worktree list until a clone of the repository takes them back. The next add, release, path or prune in any clone of it does: free slots, warm bases and all, go to that clone, and leased ones go to the clone whose git still lists them — the moved clone itself — with git worktree repair pointing their checkouts at its new path. A leased slot whose clone was deleted outright, and that no clone’s git lists, is stranded: aspect worktree prune <slot> --force=all deletes it, from outside any clone too by the slot id aspect worktree list --all shows. When both a slot and its clone have moved, recovering the lease takes git worktree repair run by hand. A slot moved away on its own whose git records hold none of the commits only its HEAD reflog reaches, refs only it has, an operation in progress or a staged index has its git registration dropped, so the moved copy’s git link breaks; its files are kept. A clone moved away and another cloned at its old path splits the slots. The free ones are recorded for that path and hold no checkout, so the new clone uses them as its own: free slots are anybody’s. The leased ones stay the moved clone’s, because only its git lists their checkouts; the new clone refuses them with slot_stranded, and the next command run from the moved clone takes them back. If you work in two clones of the same repo — one for review, one for your own branches — expect each to warm its own slots. That is not waste: they would otherwise be fighting over the same branches, and git would refuse half the checkouts. Because the path does not change, the second branch through a slot reuses the first one’s output base: the external/ tree and the action cache, both of which Bazel writes to disk under the base. If that server is still running and the flags match, it also finds a warm analysis cache, the one thing here that is not on disk: Bazel holds analysis in the server’s memory, so it goes when the server does. That is what makes time to first action short. Two conditions, then, and both can fail without anything being wrong. Bazel shuts a server down once it has been idle past --max_idle_secs, so a slot that was hot this morning is warm after lunch — the output base is still there, the analysis cache is not. And a configuration change discards analysis even on a live server. Which of the three matters most depends on the repo anyway: a large external/ tree or a deep action cache can save more wall time than loading and analysis do. aspect worktree list reports what a slot actually has rather than what it had, which is why it reads the server’s pid instead of trusting a record of one.
No symlinks, no injected flags. git worktree list reports the slot path, so everything you know about git worktrees still applies. add resolves a branch as git worktree add does — an existing branch is checked out, --create makes one, --detach takes any commit — and release refuses what git worktree remove refuses, and more. How each maps to git’s flags, and which of them are left out on purpose: add, release.

Giving it to agents

This is the case the pool was built for. An agent that creates a worktree per task, on a repo where a cold build is minutes, spends most of its time waiting on Bazel. Several agents can do this at once. The pool lock covers choosing a slot, not checking it out, so adds that start together are not queued behind each other’s git worktree add. The whole contract is two commands:
Use the two-step form rather than cd "$(…)", which returns 0 even when add fails. The substitution is empty and cd "" is a no-op, so the agent carries on in the main clone. Agent skill: pooled worktrees is written to hand straight to an agent, and explains the three ways to give it to one. Without something like it an agent will keep reaching for git worktree add. Four properties make this safe to automate: A killed session’s work is kept, and you can take it back. A harness that was killed leaves its session on disk, so claude --resume finds its branch and commits, and a checkout with uncommitted work in it is never taken from under it. A different session asking for that branch is told who holds it and offered aspect worktree add <branch> --take-over, which re-assigns the lease and leaves the checkout untouched, uncommitted work included. Crashing cannot corrupt anything. Git is the authority on whether a worktree exists, so a slot whose worktree git has forgotten is free regardless of what the pool recorded. Skipping release is never an error. And a leased slot holds git’s own worktree lock, so an agent that reaches for git worktree remove is refused and told which slot to release. It can hold a slot, though. An agent that dies with its checkout still on disk leaves a worktree git still knows about, and elapsed time will not reclaim that — a leased slot is never taken on age, because closing a terminal looks exactly like crashing and claude --resume would put the agent back in that slot expecting that branch. The pool takes a lease back only when the holder’s process is verifiably gone, Worktrees.abandoned_grace_hours (24 by default) have passed since the slot’s last activity, and the checkout is clean, so nothing uncommitted is lost — and the session stays resumable, since list still prints the way back into it. Collisions are explicit. A branch can only be checked out once. Asking for one already in use says where it is rather than failing obscurely:
Choosing wrong is cheap. The pool picks the free slot whose Bazel state best fits the branch — an identical tree, then the slot that last built that branch — weighting a matching MODULE.bazel.lock far above everything else, because a mismatch is what forces external/ to rebuild. A bad pick costs a colder build and nothing more, which is why you can override the heuristic without risk. Contention is distinguishable from failure. Two agents allocating at once is ordinary, and the one that loses the race exits 75 (EX_TEMPFAIL) rather than 1, with --output=json saying so:
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, from prune, is retryable too but clears only when Bazel’s idle timeout fires, which can be hours: report it rather than wait.

Knowing which agent has what

Leases record who took them, so a machine running several agents at once lists as something you can act on rather than a column of hashes:
The session id is printed whole, so it goes straight to the harness. A holder whose process has exited but whose session the harness still has is called out under the table with the command that resumes it — almost always a closed terminal, with work in it waiting to be picked up. A session the harness no longer has, like spike/perf’s, reads session gone and gets no command, since one that resolves nothing is worse than none. --verbose lists the command for every session a slot remembers, not just the current holder’s. A harness that marks its presence without naming a session leaves the session cell blank; see agent detection for what each one reports. Nothing has to be configured for this: agent detection lists what is read, most explicit first. Flags and the vendor-neutral variables are authoritative; the rest is inference, and --output=json reports which layer answered in agent.source. A harness nobody has taught the CLI about reports nothing rather than something wrong.

Running, resumable, gone

These are different things, and none of them alone licenses taking a slot back — a stopped process with a clean checkout does: A slot whose holder is no longer running is reclaimed when an add needs a slot and none is free, or when its own branch is asked for again — once Worktrees.abandoned_grace_hours (24 by default) have passed since the slot’s last activity, and only if the checkout is clean. That covers resumable as well as gone: a warm slot shaped for the work beats a cold new one, so reclaiming is preferred to growing the pool, and the session stays resumable either way — list still prints the way back into it. The clean checkout is the condition that never bends. Commits survive, because they are in the branch, so a clean tree loses nothing; uncommitted changes are never discarded on a timer or a heuristic, and a dirty slot is refused with both ways forward instead.

A day of work

Start a task:
Something more urgent lands. Leave it and take another slot:
See where you are:
Two live servers, one slot free with its output base still there. Hand the finished one back:
The worktree goes, the slot stays warm, and the next add is likely to land in it.

Tuning which slot you get

The default scoring suits most repos. A repo that knows its own build can replace it in .aspect/config.axl:
A hook is fn(ctx, slot, want) -> int, called once for each free slot this clone owns that holds no work. It runs while the pool lock is held, so keep it to arithmetic on these fields: a slow hook makes every other add on the machine wait. Hooks get 2 seconds across all the slots of one add, checked between calls: one long call runs to its end, and once the total is past the budget no further call is made. The built-in then ranks every slot — however many the hook got through, so the same hook gets the same treatment in a pool of any size — and add warns with score_hook_failed. ctx is the task’s full context, so a hook can make network calls, run processes or write to stdout — it should not: stdout is where add --output=json and --output=path write their answer, and anything a hook prints there corrupts it. Those are all a hook is shown: each call gets its own copy, so a hook can neither read other sessions’ records nor change what the next call sees. The highest score wins, with scores clamped to ±1,000,000,000. 0 or less refuses the slot, and when every free slot is refused add makes a new one — except once the pool is at max_slots, where it reuses the warmest refused slot rather than grow without bound, and warns with score_hook_refused_all. Only the first hook registered runs, so a developer’s ~/.aspect/config.axl and the repository’s do not combine: the repository’s config.axl runs first, so its hook wins, and a hook in ~/.aspect/config.axl takes effect only where the repository registers none. A hook that fails, or returns anything but an int, does not fail the add — allocation only ever costs build time, so the built-in scoring ranks every slot for that add, never a mix of the two, and add warns once with score_hook_failed, naming the first error. The error text is shown escaped, so a control or bidirectional character in it is printed as \u202e rather than acted on by the terminal.

Keeping the disk in check

A pool trades disk for time: every slot holds an output base, and a slot that has built a large repo holds a lot of it. Two things keep that bounded. One command for every pool. aspect worktree list --all covers every pool on this machine rather than this repository’s, each under its own heading, and its sessions section names the repository of every lease — so “which agents are running, in what project, on what repo” is one question with one answer. It works outside a repository too. And aspect worktree inspect reports one slot alone — named, or from inside a slot the one you are in, which is what an agent wanting to know where it is should run. The pool stops growing on its own. A usable free slot is always reused, however badly it scores (unless a score_slot hook returns 0 or less), so new slots appear only when every slot is already in use. The pool therefore settles at your peak concurrency rather than climbing with every branch you touch. Worktrees.max_slots (8 by default; below 1 is refused with invalid_config) is a disk budget on top of that, and it is advisory — at the limit with everything in use, add warns and hands you one anyway, because failing would just send you back to git worktree add. Like the hook, it is set in .aspect/config.axl, which loads the trait first:
.aspect/config.axl
That warning says how far over the budget the pool now is and how to get back under it: release what you have finished with, so later adds reuse those slots instead of growing, and aspect worktree prune then deletes free slots left idle. In that order, because prune only ever deletes free slots — at the limit with everything in use there are none. Deleting is left to you: the warning offers no --force, since an agent pasting one would delete slots other sessions rely on. Age reclaims the rest. aspect worktree add quietly drops free slots nothing has touched in 30 days, so ordinary use keeps the pool trimmed. aspect gc independently ages out a pooled slot’s output base on the same schedule, which leaves the slot in place to warm up again. To retire slots now, or at a different threshold:
Pruning deletes the slot, its worktree and its Bazel output base together. Three things it will never do: delete a slot someone is working in, however long it has been idle; delete a checkout holding uncommitted work, unless you name the slot with --force=all; and unlink an output base from under a running Bazel server — such a slot is reported and kept, since it is also the warmest thing in the pool. aspect worktree list shows the same ages the decision uses, next to how much use each slot has had:
Only free slots carry a last used column, because only a free slot is something prune can act on. The leases column is how you tell a pool that is working from one that is merely large: a slot on its ninth lease has saved eight cold starts, and a pool where every slot reads 1 has grown to your peak concurrency without turning over yet. --verbose breaks each one down into which branch and which agent session held the slot, for how long, and the command that returns to that session. A free slot of a clone you moved or deleted is taken over by the next command in another clone, and then ages like any other, and a clone made at a moved clone’s old path has its free slots as its own; a leased one whose clone was deleted is stranded, deleted by naming it. A second clone of the repo that still exists keeps its own slots; those are not yours to reclaim.

Things worth knowing

The slot path is where you are. It is named by a hash, so git branch --show-current is how you tell which worktree you are in. git worktree list does not mark the current entry. Text output is escaped. Every line printed for a person — tables, warnings, refusal messages — writes a character a terminal would act on or hide as a \uXXXX escape, or \UXXXXXXXX — eight hex digits — above U+FFFF, whether it comes from a branch name, a file name, a registry field or a hook’s error, so it is shown rather than obeyed. A suggested command that names something holding such a character quotes it as $'…', with each such character written as its UTF-8 bytes (\xHH), and ' and ! as bytes too, so pasted into bash — 3.2, macOS’s /bin/bash, included — zsh or ksh, it runs on the name as it is. These are the characters invalid_agent refuses in an id; a message’s own line breaks and tabs are kept. Under --output=json a refusal’s message is escaped the same way; other values, such as branch, agent and the files listed, are as they are, escaped only as JSON requires. Pools follow the remote; slots follow the clone. A moved or renamed clone’s slots follow it on its next command; only a deleted clone’s leased slots are stranded. One pool per repository above has the detail.