128 lines
5.7 KiB
Markdown
128 lines
5.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.
|
|
|
|
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.
|
|
|
|
### 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 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.
|