Previously --force left the worktree on disk so the operator could inspect it. Now it removes the worktree unconditionally — the confirmation prompt already shows the breakdown, so the decision happens before the delete, not after. Natural exit still leaves dirty worktrees on disk (no operator confirmation in that path). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
115 lines
5.2 KiB
Markdown
115 lines
5.2 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.
|
|
|
|
### `--force`
|
|
|
|
`--force` overrides the refusal and **removes the worktree**, even when it holds
|
|
unsaved work. The operator sees the breakdown in the confirmation prompt and
|
|
decides; once confirmed, the tree is gone.
|
|
|
|
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`.
|
|
|
|
### 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 removes it. The same holds when the probe
|
|
itself fails: nothing is destroyed.
|
|
|
|
## Trees left behind by natural exit
|
|
|
|
A forced kill always removes the worktree. A natural exit with unsaved work
|
|
leaves the tree on disk — there is no operator confirmation in that path. Those
|
|
trees are ordinary git worktrees; `git worktree list` finds them and
|
|
`git worktree remove` removes them.
|