> ## Documentation Index
> Fetch the complete documentation index at: https://aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# Working in parallel with warm worktrees

> A guide to running several branches or agents side by side with aspect worktree, so each one starts with Bazel's server, analysis cache and external repositories already in place.

<Warning>
  New and experimental. File bugs and suggestions to [aspect-build/aspect-cli/issues](https://github.com/aspect-build/aspect-cli/issues).
</Warning>

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.

```shell theme={null}
# the expensive way
git worktree add ../repo-fix-login fix/login
cd ../repo-fix-login
bazel test //...     # cold: new server, analysis from zero, external/ refetched
```

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.

```shell theme={null}
aspect worktree add fix/login --create=origin/main
slot=$(aspect worktree path fix/login) && cd "$slot"
bazel test //...                         # reuses this slot's output base; warm server too if it is
                                         # still running and the flags match
```

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`](/docs/cli/worktrees/prune) 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`](/docs/cli/worktrees/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.

<Note>
  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`](/docs/cli/worktrees/add#compared-with-git-worktree-add), [`release`](/docs/cli/worktrees/release#compared-with-git-worktree-remove).
</Note>

## 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:

```shell theme={null}
# at the start of a task
slot=$(aspect worktree add my-task-branch --create=origin/main --output=path) && cd "$slot"

# at the end, from outside the slot: releasing removes its directory
cd - >/dev/null
aspect worktree release my-task-branch
```

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](/docs/cli/worktrees/agent-skill) 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:

```
ERROR: fix/login is already held by this session, checked out at
       ~/.aspect/worktrees/…/aspect-worktree-18a11aa20e8d. If you took it,
       `aspect worktree path fix/login` prints that path again. Subagents share their
       parent's session, so if you did not, another agent of this session did — use a
       different branch.
```

**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:

```json theme={null}
{ "schema_version": 1, "error": "pool_busy", "retryable": true, "message": "another `aspect worktree` call is holding this pool's lock; try again" }
```

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:

```shell theme={null}
aspect worktree list
```

```
~/.aspect/worktrees/github.com/acme/web/aspect-worktree-<slot>
4 slots: 2 taken, 2 free

taken
  slot          state   warmth  age  leases  branch      agent        session                               notes
  18a11aa20e8d  in use  warm    12d       7  fix/login   claude-code  b22fe20c-1111-4000-8000-00000000b22f  session resumable
  b2697ff9b4e3  in use  cold    12d       4  spike/perf  claude-code  7f1e9a02-dead-4000-8000-00000000dead  session gone

free
  slot          warmth  last used  age  leases  last branch     notes
  f066bb8445cc  warm          <1m  12d       9  fix/logout
  0b8751be74ee  cold          41d  41d       1  old-experiment  base reclaimed

  the process that took fix/login is no longer running; resume its session:
    cd ~/src/web && claude --resume b22fe20c-1111-4000-8000-00000000b22f

  For more details run: aspect worktree list --verbose
  For usage instructions run: aspect worktree list --help
```

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](/docs/cli/worktrees/agents) for what each one
reports.

Nothing has to be configured for this: [agent detection](/docs/cli/worktrees/agents) 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:

| State | Meaning |
| - | - |
| `running` | The harness process is alive, verified by pid *and* start time, so a reused pid cannot resurrect a dead session |
| `resumable` | The process is gone but the session is still stored. Usually a closed terminal, so `list` prints the command to resume it, and `--verbose` does for every holder a slot remembers |
| `gone` | The session itself has been deleted. Nothing left to resume |
| `unknown` | No way to check for this harness. No session note and no resume hint — a stopped process still reads `process stopped` — but reclaiming ignores the state either way |

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.

```
INFO: reclaimed the slot held by old-task: its claude-code session
      41bb0c13-5555-4000-8000-000041bb0c13 is not running and the checkout
      was clean
```

## A day of work

Start a task:

```shell theme={null}
git fetch origin
slot=$(aspect worktree add fix/login --create=origin/main --output=path) && cd "$slot"
bazel test //services/auth/...
```

Something more urgent lands. Leave it and take another slot:

```shell theme={null}
slot=$(aspect worktree add hotfix/payments --create=origin/main --output=path) && cd "$slot"
bazel test //services/billing/...
```

See where you are:

```shell theme={null}
aspect worktree list
```

```
~/.aspect/worktrees/github.com/acme/web/aspect-worktree-<slot>
3 slots: 2 taken, 1 free

taken
  slot          state   warmth  age  leases  branch           agent
  a1b2c3d4e5f6  in use  hot     12d       7  fix/login
  b2c3d4e5f6a7  in use  hot     12d       3  hotfix/payments

free
  slot          warmth  last used  age  leases  last branch
  c3d4e5f6a7b8  warm           3d  12d       9  spike/perf

  For more details run: aspect worktree list --verbose
  For usage instructions run: aspect worktree list --help
```

Two live servers, one slot free with its output base still there. Hand the finished one back:

```shell theme={null}
aspect worktree release hotfix/payments
```

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](/docs/cli/worktrees/add#which-slot-you-get) suits most repos. A repo that knows its own build can replace it in `.aspect/config.axl`:

```python theme={null}
load("@aspect//traits.axl", "Worktrees")

def _prefer_same_tree(ctx, slot, want):
    """Prefer the slot that last held this exact commit: in this repo one
    directory dominates analysis, so an identical tree matters more than usual."""
    if slot.get("lock_digest", "") != want.get("lock_digest", ""):
        return 1
    return 100 if slot.get("last_commit") == want.get("commit") else 60

def config(ctx: ConfigContext):
    ctx.traits[Worktrees].score_slot.append(_prefer_same_tree)
```

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.

| `slot` field | |
| - | - |
| `id` | The slot id, as `aspect worktree list` shows it |
| `base_present` | Whether the slot has an output base on disk now. `false` means a cold build whatever it last held |
| `lock_digest` | A digest of the `MODULE.bazel.lock` it last built under, empty if none was recorded |
| `last_commit` | The commit its last worktree was at |
| `last_branch` | The branch its last worktree held |
| `created_ms` | When the slot was made, in milliseconds since the epoch |

| `want` field | |
| - | - |
| `name` | The branch being checked out; `(detached at <ref>)` for `--detach` |
| `commit` | The commit it is at |
| `lock_digest` | A digest of its `MODULE.bazel.lock`, comparable with the slot's |

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`](/docs/cli/worktrees/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:

```python title=".aspect/config.axl" theme={null}
load("@aspect//traits.axl", "Worktrees")

def config(ctx: ConfigContext):
    ctx.traits[Worktrees].max_slots = 12
```

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:

```shell theme={null}
aspect worktree prune --dry-run           # what has gone stale
aspect worktree prune                     # delete it, after confirming
aspect worktree prune --max-idle-days=7   # tighter
```

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:

```shell theme={null}
aspect worktree list
```

```
~/.aspect/worktrees/github.com/acme/web/aspect-worktree-<slot>
3 slots: 1 taken, 2 free

taken
  slot          state   warmth  age  leases  branch     agent        session
  35a73906ab6f  in use  hot     12d       7  fix/login  claude-code  b22fe20c-1111-4000-8000-00000000b22f

free
  slot          warmth  last used  age  leases  last branch     notes
  ea7bb37ec4c5  warm           2d  12d       9  fix/logout
  0b8751be74ee  cold          41d  41d       1  old-experiment  base reclaimed

  For more details run: aspect worktree list --verbose
  For usage instructions run: aspect worktree list --help
```

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`](/docs/cli/worktrees/agents#the-layers) 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](#one-pool-per-repository-one-slot-owner-per-clone) above has the detail.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.