git worktree add -b fails outright when the branch already exists, even if the target directory is free. A random fast-forward handle commonly collides with a branch an earlier, already-removed worktree left behind. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
130 lines
6.0 KiB
Markdown
130 lines
6.0 KiB
Markdown
# Worktrees
|
|
|
|
Maestro can create a git worktree for a session and is responsible for it from
|
|
that moment until the worktree is removed. This document is the contract for
|
|
that lifetime: where a worktree goes, what ends a session, what survives, and
|
|
what the operator has to do by hand.
|
|
|
|
It exists because the rules here are not guessable from the code. Every rule
|
|
below was decided once, re-litigated in review, and decided the same way again.
|
|
The reasoning is written down alongside each rule so the next reader does not
|
|
have to reconstruct it.
|
|
|
|
## Nothing is ever adopted
|
|
|
|
The daemon only ever removes a worktree it created in this session's lifetime.
|
|
It never takes ownership of a directory it finds on disk, whatever that
|
|
directory looks like.
|
|
|
|
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
|
no marker distinguishing one a daemon made from one a human made ten minutes
|
|
earlier, and any heuristic that answers the question will eventually answer it
|
|
wrong — in the direction of deleting somebody's work.
|
|
|
|
So the daemon does not ask. If the target path is occupied by anything at all, it
|
|
picks a different path.
|
|
|
|
## Placement
|
|
|
|
A worktree for branch `<branch>` of repository `<repo>` goes to
|
|
`<repo>/worktrees/<branch>`, where `<repo>` is the main checkout — not whichever
|
|
worktree the request happened to come from.
|
|
|
|
The branch name is used verbatim as a relative path, so `feature/login` becomes
|
|
`worktrees/feature/login`. A branch name that cannot be a relative path falls
|
|
back to a short hash of the branch.
|
|
|
|
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
|
a free one.
|
|
|
|
For a *new* branch (`create_branch: true`), the suffix goes on the branch name
|
|
itself, and the directory follows since it is derived from the branch — a
|
|
random handle can otherwise reuse the name of a branch some earlier, already
|
|
removed worktree left behind, and `git worktree add -b` refuses outright even
|
|
though the directory is free.
|
|
|
|
For an *existing* branch (`create_branch: false`) the name must stay exact, so
|
|
only the directory is suffixed — a second worktree of the same branch is not
|
|
what happens here, because git refuses to check the same branch out twice and
|
|
the branch picker already marks branches that are checked out elsewhere.
|
|
|
|
### The `worktree_root` override
|
|
|
|
`[daemon].worktree_root` moves every worktree out of the repository and under one
|
|
root. The per-repository directory is the repository's basename plus a short
|
|
digest of its full path — the digest is what keeps two repositories with the same
|
|
basename apart. Inside it the layout and the collision suffix are the same.
|
|
|
|
## What ends a session
|
|
|
|
`DELETE /api/sessions/:id` is **refused** when the session's worktree holds
|
|
unsaved work, meaning any of:
|
|
|
|
- uncommitted changes to tracked files
|
|
- untracked files that are not ignored
|
|
- commits on the branch reachable from no remote-tracking ref
|
|
|
|
Ignored files never block. Every Rust worktree has a `target/`; if ignored files
|
|
counted, no session would ever be killable.
|
|
|
|
The refusal is a structured error naming what blocked it and how much of it there
|
|
is, so a client can say "3 uncommitted files, 2 unpushed commits". The session
|
|
keeps running and the worktree is untouched.
|
|
|
|
The point of refusing rather than cleaning up is that the session stays as the
|
|
operator's handle on their own unfinished work. A session that is killed while
|
|
its worktree still matters leaves a directory nobody is looking at.
|
|
|
|
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
|
commits, so `--force` is the ordinary way to end a session, not an exception.
|
|
That is only safe because force never removes what blocked.
|
|
|
|
### `--force`
|
|
|
|
`--force` overrides the refusal and nothing else. It ends the session and
|
|
**leaves the worktree on disk**, untouched. Force never removes a tree that
|
|
would have blocked. A tree whose guard is clear never needed the override, so a
|
|
forced kill removes it as usual — force changes what happens to blocked work,
|
|
not ordinary cleanup.
|
|
|
|
In the web client the kill button sends the unforced `DELETE` straight away —
|
|
there is no pre-confirmation, because the daemon is the one that decides. Only
|
|
the refusal raises a prompt, and that prompt names what is in the way and offers
|
|
"Force kill", which repeats the request with `force=true`.
|
|
|
|
Ending a session and destroying its work are two separate acts. Maestro only
|
|
performs the first one. Removing a tree it left behind is the operator's own
|
|
`git worktree remove`, chosen while looking at exactly what would be lost.
|
|
|
|
### Exiting on its own
|
|
|
|
A session that ends by itself — the command finished, the operator typed `exit` —
|
|
never reaches the kill path. When the watcher notices the tmux session is gone it
|
|
applies the same rule: a pristine worktree is removed, anything else is left on
|
|
disk.
|
|
|
|
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
|
only between removing and leaving. It uses the same predicate as the kill, from
|
|
the same place, so the two paths cannot drift apart.
|
|
|
|
The session itself stays in the catalog marked dead until it is dismissed — the
|
|
replay buffer of a crashed agent outlives it — but its worktree is settled at
|
|
once. A removed tree drops off the record, so dismissing the session afterwards
|
|
does not refuse: there is no guard left to trip.
|
|
|
|
A tree that was left alone stays attached to the dead record, where dismissing it
|
|
later goes through `--force`, which leaves it on disk too. The same holds when
|
|
the probe itself fails: nothing is destroyed.
|
|
|
|
## Nothing is removed on the operator's behalf
|
|
|
|
There is no store of worktrees the daemon is holding, no list endpoint, and no
|
|
reclaim command. A tree left behind by a forced kill or a dirty exit is an
|
|
ordinary git worktree on disk; `git worktree list` finds it and
|
|
`git worktree remove` removes it.
|
|
|
|
That is deliberate. A tracked list of trees pending deletion is a second place
|
|
the truth can live, and it goes stale the moment somebody removes a directory by
|
|
hand. Git already knows which worktrees exist, and removing one is not a
|
|
decision Maestro is better placed to make.
|