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

# aspect worktree release

> End the lease on a pooled worktree and return its slot to the pool, leaving the slot path in place so Bazel's server and output base survive for the next one.

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

End the lease on a branch. The worktree goes; the slot stays.

```shell theme={null}
aspect worktree release fix/login
```

```
INFO: released fix/login; the slot stays in the pool, warm
```

That is the point, and the reason this is not called `remove`: Bazel keys its server and output base on the slot's path, so leaving that path in place is what makes the next worktree in it fast. To delete a slot and the output base behind it, use [`aspect worktree prune`](/docs/cli/worktrees/prune).

The branch stays too. Releasing removes the checkout, not the branch, so every commit on it is still in the clone; the only thing a release can lose is work that was never committed, and that is refused unless you say otherwise. `release` warns when the branch's commits are on no remote, since the clone is then the only place they exist.

Before ending the lease, the pool records what the slot is left holding — its `MODULE.bazel.lock` digest and the commit it was on — which is what the next [`aspect worktree add`](/docs/cli/worktrees/add) scores it on.

## It takes a branch, a slot or a directory — or nothing

Whichever you have: [`aspect worktree list`](/docs/cli/worktrees/list) shows the branch and the slot id, and `add --output=path` gave a script the directory. With nothing named, it releases the slot the working directory is in, from any directory inside it; outside a slot, that is refused with `no_such_worktree`.

```shell theme={null}
aspect worktree release fix/login      # the branch checked out in it
aspect worktree release 5537f1630b96   # the slot, by its whole id
aspect worktree release "$slot"        # its directory
aspect worktree release                # the slot you are in
```

Releasing the slot you are in removes the directory your shell is in: `cd` back to the clone afterwards, as the `cwd_removed` warning says.

A branch is matched against what git has checked out in each slot, so a slot you ran `git switch` in answers to the branch it is on now. A [detached slot](#detached-slots) answers to its id, or to the ref it was taken at while no other slot is at that ref.

A slot that is already free has no lease to end. That is not an error — releasing twice is harmless — and the answer names the command that does act on a free slot:

```
INFO: slot f066bb8445cc is free, last holding fix/logout, so there is nothing to release.
It keeps its Bazel output base for the next `add`; `aspect worktree prune f066bb8445cc`
deletes the slot and that base.
```

## Forgetting it never breaks anything

Git is the authority on whether a worktree exists. A slot whose worktree git no longer knows about is free regardless of the pool's own records, so:

* An agent that deletes its checkout without releasing leaves a state the next allocation reclaims, and the pool unregisters the vanished worktree from git so its branch can be checked out again — unless git still keeps commits only its HEAD reflog reaches, refs only it has, an operation in progress or a staged index for it (`VG`). Such a slot keeps its lease and its registration: it is not taken back, dropped by the idle sweep or taken over with `add --take-over`, and `release` refuses it — see [a checkout that is gone](#a-checkout-that-is-gone). Another clone's leases are kept the same way while that clone's git says it holds work for them.
* `git worktree remove` of a leased slot is refused by git itself, because every lease holds git's worktree lock:

  ```
  fatal: cannot remove a locked working tree, lock reason: leased from the aspect worktree pool; `aspect worktree release f066bb8445cc` ends it
  use 'remove -f -f' to override or unlock first
  ```

  Going past that with `-f -f` works, and the pool notices on its next scan.

A new session that wants a branch another session still holds does not have to wait for it: [`aspect worktree add <branch> --take-over`](/docs/cli/worktrees/add) re-assigns the lease and keeps the checkout.

You do not have to run this for correctness. It does cost pool size in one case: an agent that dies with its checkout still on disk leaves a worktree git still knows about, so the slot stays leased. Age does not reclaim it — a leased slot is never taken on elapsed time, because a lease is a checkout someone may still want. It is taken back only once the process that held it has verifiably stopped, `Worktrees.abandoned_grace_hours` (24 by default) have passed since the slot's last activity, *and* the checkout is clean. So release when you can.

## A checkout that is gone

A leased slot whose directory was moved with `mv`, deleted or emptied, while git still keeps commits only its HEAD reflog reaches, refs only it has, an operation in progress or a staged index for it, is refused with `worktree_dirty`:

```
ERROR: fix/login, at /Users/you/.aspect/worktrees/github.com/acme/web/aspect-worktree-5537f1630b96,
       holds a checkout gone from its directory, with work git still keeps for it:
         VG (the checkout is gone from its directory; git still keeps this for it)
         DH (2 commits only this worktree's HEAD reaches; `git branch <name> <tip>` from the clone, for tip 680d07d8e446ab3825ab464e9964662dbb4f3a49, keeps them)
       If you moved it, `git worktree repair <its new path>` from the clone brings it back, staged changes and any operation in progress with it; otherwise
       keep its commits with the `git branch` the DH line names, and its refs from
       `git for-each-ref refs/worktree` in that worktree's admin directory. Discarding them with the worktree is
       the user's decision: `aspect worktree release fix/login --force`. Not `git stash`: the stash is
       shared by every worktree of this clone.
```

The lease and git's registration of the worktree stay until then. `--force` discards the work and drops the registration, so the branch can be checked out again.

A slot whose lease already ended while its directory is gone has nothing to release, and the answer is still `already_free`. `release` also clears git's registration of it, and of any other slot of this pool whose directory is gone, when git keeps nothing for it.

Known limitations:

* A slot moved away 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.
* When both a slot and its clone have moved, recovering the lease takes `git worktree repair` run by hand.

## Flags

<ParamField path="branch" type="string">
  The branch checked out in the slot, the whole slot id `aspect worktree list` shows in its first column, or the slot's directory. Omitted, the slot the working directory is in. Not a prefix of the id: `release` deletes on the answer, and a mistyped branch that happened to prefix some slot would land on it. A detached slot also answers to the ref it was taken at, while no other slot is at it.
</ParamField>

<ParamField path="--force" type="false | true | all" default="false">
  Override a refusal, in two grades, as `git worktree remove` has `-f` and `-f -f`. Write the grade with an equals sign, `--force=all`: with a space, `all` is read as a branch name — `release --force all` is refused with a hint saying so, and `release <branch> --force all` fails to parse, naming two branches.

  * `true`, which bare `--force` means, releases your own worktree with uncommitted changes, or with commits only its HEAD reaches, discarding them. What went is listed in the result.
  * `all` also ends a lease another session holds that has not verifiably stopped, or has stopped within its grace, removing a checkout somebody may be working in — and discards another session's work even once its process has gone, which plain `--force` never does. That is a decision for whoever is driving that session, which is why discarding your own work never does it. A subagent's lease is its parent session's: the session itself, with no `--agent-id` of its own, releases it as its own. Work under a lease that records neither a session id nor a process needs `all` too: such leases are told apart by harness kind alone, so nothing shows the work is yours. Give each session an id with `ASPECT_AGENT_ID` or `--agent-id` and plain `--force` covers your own again.

  Without it, a dirty worktree is refused with `worktree_dirty` and the changed files are listed — along with work `git status` does not show, each on a line of its own: a populated submodule (`SM`), whose repository goes with the worktree, as plain `git worktree remove` refuses outright; an edit hidden by `--assume-unchanged` or `--skip-worktree` (`H`); a ref only this worktree has (`WR`); a merge, rebase, cherry-pick, revert or bisect in progress, a multi-commit cherry-pick or revert waiting between picks included (`IP`); a worktree git lists inside this one (`NW`), under an ignored `.claude/worktrees/` say, which removing this one deletes with its work; commits only its HEAD reaches (`DH`). A slot whose checkout is gone from its directory — moved with `mv`, deleted, or the directory emptied — while git's admin directory for it still holds commits only its HEAD reflog reaches (`DH`), refs only it has (`WR`), an operation in progress (`IP`) or changes staged in its index (`IX`) is refused under a `VG (the checkout is gone from its directory; git still keeps this for it)` line — as is one git lists whose admin directory cannot be found, as `?? (git's admin directory for it could not be found)`, or `?? (git's admin directory for it has no HEAD, so what it keeps cannot be read)`. The refusal says the slot "holds a checkout gone from its directory, with work git still keeps for it", and its `DH` line names the commits: "N commits only this worktree's HEAD reaches; `git branch <name> <tip>` from the clone, for tip `<sha>` (or each of several, the rest counted after five), keeps them". If it was moved, `git worktree repair <its new path>` from the clone brings it back; otherwise the `git branch` the `DH` line names keeps the commits. Its lease is not freed and its git registration is kept until then. See [a checkout that is gone](#a-checkout-that-is-gone). `--force` discards that work and drops git's registration of the worktree, so the branch is free again. A checkout git cannot read — its index damaged, its `.git` link broken or deleted, naming another worktree's admin directory, so that git answers for that worktree instead, a repository of its own at the slot's path, with a `.git` directory rather than a linked worktree's `.git` file, a plain file at the slot's path, or a checkout whose git reads another directory as its work tree (`core.worktree` set in the worktree's own config) — is refused the same way (`UR`), since work in it cannot be ruled out, and only `--force=all` releases it: nothing shows it is yours. In JSON, each line is a `status` and a `path`, with a `note` saying why it counts for every code but git's own. `path` is the file's name as it is, not as `git status` quotes it; a rename or copy gives where it went as `path` and where it came from as `from`. Bazel's own leavings — the `bazel-*` symlinks, and a `MODULE.bazel.lock` the repo does not track — are not counted and never block a release. Files git ignores are not counted either, whoever's slot it is: what a repository ignores is by its own account disposable, and goes with the checkout as `git worktree remove` takes it.
</ParamField>

<ParamField path="--agent-kind, --agent-id" type="string" default="">
  The session asking, as given to `add`. Needed only when the lease was taken with these flags, so `release` recognises it as yours. `path`, `prune`, `list` and `inspect` take them too.
</ParamField>

<ParamField path="--output" type="text | json" default="text">
  `text` writes the report to stderr and leaves stdout empty. `json` writes one document to stdout.
</ParamField>

## JSON output

```json theme={null}
{
  "schema_version": 1,
  "released": "fix/login",
  "already_free": false,
  "slot": "5537f1630b96",
  "path": "/Users/you/.aspect/worktrees/github.com/acme/web/aspect-worktree-5537f1630b96",
  "head": "e567ac6b2a18bed22ddf9e220a7e35a62f04e024",
  "head_on_remote": true,
  "discarded": [],
  "cwd_removed": false,
  "clone": "/Users/you/src/web",
  "warnings": []
}
```

`released` is the branch checked out in the slot when it was released — what holds the work, and what the push advice names — whichever way you named the slot; a detached slot's `(detached at …)` label. A free slot is not an error: `released` is empty, `already_free` is true, and the exit is 0. `head` is the commit the worktree was at, and `head_on_remote` whether any remote has it — null for a free slot, which had no commit checked out. `discarded` lists what `--force` threw away, as `{status, path}` entries like `uncommitted`. `cwd_removed` is true when you released the slot you were standing in; its warning's `cd` is where to go back to — the directory the lease was taken from, or the clone when that is gone — and `clone` is the clone's root.

`warnings`, each with a `code` and a `message`:

| `code` | Meaning |
| - | - |
| `head_not_on_remote` | The branch is kept, but no remote has its tip. The message names the `git push -u` that keeps it safe — or, in a clone with no remote, says the clone is its only copy |
| `uncommitted_discarded` | `--force` threw away uncommitted changes, listed under `files` |
| `unreferenced_commits_discarded` | `--force` dropped commits only the worktree's HEAD reached, listed by their `tips`. They are dangling until `git gc` collects them; the message gives the `git branch` command that recovers them |
| `overrode_another_session` | A lease another session held was ended — with `--force=all`, or once its holder had stopped and its grace was over — named under `previous_holder` |
| `bazel_server_running` | A Bazel server still holds the slot's output base, which keeps the next lease hot; it exits after Bazel's idle timeout |
| `cwd_removed` | Your shell was in the slot, which no longer exists. `cd` to the path given |

Every later command run from the removed directory, `aspect` or not, fails. An `aspect` command says why: "the current directory no longer exists — if it was a worktree that was released, `cd` back into the clone".

## Detached slots

A slot taken with [`add --detach`](/docs/cli/worktrees/add#detached) has no branch, so it is released by its slot id. A branch keeps its commits after a release; a detached HEAD keeps nothing, so a commit made there that no branch, tag or remote branch has is refused rather than dropped — at HEAD now, or left behind when HEAD moved on, in any slot:

```
ERROR: slot 773456561a1d has 1 commit only its HEAD reaches, at
       ~/.aspect/worktrees/github.com/acme/web/aspect-worktree-773456561a1d
       (tip 680d07d8e446ab3825ab464e9964662dbb4f3a49). Releasing would leave it dangling —
       reachable from no ref, for `git gc` to collect. To keep it,
       `git branch <branch> 680d07d8e446ab3825ab464e9964662dbb4f3a49` from the clone.
       Discarding it with the worktree is the user's decision:
       `aspect worktree release 773456561a1d --force`.
```

Removing a worktree takes its HEAD reflog with it, so nothing else would remember those commits. The reflog is read through git, so a reftable repository is covered as well as one on the files backend. On an unborn branch, after `git switch --orphan`, git will not read it, so there the files backend's `logs/HEAD` is read directly. On a reftable repository there is no such file, so a reflog git cannot list on an unborn branch counts as work, with the count unknown. So does any reflog that exists but cannot be read: the refusal says so and points to `git reflog` in the slot. Slots are checked out with `core.logAllRefUpdates=always`, so the reflog is there whatever the repository sets. Commits a branch left behind — an amend, a rebase — do not count: the branch's own reflog keeps them. Nor do the steps listed directly under a rebase's finish or abort — a pick a fixup was squashed into — or the commits of an aborted `git am`. Anything else made while a rebase was stopped still counts, an amend at an `edit` stop included, and so does a rebase's result at a detached HEAD, which is nowhere else; a stop's amend that did end up on the branch is then refused when it need not be, and `--force` releases it. A commit HEAD only checked out, which a remote-tracking branch's reflog also has — a pull request reviewed at a detached HEAD, then force-pushed over — does not count; one made here does, whatever a remote's reflog says, since the next pruning fetch deletes that. Nor does a commit any ref outside the per-worktree namespaces (`refs/worktree/`, `refs/bisect/`, `refs/rewritten/`) reaches, or one another worktree's HEAD is at, since those outlive this checkout. A commit git cannot count — the check itself failed — counts as work.

The refusal names the fewest tips whose branches keep everything, as `git merge-base --independent` finds them: a lost commit another lost commit reaches is left out, so one `git branch` per tip named keeps the lot. The message lists the first 20 and counts the rest; `tips` in the JSON carries them all.

The pool applies the same rule when it takes back an abandoned slot: unreferenced commits count as work, and the slot is left alone.

## Compared with `git worktree remove`

| `git worktree remove` | `aspect worktree release` | |
| - | - | - |
| `<worktree>` | A branch, a slot id, or the directory | The pool knows which slot holds which branch, and the directory works as git's does |
| Removes the checkout | Removes the checkout, keeps the slot's path and Bazel state | The path is what Bazel keys its output base on |
| Refuses a dirty worktree | Refuses a dirty worktree and lists the files, ignoring Bazel's own leavings | A built-in slot would otherwise never be clean |
| — | Refuses commits only its HEAD reaches | Git removes those without a word |
| `-f` | `--force` | Discards uncommitted changes, and a detached HEAD's commits |
| `-f -f` (past a lock) | `--force=all` (past another session's lease) | The lease is the pool's lock; every leased slot holds git's too, which `release` lifts |

`release` leaves the branch, as `git worktree remove` does. Deleting it is `git branch -d` from the clone afterwards.

## Refusals

| `error` | Exit | Meaning |
| - | - | - |
| `slot_setting_up` | 1 | An `add` is still checking a worktree out into that slot — named by its id, or by the branch being checked out. One whose `add` has stopped is reclaimed instead, and `release` of its branch succeeds with `already_free` and `reclaimed` |
| `held_by_another_session` | 1 | Another session holds the slot and has not verifiably stopped, or has stopped within `Worktrees.abandoned_grace_hours` of the slot's last activity — `grace_left_ms` says how long it is still kept. The message names it; `--force=all` ends its lease. `process` in the facts says whether the holder's recorded process is `running`, `stopped`, or `unknown` where none was recorded, apart from `session_state`, which asks the harness about the session. The facts carry no `path`. For a subagent, a sibling's slot is refused whatever its process and whatever the flag: the session itself decides |
| `owned_elsewhere` | 1 | The slot belongs to another clone of this repository that still exists, whether named by branch, slot id or directory. Release it from there |
| `slot_stranded` | 1 | The slot is leased for a clone that is gone, and no clone here lists its checkout, so it cannot be released, only deleted: the message names `aspect worktree prune <slot> --force=all`. If that clone moved, run the command from where it is now, which takes the slot back. Also raised for a slot recorded for this clone's path that this clone's git has no worktree for: a clone moved away and this one was cloned where it was, so the checkout is the moved clone's, and running the command from there takes it back. That message also allows that the checkout is this clone's own and damaged, and names `git worktree repair <path>` |
| `worktree_dirty` | 1 | The worktree has uncommitted changes, which are listed — or it holds a checkout git cannot read (`UR`), or one whose directory is gone while git still keeps work for it (`VG`), said as such. `--force` discards your own changes; another session's work — named in the message and in `holder` — needs `--force=all`, as does work under an agent's lease that records no session id or process. A person's lease — no agent at all — is a person's to release with plain `--force`. When the session itself releases its own subagent's slot, the message says discarding the work is the session's decision rather than the user's |
| `unreferenced_commits` | 1 | Commits only the worktree's HEAD reaches, their `tips` listed — the fewest whose branches keep them all. `git branch <branch> <tip>` keeps them; `--force` discards your own, `--force=all` another session's |
| `no_such_worktree` | 1 | Nothing in this pool answers to that name, and the message says what this clone does have out. For a branch whose lease of your session ended in the last day, it says that lease ended, names the slot and how long ago, and suggests `aspect worktree add <branch>`; the facts carry those leases as `ended` |
| `pool_busy` | 75 | Another call holds the pool lock. Retryable |
| `not_a_repository` | 1 | The working directory is not a clone |
| `registry_newer` | 1 | The pool's registry was written by a newer aspect CLI, which this one would rewrite without what it does not understand. Run that version, or upgrade |
| `invalid_agent` | 1 | `--agent-kind` / `ASPECT_AGENT_KIND` is not a name, or `--agent-id` / `ASPECT_AGENT_ID` is all spaces, longer than 128 characters, or has a character a terminal would act on or hide: a control character, DEL, the soft hyphen, the combining grapheme joiner, a Hangul or Khmer filler, an Arabic or Mongolian format mark, a zero-width character or joiner, a line or paragraph separator, a bidirectional override or isolate, an invisible operator, a variation selector, a musical formatting character, an interlinear annotation mark, a byte-order mark or a tag character. The message shows the value in double quotes, each such character escaped once, as `\u202e`, or as `\U` and eight hex digits above U+FFFF. Fix the value |

## If the worktree will not go

Git can fail to remove a worktree, and a directory can resist deletion: an unreadable file, something holding it open. The slot is still marked free, and you get a warning rather than silence:

```
WARNING: could not remove the checkout at …/aspect-worktree-5537f1630b96; the slot is free
         in the pool, but the directory is still on disk. Removing it, then
         `git worktree prune` from this clone, clears it.
```

The next `aspect worktree add` clears a leftover directory with nothing in it to lose. One holding work, or a checkout git cannot read (`UR`) — a repository of its own at the slot's path, a plain file there, or a checkout whose git reads another directory as its work tree (`core.worktree`) included — it skips with a warning and leaves for you to clear.

<Note>
  Nothing here deletes Bazel's output base, because keeping it is the point. `aspect gc` will not treat it as orphaned either, since a free slot is indistinguishable from an abandoned worktree, though it does remove one left idle past `--base-max-idle-days`. [`aspect worktree prune`](/docs/cli/worktrees/prune) retires the whole slot.
</Note>


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