# ADR-0049: Destructive Worktree Close over the Daemon, Guarded by Structural `is_main`

## Status

✅ Accepted (2026-07-10)

## Context

[ADR-0040](adr-0040.md) introduced the `worktrees` daemon service and
[ADR-0048](adr-0048.md) grew it into a live tree view: repositories at the top
level, every git worktree as a child, with an `open` op that focuses (or opens) a
worktree's VS Code window over the control socket. Every capability so far is
**non-destructive** — it reads registry/git state and, at most, spawns `code` on
an existing directory.

The natural next action on a worktree leaf is to **close** it: shut the VS Code
window it is open in and, for a linked worktree, delete the worktree from disk.
That is qualitatively different from everything before it — it **removes files**
and **closes windows** — so it needed its own decision rather than another op
folded silently into ADR-0048's surface.

Three structural facts shaped the design:

1. **The extension host is sandboxed per window.** The tree in window A lists
   worktrees open in *other* windows, each leaf carrying the owning window's
   `key`. But window A's extension **cannot** close window B — only window B's own
   extension host can (`workbench.action.closeWindow`). And the daemon is a pure
   request/reply server: it never initiates a connection to a window. So a
   cross-window close must travel **A → daemon → B**, and the daemon has only two
   ways to reach a specific window: the **heartbeat reply** (per-window addressed,
   because the window sends its own `key`; latency ≤ the ~10 s heartbeat interval)
   and a **keyed push** over the `subscribe` stream (near-instant, but the stream
   is anonymous today).
2. **Deletability is a structural fact, not a branch-name heuristic.** `git
   worktree remove` refuses the **main working tree** by design; a **linked**
   worktree is removable and removing it *never* deletes its branch. A linked
   worktree that happens to have `main`/`master` checked out is still a linked
   worktree — fully deletable, branch preserved. The one bit that decides
   deletability is `is_main` (the common dir's parent vs a `git worktree add`
   checkout), never the branch name.
3. **A destructive op must fail closed and never surprise.** Removing a worktree
   can lose uncommitted work; closing a window can lose unsaved buffers or a
   multi-root layout. The op has to *know* what would be lost before it acts, and
   must never delete the one tree `git` itself refuses.

The ADR-0040/0039 constraints still bind: reuse the `0600`-socket/`0700`-dir
transport and trust model unchanged, persist no secret and no state, keep the
engine's `Mutex`-never-across-`.await` invariant, and stay wire-compatible with
older clients through the `#[serde(skip_serializing_if = …)]` convention.

## Decision

Add a single **`close`** op to the `worktrees` service, symmetric to `open` but
destructive, with all git logic in Rust/`git2` (the companion never runs git),
gated by a structural guard and a two-phase confirm, and signalling a
cross-window target through the **heartbeat reply**.

### 1. Two phases keyed off `confirmed`, so a prompt appears only when needed

Payload: `{ path, requester_key?, remove: bool, confirmed?: bool }`.

- **Phase 1 — safety check** (`remove:true`, `confirmed` absent). Side-effect-free.
  The adapter opens the worktree with `git2` on a blocking thread and returns
  `{ removable, is_main, open, window_key?, window_folder_count, risks:[{kind,
  detail}], info:[…] }`. `risks` enumerates what a removal would **lose** —
  modified tracked files, untracked files (ignoring `.gitignore`d), an in-progress
  rebase/merge/cherry-pick (`repo.state()`), and commits reachable only from a
  detached HEAD (GC'd on removal). Unpushed commits on a **named** branch are
  `info` only, never a risk: the branch survives. `removable && risks.is_empty()`
  → the extension proceeds with **no** dialog; any risk → a modal confirm listing
  them.
- **Phase 2 — execute** (`confirmed:true`, or any `remove:false`). Signals the
  owning window(s), waits bounded for them to close, then (for `remove:true`)
  removes the worktree.

A "Close Window" (`remove:false`, the main working tree) skips git inspection
entirely — there is nothing to delete and nothing to confirm.

### 2. Deletability keys solely on `is_main`, guarded defensively in the daemon

The adapter derives `is_main` from `repo.is_worktree()` (the common-dir structural
fact), never from the branch name — so a linked worktree on the default branch is
fully deletable and its branch is kept. The daemon **re-validates on execute** and
refuses `remove:true` on a main working tree regardless of what the client sends:
the UI gating (a `contextValue` that offers *Close Window*, not *Close Worktree*,
for a main tree) is **not** the only guard. Removal is a real linked worktree of a
discoverable repo or it does not happen — never an arbitrary `rm`.

### 3. Removal is `git2`, not a shell-out

The worktree is pruned with `git2`'s `WorktreePruneOptions{ valid(true),
working_tree(true) }` — deleting both the admin files and the checked-out
directory — with `locked` left false so a `git worktree lock` is surfaced
("unlock first") rather than forced. `git2` is preferred over shelling `git
worktree remove` precisely to avoid the daemon-`PATH` problem the `code` launcher
already fights (launchd's minimal `PATH`). An already-gone path is idempotent
success.

### 4. Cross-window close rides the heartbeat reply

The execute phase signals every owning window **other than the requester** by
recording a per-key **close directive** in an in-memory set on the registry
(separate `Mutex`, never nested under the window map's). That directive is
surfaced — and cleared — on the target window's next `heartbeat` as an additive
`close: true` field on the reply, exactly the `{ known:false } → re-register`
precedent already in place. The daemon then waits, bounded (~20 s, generous
against the ~10 s heartbeat interval the directive rides), for each signalled
window to `unregister`; a window whose heartbeat has already gone stale counts as
closed, and one that never closes times out with an actionable error and the
worktree left intact. The **requester** (if it owns the target) is deliberately
*not* signalled: the daemon removes first and replies, and the extension closes
its own window on `ok`, dodging the ext-host-dies-mid-op race.

This ships v1 on the heartbeat directive because it is the smallest additive,
wire-compatible change with an exact precedent. Keying the `subscribe` push stream
to a window — near-instant, no ≤10 s wait — is the responsiveness fast-follow.

### 5. The companion routes two menu commands off an enriched `contextValue`

`worktreeContextValue` now encodes the structural role (`.main`/`.linked`)
alongside the open state, yielding six variants. **Close Window** gates on
`/worktree\.(current|open)\.main/`, **Close Worktree** on `/worktree\..*linked/`;
a main tree with no window matches neither (nothing to close or delete). The
existing `/worktree/` "open" menu still matches all six (back-compat). The
handlers run phase-1 → conditional modal confirm → phase-2 inside
`withProgress`, close this window when the heartbeat reply carries the directive,
and surface an error dialog when the daemon is down (an explicit action, unlike
the silent reporter path).

The op stays **socket-only** — no `omni-dev worktrees close` CLI subcommand — so
there is no `insta` snapshot churn, matching `open`/`register`.

## Consequences

- **The first destructive capability on the socket — a real threat-model
  escalation, bounded exactly as everything else here is.** Before `close`, a
  socket *writer* could at most spawn `code` on a path (ADR-0048); now it can
  delete a linked worktree's files and close windows. It is same-user-bounded (the
  `0600` socket already requires the owning local user — no new principal gains
  anything), and the `git2`-enforced "real linked worktree of a discoverable repo,
  never the main tree" guard confines deletion to genuine worktrees. The guard is
  in the daemon, not the UI, so a malformed client cannot delete a main tree. No
  secret and no new state is persisted — the close directive is in-memory and lost
  on restart (an in-flight close then simply aborts and the user retries).
- **Data safety is a check, not a policy the client is trusted to enforce.** The
  daemon computes what a removal would lose and reports it; the extension only
  decides whether to prompt. A clean single-folder worktree deletes with no
  dialog; anything at risk — or a multi-root window whose other folders would also
  close — prompts first, and only `confirmed:true` proceeds.
- **Cross-window latency is the v1 cost of the smallest change.** A worktree open
  in another window closes within roughly one heartbeat interval (≤10 s) plus the
  window's own close/save time, shown under a progress notification. The
  keyed-push upgrade removes the wait; until then the ~20 s daemon-side bound and
  the "window did not close" timeout keep a stuck window from hanging the op.
- **`git2`, not a shell, keeps removal PATH-independent and lock-aware.** Pruning
  through `git2` sidesteps the launchd-`PATH` problem and lets the daemon refuse a
  locked worktree cleanly; a shell-out to `git worktree remove --force` is the
  documented fallback only if `git2` ever proves insufficient.
- **Wire-compatible and additive.** `close` is a new op; the `close` heartbeat
  field follows the `skip_serializing_if` convention (older windows read only
  `known`); `list`, `tree`, `open`, `subscribe`, the tray, and the reporter
  lifecycle are byte-for-byte unchanged.
- **Still Unix-only**, like the rest of the service and companion.

This supersedes nothing: it extends [ADR-0040](adr-0040.md) and
[ADR-0048](adr-0048.md) in place. See [ADR-0039](adr-0039.md) for the daemon
framework, and [docs/worktrees-service.md](../worktrees-service.md) for the
operator guide and the companion's full `close` wire contract.
