Git mutation surface
ADR-0007 makes hocket observe-only, then carves out a short list of guarded, user-initiated local writes through its amendments. The records live in the ADR; this page is the operational map — every write hocket can perform, the guards on it, and how the destructive ones compose with dirty trees, live agents, remotes, and recoverability. All writes go through core/git/run.ts (gitWrite), argv arrays, never a shell; a failure surfaces as a status-bar notice and changes nothing.
| Write | Command | Gesture | Guards |
|---|---|---|---|
| Create worktree + branch | worktree add -b | menus/palette, typed name | agent path convention, user-initiated only |
| Mount existing branch | worktree add | branch menu | — |
| Create branch | branch <name> <from> | menus/palette | — |
| Rename branch | branch -m | branch menu | unmounted, non-trunk |
| Record branch base | config --local branch.<n>.hocket-base | automatic rider on create gestures | key prefix-locked in code; metadata only, never refs/objects; failure is silent (inference covers) |
| Retarget children's base | same command, per child | automatic rider on hocket rename | same key lock; external renames instead degrade to trunk at the next scan |
| Delete branch (soft) | branch -d | branch menu, confirm | unmounted, non-trunk, merged into trunk; -d re-proves it |
| Remove worktree | worktree remove | worktree menu, confirm | non-main, clean, fresh scan, not unmerged-detached; live sessions and observed processes offer end-and-remove (red confirm); branch preserved |
| Delete worktree + branch | worktree remove then branch -d, escalating to -D only when the confirm carried the unmerged warning | worktree menu, confirm | every Remove-worktree guard, then the branch rung: non-trunk (menu-gated), merged states soft stakes and -d re-proves; unmerged states the hard-delete stakes up front |
| Switch root to trunk | worktree remove (a mounted trunk's worktree) then switch <trunk> | root checkout menu | root only, off trunk (or detached); live sessions in trunk's worktree render the item inert; never forced — git refuses a dirty/untracked trunk worktree (remove) and conflicting root changes (switch); no confirm, nothing can be lost |
| Auto-prune | the two above | opt-in, automatic | merged + clean + idle only, re-checked at prune time |
| Hard delete branch | branch -D | graph ref chip, red confirm | unmounted, non-trunk |
| Drop archived bookmark | update-ref -d | graph ref chip, red confirm | prefix-locked to refs/archived-worktrees/ in code |
Remote refs, merge/rebase, commits, history rewriting, and anything over the network remain outside the boundary entirely.
Lineage metadata
The two config writes above are the lineage rider (ADR-0017, ADR-0007's lineage amendment): one inert key per branch naming the branch it was forked from. The key's lifecycle rides git's own section handling — branch -m migrates it, branch -d/-D removes it — so external renames and deletes need no hocket-side GC. Deleting a base re-parents its children to trunk at the next observation (a read-side collapse, no write). Auto-prune performs no lineage writes. Branches without the key fall back to reflog inference, then trunk-default, read-only.
The deletion ladder
Deletion is ordered so that each rung's guard makes the next rung safe:
ref to delete
│
▼
mounted in a worktree? ── no ──────╮
│ yes │
▼ │
╭ remove worktree first ───────────────╮ │
│ clean + fresh scan; live sessions │ │
│ and observed processes offer │ │
│ end-and-remove (red confirm); │ │
│ Delete worktree + branch walks │ │
│ both rungs on one confirm │ │
╰──────────────────────────────────────╯ │
│ │
▼ │
now unmounted ◀───────────────╯
│
▼
merged into trunk? ── no ───────────────────╮
│ yes │
▼ ▼
╭ soft delete · branch -d ─────╮ ╭ hard delete · branch -D ─────╮
│ sidebar menu or auto-prune │ │ graph ref chip · red confirm │
╰──────────────────────────────╯ ╰──────────────────────────────╯- The paired delete rides the ladder, it doesn't skip it. "Delete worktree + branch" is one gesture over the same two rungs: the removal rung keeps every guard (and the end-and-remove path for occupants), then the branch rung runs with the merged question answered in the confirm the user already read — soft stakes and
-dwhen the work landed, the hard-delete warning up front when it didn't. Even behind that warning-dis tried first, so a branch that never committed is dropped without force and-Dfires only for a tip that would actually go unreachable. If the branch delete refuses (say the tip advanced past a stale merged read), the branch survives and is revealed, exactly as if the preserving remove had run. - Dirty trees can never reach hard delete. Only unmounted branches offer it from the graph, and an unmounted branch has no working tree to be dirty. A mounted branch's routes to deletion run through worktree removal — beforehand, or in the same paired gesture — and that step owns the working-state proof: clean tree plus a fresh process scan, because "not observed" is not "idle" (an unopened Processes pane has no sample). A failed scan blocks removal. Live sessions and observed processes are soft rungs: the red end-and-remove confirm lists sessions and unrelated processes, closes the confirmed sessions, then uses SIGTERM, a short grace, and SIGKILL for survivors that still match the confirmed command identity (ADR-0012's pid-reuse guard). The removal that follows is still the non-forced
worktree remove, so a shutdown hook that dirties the tree makes git itself refuse. Uncommitted work is therefore protected one rung before any ref drops. - Working agents are worktree-shaped. Sessions and processes attach to worktrees, never to unmounted branches, so the rows that carry agent glyphs (worktree ghost rows) never offer hard delete — their menu is the worktree menu. An agent busy in one worktree does not block deleting an unrelated parked branch, and shouldn't.
- The root checkout never enters the ladder. It is the repo, not a removable anchor: its menu renders Remove inert with the reason ("root checkout") and offers the one write that goes home instead —
switch <trunk>, non-forced, no confirm, because switch refuses rather than discards (git carries non-conflicting changes and rejects the rest). A trunk mounted in a linked worktree comes home first via the same non-forcedworktree remove— deliberately without the ladder's confirm, because its object is a clean checkout of trunk's own tip immediately re-materialized at the root; only live sessions there hold the gesture inert. If the remove lands and the switch refuses, trunk parks unmounted (pinned second) with the plain switch still offered. - The race window is the confirm modal. The menu item names a ref, not a sha;
branch -Ddrops whatever the ref points at when confirmed. If an agent advances the branch between right-click and return, the newer tip is what becomes unreachable — same contract as the sidebar's soft delete, where-d's own merged re-check narrows the window further. Git is re-read after every write, so the UI never keeps showing a deleted ref.
Remotes
- Hocket still never touches
refs/remotesor the network: no fetch, no push, no remote branch deletion. Hard delete does not change this. branch -Ddrops the local ref (and itsbranch.<name>config). A pushed branch survives atorigin/<name>and is recoverable by recreating the branch from it — hard-deleting a pushed branch discards nothing but your local pointer.- "Leaves the graph" is a projection statement, not a repo statement. The graph walks from local heads, worktree HEADs, and archived bookmarks only; remote refs are neither drawn nor walked. Commits reachable only from
origin/<name>disappear from the graph while remaining safely in the repository. - Hocket tracks no upstream state for unmounted branches (ahead/behind comes from status porcelain, per worktree), so the confirm cannot yet say "also exists on origin". If that guard ever earns its keep, it belongs on
BranchStateviafor-each-ref %(upstream), not in the confirm text.
Recoverability
- Soft delete is provably lossless —
-dverifies merged ancestry inside git; the commits remain in trunk history. - Hard delete drops a ref, never objects. The commits stay in the object store and remain reachable through any other ref or worktree HEAD that holds them (a detached worktree parked on the tip keeps it alive — and in the graph). Truly unreachable commits survive until
git gcprunes them (~two weeks by default); until then they are recoverable by sha (git fsck --lost-found, the creating agent's scrollback, or the remote). The branch's own reflog is deleted with the branch, so recovery is by sha, not by name. - Removing a worktree deletes an anchor, never commits. A detached checkout's tip is reachable only through the worktree's own HEAD, which lives in the main repo's
.git/worktrees/<name>/admin directory;worktree removedeletes that directory — HEAD and its reflog included — so an unmerged detached tip goes unreachable and leaves the graph in the same stroke. That is exactly the loss the "not unmerged-detached" guard refuses. The commits themselves follow the hard-delete rules above: they sit in the object store untilgit gcprunes, recoverable by sha (git fsck --unreachable --no-reflogs, thengit branch <rescue> <sha>) — never by reflog, which died with the worktree. Origin never notices any of this: fetch and push negotiate refs only, so unreachable local objects are invisible to the remote and no sync state changes. - Archived bookmarks are pure pointers. Hocket never creates
refs/archived-worktrees/*; dropping one deletes only the bookmark, and its commits follow the same reachability rules as any hard delete.
After every write
Every mutation ends in the same refresh: the observer re-reads porcelain, the graph source re-probes (the ref hash moved, so the layout rebuilds), the graph cursor clamps to the shrunken grid, and the sidebar reselects the surviving branch or worktree. Nothing in the UI trusts its own write — git is re-read as the only truth (ADR-0011 refresh doctrine).