From 35c75f92381af28ceea2be441cbe471e3bec49cc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:29:42 +0100 Subject: [PATCH] docs(maestro): write the worktree lifecycle contract Closes #89 --- CLAUDE.md | 4 ++ docs/WORKTREES.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 docs/WORKTREES.md diff --git a/CLAUDE.md b/CLAUDE.md index cfa0ac5..50ec101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md new file mode 100644 index 0000000..fca3566 --- /dev/null +++ b/docs/WORKTREES.md @@ -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 `` of repository `` goes to +`/worktrees/`, where `` 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/` 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.