Skip to main content
New and experimental. File bugs and suggestions to aspect-build/aspect-cli/issues.
End the lease on a branch. The worktree goes; the slot stays.
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. 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 scores it on.

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

Whichever you have: aspect worktree 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.
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 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:

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

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.
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. --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.
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.
text | json
default:"text"
text writes the report to stderr and leaves stdout empty. json writes one document to stdout.

JSON output

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

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

Refusals

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:
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.
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 retires the whole slot.