@@ -62,6 +62,10 @@ nix/ # Home Manager module (`services.maestro`)
|
|||||||
|
|
||||||
See `docs/INSTALL.md` for end-user install instructions.
|
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
|
`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
|
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
|
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