When the liveness watcher finds the tmux session gone, it now settles the worktree through the same probe_worktree/record_retention helpers the kill uses: pristine trees are removed, anything else is retained as exited-with-unsaved-work before anything is torn down. The dead session drops its worktree reference once settled, so dismissing it neither refuses nor retains twice. If the probe or store write fails, nothing is destroyed and the tree stays attached to the record. WORKTREES.md: written down the settle-then-drop and nothing-destroyed- when-unrecordable rules alongside the existing exit rule.
144 lines
6.7 KiB
Markdown
144 lines
6.7 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 or
|
|
recorded in its retained store. 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. The suffix names the **directory only**. The branch is whatever was
|
|
requested; 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 retains.
|
|
|
|
### `--force`
|
|
|
|
`--force` overrides the refusal and nothing else. It ends the session and
|
|
**retains** the worktree, which enters the retained store with reason
|
|
`force-killed`. 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.
|
|
|
|
Ending a session and destroying its work are two separate destructive acts.
|
|
Each is chosen on its own, and the second one — reclaim — is chosen while
|
|
looking at exactly what would be lost.
|
|
|
|
A forced kill answers with what was retained and why, so a client can say where
|
|
the work went rather than leaving the operator to wonder.
|
|
|
|
### 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 retained
|
|
with reason `exited-with-unsaved-work`.
|
|
|
|
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
|
only between removing and retaining. 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. Once settled, the session drops its worktree reference, so dismissing it
|
|
afterwards neither refuses (there is no guard left to trip) nor records a second
|
|
store entry.
|
|
|
|
If settling is impossible — the probe fails, or the store cannot take the entry —
|
|
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
|
|
record, where dismissing it later still goes through `--force`'s
|
|
retain-before-teardown path. Losing the store must never mean losing the work.
|
|
|
|
## The retained store
|
|
|
|
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
|
session. For each one: the path, the base repository, the branch, why it was
|
|
retained, and when.
|
|
|
|
It survives a daemon restart. It is the recovery path for everything above — a
|
|
store that is lost on restart is worse than no store, because the worktrees are
|
|
still on disk with nothing pointing at them.
|
|
|
|
Entries leave when the worktree is reclaimed, and when the daemon notices the
|
|
directory is gone because somebody removed it by hand.
|
|
|
|
**It is not an ownership record.** It must never be consulted to decide whether a
|
|
path may be adopted — see the first rule. Its only job is to remember what the
|
|
daemon is holding so that it can be listed and reclaimed.
|
|
|
|
## Reclaim
|
|
|
|
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
|
|
and what is currently in the way. The in-the-way breakdown is recomputed on read,
|
|
not stored, so a tree whose work has since been pushed says so.
|
|
|
|
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
|
|
store entry. It does **not** consult the guard — reclaim is the deliberate
|
|
destructive act, and the operator chose it while looking at what was in the way.
|
|
|
|
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
|
|
a tree must not throw away commits on it. This is worth saying out loud in every
|
|
client, because "reclaim" otherwise sounds like it takes the commits too.
|
|
|
|
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
|
|
reclaim.
|