@@ -62,6 +62,10 @@ nix/ # Home Manager module (`services.maestro`)
|
||||
|
||||
See `docs/INSTALL.md` for end-user install instructions.
|
||||
|
||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
||||
touches. Read it before changing anything that creates or removes one.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user