Compare commits

...
Author SHA1 Message Date
Miguel Palhas b3c73fd2cb test(maestro): assert the exported session env end to end through real tmux 2026-08-24 19:30:11 +01:00
Miguel Palhas 7d06bec21e feat(maestro): export MAESTRO_SESSION_ID and MAESTRO_URL into spawned sessions
Every tmux-spawned session now gets its UUID under MAESTRO_SESSION_ID and,
when the runner has wired its base URL from [daemon].bind_addr, that URL
under MAESTRO_URL (#103). The daemon-owned values override anything the
spawn request carried. Adopted sessions stay without them by design.
2026-08-24 19:30:11 +01:00
Miguel Palhas ad2e700391 fix(maestro): spawn opens a new tab instead of splitting
ci / web (push) Successful in 31s
ci / rust (push) Successful in 2m22s
ci / flake (push) Successful in 6m43s
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.

Scoped to the explicit Spawn affordances (the header button and the
sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the
fast-forward key deliberately keep splitting: both act on the session in
front of you, so a pane beside it is the point. Reusing an empty pane
(#72) is unchanged — that path never split anything.

Closes #100
2026-08-24 17:17:35 +01:00
Miguel Palhas 2dcef860a6 fix(maestro): group worktree sessions under their repository
The sidebar nested a worktree session under the worktree, so a repo grew
a <repo>/worktrees/<branch> folder holding one session of the same name,
and the repo stopped being where you look for the work.

The daemon recorded whatever base_repo the request named, which is the
worktree itself when spawning from inside one; it now records the
resolved main checkout. The web tree groups by that instead of the cwd.
The TUI sidebar is a flat list with no directory grouping, so it needs
no mirror.

Closes #99
2026-08-24 17:17:35 +01:00
Miguel Palhas 0bb5df7245 fix(maestro): write nix.conf before installing nix in CI
ci / web (push) Successful in 1m26s
ci / rust (push) Successful in 4m20s
ci / flake (push) Successful in 7m24s
The runner container is root with no `nixbld` group. The installer
writes `build-users-group = nixbld` regardless and then fails on it:

  error: the group 'nixbld' specified in 'build-users-group' does not exist

Writing the config first (and again after, in case the installer
replaced it) clears that and enables flakes in the same place.

Refs #98
2026-08-24 16:19:01 +01:00
Miguel Palhas cb070e4f84 fix(maestro): make the flake CI job work on the runner
ci / flake (push) Failing after 16s
ci / web (push) Successful in 37s
ci / rust (push) Successful in 5m9s
Two defects in the job as landed, both invisible locally:

- The multi-user installer needs systemd, which the act_runner container
  does not have. Single-user install instead.
- The installer does not enable flakes, so `nix build .#…` fails on a
  fresh install. A machine with them in nix.conf builds fine, which is
  why this passed locally and failed in CI.

Refs #98
2026-08-24 16:17:00 +01:00
Miguel Palhas 8fde211348 feat(maestro): spawn form v2 refinements and review fixes
ci / web (push) Successful in 26s
ci / flake (push) Failing after 21s
ci / rust (push) Successful in 2m30s
Closes the Spawn v2 milestone: six review findings from the first blitz
plus seven pieces of operator feedback on the draft pane, and a flake
build that had been silently broken.

Form:
- The draft form is capped and centred instead of stretching to the pane.
- The worktree section sits behind a collapsed "Advanced options"
  disclosure whose header says whether a worktree will be created.
- The two controls both labelled "Branch" are now "New branch name" and
  "Pick a branch", matching in both clients.
- Enter submits from any field; inside a picker it chooses first.

Directory picker, rebuilt in both clients from one written set of rules:
- Projects are boxes with full paths; worktrees are a second step that
  appears once a project is picked, with "+ new" as the default.
- Picking an existing worktree runs in it and leaves the create-worktree
  toggle off — the daemon never adopts, so running in a tree and owning
  it stay separate acts (docs/WORKTREES.md).

Fixes: the fast-forward spawn key never matched a shifted 'S'; openDraft
never reused an empty pane and dropped its seed; swapping draft panes
moved the data but not the forms; the gitroot probe fired per keystroke;
the projects rescan ignored its own TTL; a late /api/info reset a picked
preset; and CI now builds the flake packages, which had been failing
since the pnpmDeps hash went stale.
2026-08-24 16:13:17 +01:00
Miguel Palhas 31cdbd4f0e merge: Enter submits the draft form from any field
Closes #97
2026-08-24 15:52:28 +01:00
Miguel Palhas 0d06827b69 fix(maestro): keep Enter toggling the worktree checkbox, not submitting
Matches the TUI's WorktreeToggle, whose Enter also toggles rather than
submitting — otherwise the two clients would disagree on this one
field despite #97 requiring the same rule everywhere else.
2026-08-24 15:51:19 +01:00
Miguel Palhas 8a6cb81d3a feat(maestro): Enter submits the TUI draft pane from any field (#97)
DirPicker mirrors the web filter/picker split: typing narrows and the
next Enter submits with the highlighted project directly (nothing was
chosen yet); an explicit Up/Down move onto a different row is
mid-selection, so Enter there only chooses it and a further Enter (now
the active pick) submits. WorktreeChoice, BranchExisting, Preset and
BranchMode already apply their highlight eagerly on Up/Down/Left/Right,
so Enter always submits there. Advanced and WorktreeToggle keep
activating on Enter, matching the web form's own button controls.
2026-08-24 15:50:28 +01:00
Miguel Palhas 52f9d0679e merge: distinct labels for the worktree branch fields
Closes #94
2026-08-24 15:48:38 +01:00
Miguel Palhas e6ef236f89 fix(maestro): match the web form's distinct branch-field labels in the TUI (#94)
The draft pane's new-branch gutter was labelled "br:" in both branch
modes — since #94's web fix now says "New branch name" / "Pick a
branch", mirror the same distinction here: "new:" for the branch-name
input, "pick:" for the existing-branch list (which previously had no
label at all).
2026-08-24 15:44:06 +01:00
Miguel Palhas 5747c71800 fix(maestro): distinct labels for the worktree branch field (#94)
The new-branch name input and the existing-branch picker both had a
literal <label>Branch</label>, on top of "New branch"/"Existing branch"
mode radios — reading the form gave no clue what the field wanted.
Kept the mode radios (they name the choice, not the field) and gave
each field its own label: "New branch name" for the text input,
"Pick a branch" for the select.
2026-08-24 15:42:10 +01:00
Miguel Palhas 5b0a0c2001 feat(maestro): Enter submits the spawn form from any field (#97)
A project-box or worktree-choice picker button that isn't yet the
active pick takes the Enter to choose it instead; the next Enter, once
it's active, submits. The project filter resolves to the top filtered
project rather than sending whatever cwd was last set. The raw-cwd
autocomplete's own Enter (accept the highlight) keeps working via
e.defaultPrevented.
2026-08-24 15:38:44 +01:00
Miguel Palhas df9013ffdf merge: two-step worktree picker in the TUI draft
Keeps both #93 and #96 in the draft layout: the worktree toggle and body
stay behind the Advanced disclosure, while the worktree choice step
belongs to the directory picker (#95 rule 3) and stays visible once a
project is picked.

Closes #96
2026-08-24 15:30:02 +01:00
Miguel Palhas 39341b278c merge: collapse the worktree section by default in both clients
Closes #93
2026-08-24 15:27:38 +01:00
Miguel Palhas b646cc5adf Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/96-tui-picker 2026-08-24 15:25:29 +01:00
Miguel Palhas cfc125144f feat(maestro): two-step worktree picker in the TUI draft
Mirrors the web project-picker redesign (#95) in the TUI (#96): step one
lists one box per repository (name, full path); picking one reveals step
two — "+ new" then that repository's worktrees newest-first, defaulting
to "+ new" except on a box that is itself a linked worktree. "+ new"
creates (cwd is the repo, create-worktree on); an existing worktree runs
in it (cwd is that worktree, create-worktree off). Neither step probes
/api/gitroot — only the free-text "Other directory…" hatch does.

Replaces the flat indented project+worktree list from #91/#64 with
projects::group_projects-backed DraftGroup boxes; the filter still
matches a repository via its own name/path or any of its worktrees.
2026-08-24 15:25:27 +01:00
Miguel Palhas cd66dbf7d8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/93-collapse-worktree
# Conflicts:
#	web/src/spawnForm.ts
2026-08-24 15:22:16 +01:00
Miguel Palhas 6bb812f6bd feat(maestro): collapse the TUI draft's worktree section by default (#93)
Mirrors the web spawn form: worktree/branch controls sit behind a new
"Advanced options" disclosure line (bracket [+]/[-], toggled by Enter
or Space, closed by default) instead of always showing the checkbox
and branch rows. The header states whether a worktree will be created
so #91's off-by-default for a worktree row is visible without
expanding. Collapsed fields drop out of Tab order; expanding only adds
rows below the disclosure, never moving the ones above it.
2026-08-24 15:20:35 +01:00
Miguel Palhas d3098a9400 merge: build flake packages in CI so a stale pnpmDeps hash fails loudly
Closes #98
2026-08-24 15:16:30 +01:00
Miguel Palhas 5de04af36a feat(maestro): collapse the spawn form worktree section by default (#93)
Closed by default behind a disclosure reusing the sidebar folder-header
affordance (catalog.ts), since most spawns take the default target and
the section was the largest block in the form. The collapsed header
states whether a worktree will be created, so #91's off-by-default for
a worktree row stays visible without expanding.

Fixes a latent [hidden] + display:flex bug for the new .wt-collapsible
element (author CSS beats the UA [hidden] rule at equal specificity);
.wt-body has the same bug pre-existing and is out of scope here.
2026-08-24 15:15:47 +01:00
Miguel Palhas 36cb03961c ci(maestro): build flake packages so a stale pnpmDeps hash fails loudly
A web/pnpm-lock.yaml change invalidates the pinned pnpmDeps.hash in
flake.nix silently; nix build .#maestro-web then stays broken until
someone notices by hand (#98). Add a flake job that installs nix and
builds the packaged outputs on every CI run.
2026-08-24 15:14:43 +01:00
Miguel Palhas 5e34ce7a61 merge: pick a project, then its worktree
Closes #95
2026-08-24 15:10:27 +01:00
Miguel Palhas 1fa9ce798e Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/95-project-picker
# Conflicts:
#	web/src/spawnFormWorktree.test.ts
2026-08-24 15:09:05 +01:00
Miguel Palhas 2c89902829 feat(maestro): pick a project, then its worktree
The working-directory picker was a flat list mixing repositories with
their worktrees, each row repeating the project name as an abbreviated
path. It is now two steps: a grid of project boxes carrying the full
unabbreviated path, then — only once a project is picked — that
repository's worktrees with "+ new" as the default.

"+ new" creates a worktree; picking an existing one runs in it and
leaves the create-worktree toggle off, since the daemon never adopts
(docs/WORKTREES.md). That is #91's rule, now an explicit choice rather
than an inference from which row was clicked.

Also fixes `.raw-wrap[hidden]`: `display: flex` outranked the UA sheet's
`[hidden]`, so the free-text cwd input showed even when hidden.

The interaction rules are listed in spawnForm.ts for the TUI mirror (#96)
to follow.

Refs #95
2026-08-24 15:08:05 +01:00
Miguel Palhas 8949fdfdf6 merge: debounce the gitroot probe in both clients
Closes #74
2026-08-24 15:06:50 +01:00
Miguel Palhas 596e7e2f49 feat(maestro): debounce the gitroot probe in the TUI draft
Typing in the raw path field fired one /api/gitroot probe per
keystroke (#74). The draft now schedules the probe 250ms out — matching
GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke
resets it; the 30Hz heartbeat polls for due probes so the burst's one
request fires after typing stops. The seq guard in resolve_gitroot is
untouched, so a late answer still cannot overwrite a newer input.
2026-08-24 15:04:47 +01:00
Miguel Palhas 16609eff9e feat(maestro): debounce the gitroot probe in the spawn form
Typing in the raw cwd field fired one /api/gitroot request per
keystroke (#74). Every keystroke now resets a 250ms window shared with
the TUI draft (GITROOT_PROBE_DEBOUNCE_MS there); probeToken still owns
ordering so a late answer can't overwrite a newer input.
2026-08-24 14:59:56 +01:00
Miguel Palhas 9b0e9751ec merge: stop a late /api/info resolution from clobbering a picked preset
Closes #76
2026-08-24 14:58:53 +01:00
Miguel Palhas d1ed4f1fdc Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/76-preset-reset 2026-08-24 14:58:19 +01:00
Miguel Palhas 2c8758ed14 fix(maestro): stop a late /api/info resolution from clobbering a picked preset
A tile clicked, or a custom command typed, during the /api/info round trip
was discarded: applyInitialCommand() ran unconditionally a second time once
presets refined, re-selecting the first non-custom preset with no seed to
protect the operator's pick. Guard it with a commandTouched flag, mirroring
the nameTouched pattern already used for the name-batch race.

Fixes #76.
2026-08-24 14:58:07 +01:00
Miguel Palhas 2a2da7f779 merge: move draft forms along with swapped pane data
Closes #73
2026-08-24 14:55:55 +01:00
Miguel Palhas 5eab434647 merge: default the worktree toggle off in an existing worktree
Closes #91
2026-08-24 14:54:46 +01:00
Miguel Palhas 8e9ed580ea Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/73-draft-pane-swap 2026-08-24 14:54:26 +01:00
Miguel Palhas 2d5e2f1718 fix(maestro): move draft forms along with swapped pane data
Center-zone pane drag swapped dragged.draft/target.draft but left
draftForms cached under the old leaf id, so both panes kept showing
their pre-swap form values while writing into the swapped leaf.

Tear down both leaves' cached forms at swap time (same pattern as
openDraft's stale-form teardown, #72) so render() remounts fresh
forms seeded from the swapped data.

Fixes #73
2026-08-24 14:54:18 +01:00
Miguel Palhas b80d604408 fix(maestro): default the worktree toggle off when the directory is itself a worktree (#91)
Picking a project row that is itself a linked git worktree used to
default 'Create git worktree' on, so spawning asked for a second
worktree of an already-checked-out branch and git refused. Both clients
now default the toggle off there: the session just runs in that existing
worktree with no worktree record (not adoption). Turning it back on by
hand still tries, per the issue's non-goal.
2026-08-24 14:52:17 +01:00
Miguel Palhas 56d1afe9bd merge: cap and centre the spawn form's width
Closes #92
2026-08-24 14:48:45 +01:00
Miguel Palhas 73e9ecfc85 fix(maestro): cap and centre the spawn form's width
The form filled the full pane width, so on a 4K display every input
stretched thousands of pixels wide. Bound it to 60ch (in line with the
48ch/42ch precedents already in the type scale) and centre it with
margin auto; panes narrower than the cap still fill edge to edge.
2026-08-24 14:48:19 +01:00
Miguel Palhas 095f810176 merge: make shift-S resolve to the fast-forward spawn binding
Closes #70
2026-08-24 14:45:53 +01:00
Miguel Palhas 3e809db1bc merge: honour the project cache TTL in the background rescan
Closes #75
2026-08-24 14:42:54 +01:00
Miguel Palhas e8bd8a4952 merge: reuse an empty draft pane and keep its seed
Closes #72
2026-08-24 14:41:42 +01:00
Miguel PalhasandClaude Sonnet 5 a85773cef0 fix(maestro): make shift-S resolve to the fast-forward spawn binding
crossterm attaches KeyModifiers::SHIFT to every uppercase char, so a real
Shift+S delivered Char { c: 'S', shift: true }. canonical() turned that
into "shift-S", which the default "S" binding never matched, so
FastForwardSpawn was unreachable from the keyboard.

canonical() now drops the shift- prefix when the char is already
uppercase, since the case already says "shifted". Regression test drives
an actual crossterm KeyEvent through canonicalise() and the keymap,
rather than asserting on the binding string, per #70.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:39:53 +01:00
Miguel Palhas 9c6e06ec4f fix(maestro): reuse empty draft pane and keep its seed (#72)
openDraft's reuse check was `!target.draft`, dead after first render:
createSpawnForm auto-syncs its picked defaults into the leaf's draft
once its project load settles, so every empty pane has a non-null
draft a second later. `n` and Spawn always split instead of reusing
the pane on screen.

Reuse now checks only `!target.sessionId`, matching the codebase's own
definition of "empty" pane elsewhere. Reusing also drops the leaf's
stale form instance so a new seed is read by the rebuilt form instead
of being ignored by the old one.
2026-08-24 14:39:51 +01:00
Miguel Palhas 3029de667e fix(maestro): honour the project cache TTL in the background rescan (#75) 2026-08-24 14:39:10 +01:00
Miguel Palhas a8d693c0a3 fix(web): bump pnpmDeps hash for lockfile change
ci / web (push) Successful in 26s
ci / rust (push) Successful in 3m8s
2026-08-24 13:57:01 +01:00
Miguel Palhas 6c1bcf042c feat(maestro): spawn v2 and the worktree lifecycle redesign
ci / web (push) Successful in 25s
ci / rust (push) Failing after 1m40s
Merges two milestones. Spawn v2 (#53-#68) replaced the spawn modal with
an inline draft pane, added project scanning, branch pickers and name
generation across the web and TUI clients.

Worktree lifecycle (#77-#89) then redesigned what the daemon does with a
worktree it created, which is what Spawn v2's reviews kept getting wrong:

- The daemon never adopts a directory it finds. A path collision is
  sidestepped with a numeric suffix instead of resolved.
- A kill is refused when the worktree holds unsaved work; ignored files
  never count. --force ends the session and RETAINS the tree.
- A session that exits on its own applies the same predicate, from the
  same function, so the two paths cannot drift.
- Retained trees are persisted in state_dir, listed by GET /api/worktrees,
  and reclaimed explicitly. Reclaim removes the directory, never the branch.
- cleanup_on_kill is gone from the wire; what survives is decided by what
  is in the tree, not by what the spawn asked for.

docs/WORKTREES.md is the written contract, with the reasoning for each
non-obvious rule.
2026-08-24 13:36:53 +01:00
Miguel Palhas fb87a00906 merge: surface blocked work before the kill is attempted
Closes #83
2026-08-24 13:22:50 +01:00
Miguel Palhas d4dcfe7f00 feat(maestro): show blocked work on session rows and warn on kill in the web UI (#83) 2026-08-24 13:19:12 +01:00
Miguel Palhas 6161cabe77 feat(maestro): warn on kill and mark blocked sessions in the TUI (#83) 2026-08-24 13:08:13 +01:00
Miguel Palhas 524a848fc6 feat(maestro): show blocked work in the CLI table and warn on kill (#83) 2026-08-24 13:06:43 +01:00
Miguel Palhas 187bcb01d6 feat(maestro): refresh the blocked-work indicator in the watcher (#83)
run_blocked_work_pass rides the liveness tick but probes on a slower
cadence (DEFAULT_BLOCKED_WORK_INTERVAL, 15s): each probe is three git
subprocesses per session. Sessions without a worktree are filtered out
before any probe, so their indicator costs nothing. A failed probe keeps
the previous value; settled exits clear it.
2026-08-24 13:04:30 +01:00
Miguel Palhas 003e3839bc feat(maestro): carry a blocked-work indicator on Session (#83)
Session.blocked_work is Option<InTheWay>, refreshed by the daemon's
watcher; None for worktree-less sessions and until first probed.
Defaults on the wire so older payloads stay forward-compatible.
2026-08-24 12:58:16 +01:00
Miguel Palhas d2079cac3a merge: e2e coverage for the whole worktree lifecycle
Resolves the e2e conflict in favour of #88's helper, rewritten as a
struct literal now that cleanup_on_kill is gone — #88 had built the
spec through serde_json to dodge the field, which cost compile-time
checking for no reason once the field no longer exists.

Closes #88
2026-08-24 12:37:41 +01:00
Miguel Palhas 2083911baa test(maestro): cover the whole worktree lifecycle end to end (#88)
Every scenario from the contract, against a real daemon, tmux and git:
collision suffixes with both trees working, foreign directories
sidestepped without adoption, the guarded kill and its refusal
breakdown, save-then-kill succeeding, --force retaining the tree,
natural exits settling dirty and pristine worktrees differently, the
retained list with reasons, reclaim keeping the branch, and the store
surviving a daemon restart.

Worktree specs are built through their wire shape so the tests carry no
reference to WorktreeSpec.cleanup_on_kill, which #87 is removing.
2026-08-24 12:33:39 +01:00
Miguel Palhas b5450651d1 test(maestro): expose forced kill and retained-worktree calls in the e2e harness 2026-08-24 12:33:39 +01:00
Miguel Palhas 8aa007c73b fix(maestro): drop the cleanup_on_kill call sites #87 could not reach
Three in session_manager.rs arrived with #82 after #87 branched; four in
maestro-e2e were fenced off because #88 owns that crate. Removing the
field without them left the workspace uncompilable.
2026-08-24 12:12:02 +01:00
Miguel Palhas f4d12fa02d merge: drop cleanup_on_kill from the wire and the CLI
Closes #87
2026-08-24 12:11:31 +01:00
Miguel Palhas 31702e03e3 refactor(maestro): drop cleanup_on_kill from the wire and the CLI
Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after
#79 and #81 survival is decided by what is in a worktree, not by what
the spawn asked for. A field that lies is worse than a missing one.

Removes the field from the protocol, the --cleanup-on-kill flag from
the CLI, and the tests asserting its default.
2026-08-24 12:08:41 +01:00
Miguel Palhas dde8dd8cfc merge: apply the worktree rule when a session exits on its own
Closes #82
2026-08-24 11:57:51 +01:00
Miguel Palhas 2fc19a886e feat(maestro): apply the kill's worktree rule when a session exits on its own
When the liveness watcher finds the tmux session gone, it now settles
the worktree through the same probe_worktree/record_retention helpers
the kill uses: pristine trees are removed, anything else is retained as
exited-with-unsaved-work before anything is torn down. The dead session
drops its worktree reference once settled, so dismissing it neither
refuses nor retains twice. If the probe or store write fails, nothing
is destroyed and the tree stays attached to the record.

WORKTREES.md: written down the settle-then-drop and nothing-destroyed-
when-unrecordable rules alongside the existing exit rule.
2026-08-24 11:54:43 +01:00
Miguel Palhas 0b95d11aa6 Merge remote-tracking branch 'origin/blitz/worktree-lifecycle' into wl/82-natural-exit 2026-08-24 11:54:11 +01:00
Miguel Palhas a85084d0d9 refactor(maestro): extract shared probe and retain helpers from the kill path
probe_worktree and record_retention will be reused by the natural-exit
path (#82); kill's behavior is unchanged.
2026-08-24 11:49:36 +01:00
Miguel Palhas 5282ba3411 merge: web retained-worktrees view with reclaim
Closes #84
2026-08-24 11:45:34 +01:00
Miguel Palhas 6f698df9f4 feat(maestro): retained-worktrees view with reclaim (#84) 2026-08-24 11:44:22 +01:00
Miguel Palhas 8e8540460f feat(maestro): web wire types + client calls for retained worktrees 2026-08-24 11:44:22 +01:00
Miguel Palhas 2d796a3556 merge: kill --force ends the session and retains the worktree
Closes #81
2026-08-24 11:39:28 +01:00
Miguel Palhas 1984bf9472 merge: tui retained-worktrees view with reclaim
Closes #85
2026-08-24 11:36:08 +01:00
Miguel Palhas f67803acf5 Merge remote-tracking branch 'origin/blitz/worktree-lifecycle' into wl/81-force-retains
# Conflicts:
#	crates/maestro-protocol/src/client.rs
2026-08-24 11:35:08 +01:00
Miguel Palhas 0c135aa065 feat(maestro): maestro kill --force
The flag overrides the kill refusal (#81): the session ends, the guarded
worktree is retained, and the CLI prints where the work went — path,
branch, force-killed — instead of leaving the operator to wonder.

WORKTREES.md: write down that a forced kill of a tree whose guard is
clear removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
2026-08-24 11:34:15 +01:00
Miguel Palhas 56c8e35d6f feat(maestro): wire worktrees view into the tui loop (#85)
w opens the view; r confirms reclaim and says the branch survives.
The in-the-way summary uses InTheWay's Display impl so the TUI cannot
drift from the CLI's wording.
2026-08-24 11:32:22 +01:00
Miguel Palhas b96f18c7cb feat(maestro): forced kill ends the session and retains the worktree
--force overrides the kill refusal and nothing else (#81). The guarded
worktree is recorded in the retained store with reason force-killed —
never removed — and the outcome names what was kept so a client can say
where the work went. A tree whose guard is clear never needed the
override; force removes it as usual.

The store write happens before teardown, so a failed retention leaves
the session running and every artifact intact. DELETE /api/sessions/:id
takes ?force=true and answers 200 with KillOutcome; the unforced kill
keeps its 204.
2026-08-24 11:29:45 +01:00
Miguel Palhas 0166607064 feat(maestro): retained-worktrees view overlay (#85) 2026-08-24 11:26:56 +01:00
Miguel Palhas bb2f439302 feat(maestro): kill outcome payload and force query in protocol
DELETE /api/sessions/:id gains a force query param (#81). A forced kill
answers 200 with KillOutcome, naming what was retained and why; the
unforced kill keeps its 204. DaemonClient::kill_session takes the flag.
2026-08-24 11:20:00 +01:00
Miguel Palhas 58b4257c92 merge: maestro worktrees list / reclaim
Closes #86
2026-08-24 11:18:17 +01:00
Miguel Palhas 86443d195e merge: base update from blitz/worktree-lifecycle 2026-08-24 11:14:12 +01:00
Miguel Palhas abaa498b9f feat(maestro): worktrees list / reclaim CLI (#86)
maestro worktrees list mirrors the sessions table shape: hostname header,
home-abbreviated paths, and the in-the-way breakdown recomputed by the
daemon at read time. reclaim takes a required explicit ID — no
reclaim-everything — and its confirmation says the branch survives.
2026-08-24 11:13:55 +01:00
Miguel Palhas 62581d9f85 merge: refuse a kill when the worktree holds unsaved work
Collapses the two probes #79 and #80 grew in parallel into one:
worktree_probe::in_the_way is the single predicate behind the kill
refusal, the retained list, and the natural-exit classifier to come.
Keeps #79's in-worktree resolution (detached HEAD, missing directory)
and #80's InTheWay payload, and drops the duplicate UnsavedWork type.

Closes #79
2026-08-24 11:09:24 +01:00
Miguel Palhas 267f8a6712 feat(maestro): DaemonClient calls for retained worktrees 2026-08-24 11:06:30 +01:00
Miguel Palhas e9e0136a89 merge: /api/worktrees list and reclaim
Closes #80
2026-08-24 11:02:13 +01:00
Miguel Palhas 4921f62796 feat(maestro): GET /api/worktrees and DELETE reclaim endpoints 2026-08-24 10:59:14 +01:00
Miguel Palhas 44b9745e8c feat(maestro): read-side in-the-way probe and guard-free reclaim 2026-08-24 10:53:38 +01:00
Miguel Palhas cc8af94254 feat(maestro): worktree routes and retained-worktree payload in protocol 2026-08-24 10:48:12 +01:00
69 changed files with 7229 additions and 655 deletions
+31
View File
@@ -97,3 +97,34 @@ jobs:
- name: build
run: pnpm -C web build
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
# change invalidates the pin silently elsewhere, so the packaged build has to
# fail here too.
flake:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The runner container is root with no `nixbld` group, but the installer
# writes `build-users-group = nixbld` anyway and then fails on it, so the
# config goes in first. Flakes are off by default and `nix build .#…`
# needs them. Written again afterwards in case the installer replaced it.
- name: Install nix
run: |
mkdir -p /etc/nix
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
# The installer does not enable flakes, so `nix build .#…` needs the
# features passed explicitly — a local machine that has them in
# nix.conf will build fine while CI fails without this.
- name: build flake packages
run: |
nix --extra-experimental-features "nix-command flakes" build \
.#webFrontend \
.#maestro \
.#maestro-web \
--no-link
+113 -28
View File
@@ -41,6 +41,36 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// List retained worktrees, or reclaim one by ID (#86).
Worktrees(WorktreesArgs),
}
/// `maestro worktrees <list|reclaim>`
#[derive(Debug, clap::Args)]
pub struct WorktreesArgs {
#[command(subcommand)]
pub command: WorktreesCommand,
}
#[derive(Debug, Subcommand)]
pub enum WorktreesCommand {
/// List retained worktrees and what is in the way of reclaiming each.
List(ListWorktreesArgs),
/// Reclaim one retained worktree: removes the directory, never the branch.
Reclaim(ReclaimArgs),
}
#[derive(Debug, clap::Args)]
pub struct ListWorktreesArgs {
/// Output as JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct ReclaimArgs {
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
pub id: Uuid,
}
#[derive(Debug, clap::Args)]
@@ -72,11 +102,6 @@ pub struct SpawnArgs {
/// there.
#[arg(long)]
pub existing_branch: bool,
/// Remove the worktree when the session is killed. Kept for wire
/// compatibility (#56): the daemon now always cleans up on kill, guarded
/// against unsaved work.
#[arg(long, default_value_t = true)]
pub cleanup_on_kill: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
@@ -95,6 +120,11 @@ pub struct AttachArgs {
pub struct KillArgs {
/// Session ID to kill.
pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The
/// worktree is retained — never removed — and enters the retained store;
/// the output says where the work went.
#[arg(long)]
pub force: bool,
}
fn parse_kv(s: &str) -> Result<(String, String), String> {
@@ -133,7 +163,6 @@ mod tests {
"/repos/foo",
"--branch",
"feat/x",
"--cleanup-on-kill",
"-e",
"FOO=bar",
"-e",
@@ -144,7 +173,6 @@ mod tests {
Command::Spawn(a) => {
assert_eq!(a.command, "bash");
assert_eq!(a.branch.as_deref(), Some("feat/x"));
assert!(a.cleanup_on_kill);
assert_eq!(
a.env,
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
@@ -154,27 +182,6 @@ mod tests {
}
}
#[test]
fn cleanup_on_kill_defaults_true_for_the_wire() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repos/foo",
"--branch",
"feat/x",
])
.unwrap();
match cli.command {
Command::Spawn(a) => assert!(a.cleanup_on_kill),
_ => panic!("wrong subcommand"),
}
}
/// Review fix: without a flag the CLI always sent `create_branch: true`,
/// so the daemon's existing-branch path was unreachable and spawning onto
/// a branch that already existed just failed.
@@ -226,6 +233,32 @@ mod tests {
}
}
#[test]
fn parses_kill_with_force() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap();
match cli.command {
Command::Kill(a) => {
assert!(a.force);
assert_eq!(a.session_id.to_string(), id);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn kill_force_defaults_false() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap();
match cli.command {
Command::Kill(a) => {
assert!(!a.force);
assert_eq!(a.session_id.to_string(), id);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn rejects_env_without_equals() {
let err = Cli::try_parse_from([
@@ -241,4 +274,56 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("KEY=VALUE"));
}
#[test]
fn parses_worktrees_list() {
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::List(a) => assert!(a.json),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_worktrees_reclaim() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
/// Contract rule: reclaim refuses to run without an explicit target.
/// There is no reclaim-everything convenience.
#[test]
fn reclaim_requires_an_explicit_target() {
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::InvalidSubcommand
| clap::error::ErrorKind::MissingRequiredArgument
),
"{err}"
);
}
#[test]
fn worktrees_without_subcommand_shows_help() {
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
),
"{err}"
);
}
}
+54 -2
View File
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::Command;
use crate::cli::{Command, WorktreesCommand};
use crate::sessions::{run_kill, run_list, run_spawn};
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)]
@@ -61,9 +62,41 @@ pub async fn dispatch(
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
}
Command::Kill(args) => {
run_kill(&daemon, &args).await?;
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
// Best-effort: an unreachable list must not block the kill.
if !args.force {
warn_if_blocked(&daemon, args.session_id).await;
}
let outcome = run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
if let Some(w) = outcome.retained {
println!(
"worktree retained at {} (branch {}, force-killed)",
w.path.display(),
w.branch
);
}
}
Command::Worktrees(args) => match args.command {
WorktreesCommand::List(list_args) => {
let mut stdout = std::io::stdout().lock();
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
}
WorktreesCommand::Reclaim(reclaim_args) => {
// The daemon removes the directory and the store entry; the
// branch is never deleted — worth saying out loud (#86).
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
match entry {
Some(wt) => println!(
"reclaimed {} ({})\nbranch '{}' kept",
wt.id,
wt.path.display(),
wt.branch
),
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
}
}
},
}
Ok(())
}
@@ -71,6 +104,25 @@ pub async fn dispatch(
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
const CTRL_Q: u8 = 0x11;
/// #83: say "this will be refused" before the operator commits to it, in the
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
let Ok(sessions) = daemon.list_sessions().await else {
return;
};
if let Some(b) = sessions
.iter()
.find(|s| s.id == id)
.and_then(|s| s.blocked_work)
{
if !b.is_clear() {
eprintln!(
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
);
}
}
}
/// HTTP client pointed at the daemon's API. That surface needs no auth —
/// reaching `bind_addr` is the authorization.
#[allow(clippy::result_large_err)]
+1
View File
@@ -6,5 +6,6 @@ pub mod attach;
pub mod cli;
pub mod dispatch;
pub mod sessions;
pub mod worktrees;
pub use dispatch::{dispatch, CliError};
+60 -9
View File
@@ -43,7 +43,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
(Some(repo), Some(branch)) => Some(WorktreeSpec {
base_repo: repo.clone(),
branch: branch.clone(),
cleanup_on_kill: args.cleanup_on_kill,
create_branch: !args.existing_branch,
}),
_ => None,
@@ -59,9 +58,14 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
client.create_session(&body).await
}
/// `maestro kill`
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
client.kill_session(args.session_id).await
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
pub async fn run_kill(
client: &DaemonClient,
args: &KillArgs,
) -> Result<maestro_protocol::KillOutcome, ClientError> {
client.kill_session(args.session_id, args.force).await
}
#[allow(clippy::write_literal)]
@@ -89,8 +93,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
}
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
)
.ok();
for s in sorted {
@@ -111,18 +115,31 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
// #83: what an unforced kill would be refused for, in InTheWay's own
// words; blank while nothing blocks (or hasn't been probed yet).
let blocked = blocked_label(s);
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command
)
.ok();
}
}
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
/// Empty unless something actually blocks — "no unsaved work" would just be
/// noise on every row.
pub(crate) fn blocked_label(s: &Session) -> String {
s.blocked_work
.filter(|b| !b.is_clear())
.map(|b| b.to_string())
.unwrap_or_default()
}
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
/// UI's abbreviation so CLI and browser show the same directory shorthand.
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
@@ -160,6 +177,7 @@ mod tests {
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
@@ -220,4 +238,37 @@ mod tests {
super::print_session_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
}
/// #83: a blocked session shows what an unforced kill would be refused
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
#[test]
fn shows_the_blocked_work_summary_only_when_something_blocks() {
use maestro_protocol::InTheWay;
let mut blocked = session("/tmp", "claude", 1);
blocked.blocked_work = Some(InTheWay {
uncommitted_files: 3,
unpushed_commits: 2,
..InTheWay::default()
});
let mut clear = session("/tmp", "bash", 2);
clear.blocked_work = Some(InTheWay::default());
let unprobed = session("/tmp", "zsh", 3);
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
let out = String::from_utf8(buf).unwrap();
assert!(
out.contains("3 uncommitted files, 2 unpushed commits"),
"{out}"
);
// Clear and unprobed rows stay blank in the BLOCKED column.
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
for line in &lines[header + 1..] {
if !line.contains("uncommitted files") {
assert!(!line.contains("unsaved work"), "{line}");
}
}
}
}
+204
View File
@@ -0,0 +1,204 @@
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
//!
//! `reclaim` removes one directory; it never deletes the branch, which is
//! said out loud in the output because "reclaim" otherwise sounds like it
//! takes the commits too.
use std::io::Write;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
use crate::sessions::abbreviate_dir;
/// `maestro worktrees list`
pub async fn run_list(
client: &DaemonClient,
args: &ListWorktreesArgs,
out: &mut impl Write,
) -> Result<(), ClientError> {
let worktrees = client.list_worktrees().await?;
let info = client.daemon_info().await.unwrap_or_default();
if args.json {
let body = serde_json::json!({ "worktrees": worktrees });
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
return Ok(());
}
print_worktree_table(out, &worktrees, &info);
Ok(())
}
/// `maestro worktrees reclaim <id>`
///
/// Returns the entry as listed just before the reclaim, when it was found in
/// the listing, so the confirmation can name what was removed.
pub async fn run_reclaim(
client: &DaemonClient,
args: &ReclaimArgs,
) -> Result<Option<RetainedWorktree>, ClientError> {
let listed = client
.list_worktrees()
.await?
.into_iter()
.find(|w| w.id == args.id);
client.reclaim_worktree(args.id).await?;
Ok(listed)
}
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
if worktrees.is_empty() {
writeln!(out, "no retained worktrees").ok();
return;
}
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
sorted.sort_by_key(|w| w.retained_at);
if !info.hostname.is_empty() {
let warn = if maestro_protocol::is_compatible(&info.version) {
""
} else {
" (protocol mismatch)"
};
let version = if info.version.is_empty() {
String::new()
} else {
format!(" maestro {}{warn}", info.version)
};
writeln!(out, "\n{}{version}", info.hostname).ok();
}
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
"ID", "BRANCH", "PATH", "REASON"
)
.ok();
for w in sorted {
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} {}",
w.id,
w.branch,
abbreviate_dir(&w.path, &info.home_dir),
reason_label(w.reason),
blocker_label(&w.in_the_way),
)
.ok();
}
// The branch survives every reclaim — see docs/WORKTREES.md.
writeln!(
out,
"\nreclaim removes the directory only; branches are never deleted"
)
.ok();
}
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
match reason {
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
/// The same breakdown that blocks an unforced kill, as words: what is still
/// in the way, or `clear` when the work has since been committed and pushed.
fn blocker_label(in_the_way: &InTheWay) -> String {
if in_the_way.is_clear() {
return "clear".into();
}
let mut parts: Vec<String> = Vec::new();
if in_the_way.uncommitted_files > 0 {
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
}
if in_the_way.untracked_files > 0 {
parts.push(format!("{} untracked", in_the_way.untracked_files));
}
if in_the_way.unpushed_commits > 0 {
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
use uuid::Uuid;
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(id),
path: PathBuf::from(path),
base_repo: PathBuf::from("/home/naps/maestro"),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
..Default::default()
}
}
#[test]
fn formats_table() {
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
w.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("naps-laptop"), "{s}");
assert!(s.contains("feat/login"), "{s}");
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
assert!(s.contains("force-killed"), "{s}");
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
assert!(s.contains("IN THE WAY"), "{s}");
}
/// Contract rule, visible to the operator: reclaiming a tree must not
/// throw away commits on its branch.
#[test]
fn says_the_branch_survives() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("never deleted"), "{s}");
}
#[test]
fn clear_tree_says_clear() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("clear"), "{s}");
}
#[test]
fn empty_store() {
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
}
}
@@ -76,7 +76,7 @@ async fn killing_an_unknown_session_reports_the_daemon_reason() {
let err = mock
.client()
.kill_session(Uuid::new_v4())
.kill_session(Uuid::new_v4(), false)
.await
.expect_err("should fail");
@@ -102,6 +102,9 @@ async fn spawn_round_trips_through_the_daemon() {
assert_eq!(created.command, "bash");
assert_eq!(mock.session_count(), 1);
mock.client().kill_session(created.id).await.expect("kill");
mock.client()
.kill_session(created.id, false)
.await
.expect("kill");
assert_eq!(mock.session_count(), 0);
}
+11 -1
View File
@@ -56,6 +56,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
Some(p) => Tmux::new().with_tmpdir(p),
None => Tmux::new(),
};
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
config.retained_worktree_dir(),
));
let manager = SessionManager::new_with_replay(
std::sync::Arc::new(tmux),
sidecar,
@@ -66,7 +69,13 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
config.spawn_history_path(),
config.spawn_history_days,
));
))
// One store instance shared with the API handlers: forced kills (#81)
// record here, `GET /api/worktrees` (#80) reads back from here.
.with_retained_store((*retained).clone());
// Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr));
let recovered = manager.recover().await?;
tracing::info!(recovered, "adopted existing tmux sessions");
@@ -85,6 +94,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.home_dir.clone(),
config.spawn_presets.clone(),
projects,
retained,
));
let listener = TcpListener::bind(config.bind_addr).await?;
+1
View File
@@ -25,6 +25,7 @@ pub mod sidecar;
pub mod spawn_history;
pub mod tmux;
pub mod worktree;
pub mod worktree_probe;
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
pub use session::{SessionError, SessionManager};
+138 -3
View File
@@ -38,6 +38,9 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
/// reclaim endpoint (#80).
pub retained: Arc<crate::retained_store::RetainedStore>,
}
pub fn router(state: LocalApiState) -> Router {
@@ -50,6 +53,8 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::GITROOT, get(git_root))
.route(routes::BRANCHES, get(crate::branches::handler))
.route(routes::NAMES, get(names))
.route(routes::WORKTREES, get(list_worktrees))
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
.route(routes::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
@@ -96,6 +101,8 @@ fn session_error_status(code: &str) -> StatusCode {
"invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The retention record failed before anything was torn down (#81).
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -174,9 +181,29 @@ async fn create_session(
/// The full error info is passed through rather than rebuilt from
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
match s.manager.kill(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
///
/// With `force=true` (#81) the refusal is overridden and the worktree
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
/// Without it the historical `204` stands.
#[derive(Debug, Deserialize)]
struct KillQuery {
#[serde(default)]
force: bool,
}
async fn kill_session(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(q): Query<KillQuery>,
) -> Response {
match s.manager.kill(id, q.force).await {
Ok(outcome) => {
if q.force {
(StatusCode::OK, Json(outcome)).into_response()
} else {
StatusCode::NO_CONTENT.into_response()
}
}
Err(e) => {
let info = e.to_error_info();
let status = session_error_status(&info.code);
@@ -185,6 +212,112 @@ async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> R
}
}
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
/// breakdown recomputed at read time — never stored, so a tree whose work has
/// since been committed or pushed says so. Entries whose directory somebody
/// removed by hand are pruned on the way through.
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
let retained = s.retained.clone();
let listed = tokio::task::spawn_blocking(move || {
retained.prune_missing()?;
retained
.load_all()?
.iter()
.map(|entry| {
// A tree git cannot read (permissions, corrupt checkout)
// still gets listed; it just reports nothing in the way.
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
Ok(maestro_protocol::RetainedWorktree {
id: entry.id,
path: entry.path.clone(),
base_repo: entry.base_repo.clone(),
branch: entry.branch.clone(),
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
})
})
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
})
.await;
match listed {
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
Ok(Err(e)) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"store_failed",
&e.to_string(),
),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
),
}
}
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
/// the directory and drops the store entry, without consulting the guard —
/// reclaim is the deliberate destructive act, chosen while looking at what is
/// in the way. The branch itself is never deleted; commits on it survive.
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
let found = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<
Option<crate::retained_store::RetainedWorktreeRecord>,
crate::retained_store::RetainedStoreError,
> {
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
})
.await
};
let entry = match found {
Ok(Ok(Some(entry))) => entry,
Ok(Ok(None)) => {
return error_response(
StatusCode::NOT_FOUND,
"not_found",
"no such retained worktree",
)
}
Ok(Err(e)) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"store_failed",
&e.to_string(),
)
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
)
}
};
let outcome = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<(), String> {
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
.map_err(|e| e.to_string())?;
retained.remove(id).map_err(|e| e.to_string())?;
Ok(())
})
.await
};
match outcome {
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
// The directory could not be removed: leave the entry so the
// operator still sees the tree and can retry.
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
),
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
@@ -200,6 +333,7 @@ pub fn state_for(
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>,
retained: Arc<crate::retained_store::RetainedStore>,
) -> LocalApiState {
LocalApiState {
manager,
@@ -207,6 +341,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
retained,
}
}
+35 -13
View File
@@ -261,17 +261,13 @@ impl ProjectScanner {
}
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
/// Also the background timer's entry point: a tick inside the TTL is a
/// no-op (#75).
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
self.repos_inner(false).await
}
async fn repos_inner(&self, force: bool) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if !force {
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
let roots = self.roots.clone();
@@ -287,11 +283,11 @@ impl ProjectScanner {
repos
}
/// Rescan now, ignoring the TTL. Called by the background timer — going
/// through `repos()` would hit the cache and make most ticks no-ops,
/// leaving requests to pay for the rescan instead (#54 review).
/// One background-refresh tick: rescan only if the cached snapshot has
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
/// roots every [`REFRESH_INTERVAL`].
async fn refresh(&self) {
self.repos_inner(true).await;
self.repos().await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
@@ -544,6 +540,32 @@ mod tests {
assert_eq!(fresh[0].name, "late");
}
#[tokio::test]
async fn background_refresh_honours_the_ttl() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// A background tick inside the TTL must not re-walk the filesystem:
// the new repo stays invisible until the cache expires.
scanner.refresh().await;
assert!(scanner.repos().await.is_empty());
// Once the TTL lapses, the next tick picks it up.
scanner.ttl = Duration::ZERO;
scanner.refresh().await;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[test]
fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled();
+9 -18
View File
@@ -14,6 +14,9 @@
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
/// so the store and the wire payload cannot drift apart.
pub use maestro_protocol::RetainedReason;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -24,20 +27,8 @@ pub enum RetainedStoreError {
Json(#[from] serde_json::Error),
}
/// Why a worktree outlived its session. Kebab-case on the wire; new variants
/// are additive but clients should render unknown reasons generically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetainedReason {
/// The session was killed with `--force` while its worktree held unsaved
/// work.
ForceKilled,
/// The session ended by itself and its worktree held unsaved work.
ExitedWithUnsavedWork,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RetainedWorktree {
pub struct RetainedWorktreeRecord {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
@@ -71,8 +62,8 @@ impl RetainedStore {
base_repo: &Path,
branch: &str,
reason: RetainedReason,
) -> Result<RetainedWorktree, RetainedStoreError> {
let entry = RetainedWorktree {
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
let entry = RetainedWorktreeRecord {
id: Uuid::new_v4(),
path: path.to_path_buf(),
base_repo: base_repo.to_path_buf(),
@@ -85,7 +76,7 @@ impl RetainedStore {
}
/// Atomically write `entry`. Creates the directory if it does not exist.
pub fn overwrite(&self, entry: &RetainedWorktree) -> Result<(), RetainedStoreError> {
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
std::fs::create_dir_all(&self.dir)?;
let final_path = self.path_for(entry.id);
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
@@ -108,7 +99,7 @@ impl RetainedStore {
/// Read every entry. Files that are missing or malformed are skipped
/// silently: a corrupt record must not hide the others, and an empty or
/// absent directory simply means nothing is retained.
pub fn load_all(&self) -> Result<Vec<RetainedWorktree>, RetainedStoreError> {
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
@@ -124,7 +115,7 @@ impl RetainedStore {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktree>(&bytes) {
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
out.push(worktree);
}
}
+363 -29
View File
@@ -12,8 +12,8 @@ use std::time::Duration;
use chrono::Utc;
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
WorktreeSpec,
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
};
use tokio::sync::{broadcast, Mutex};
use uuid::Uuid;
@@ -33,6 +33,13 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
/// How often the liveness watcher polls tmux for sessions that disappeared.
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// How often the liveness watcher re-probes worktrees for the blocked-work
/// indicator (#83). Each probe costs three git subprocesses per session, so
/// the pass rides the same tick as everything else but runs on a slower
/// cadence: an indicator a few seconds stale still warns before the kill
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -44,6 +51,11 @@ pub struct SessionRecord {
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
/// Branch the worktree was created for, recorded at spawn time so a
/// forced kill (#81) can name it in the retained store without asking
/// git. Sessions adopted from a sidecar have no branch on record and
/// fall back to a probe.
pub worktree_branch: Option<String>,
pub bridge: PtyBridge,
}
@@ -65,7 +77,12 @@ pub enum SessionError {
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
KillGuarded(maestro_protocol::UnsavedWork),
KillGuarded(maestro_protocol::InTheWay),
/// A forced kill (#81) records the retained worktree *before* tearing
/// anything down; if that write fails nothing was destroyed, and the
/// error says so rather than silently dropping unrecorded work.
#[error("could not record the retained worktree: {0}")]
RetainFailed(String),
}
impl SessionError {
@@ -78,6 +95,7 @@ impl SessionError {
SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::KillGuarded(_) => "kill_guarded",
SessionError::RetainFailed(_) => "retain_failed",
};
let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b),
@@ -110,6 +128,14 @@ struct Inner {
/// `None` when not wired (tests, or before `with_spawn_history`); a present
/// but disabled (`0`-day) history no-ops internally.
spawn_history: Option<SpawnHistory>,
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
/// kill (#81) without one fails rather than destroying unrecorded work.
retained: Option<crate::retained_store::RetainedStore>,
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
/// the variable. A mutex because the runner sets it after construction,
/// possibly while clones are already serving.
daemon_url: std::sync::Mutex<Option<String>>,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
}
@@ -148,6 +174,8 @@ impl SessionManager {
fifo_dir,
replay_bytes,
spawn_history: None,
retained: None,
daemon_url: std::sync::Mutex::new(None),
sessions: Mutex::new(HashMap::new()),
events,
}),
@@ -170,6 +198,24 @@ impl SessionManager {
self
}
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
/// to record what it retained; without one, forcing a guarded kill fails
/// instead of destroying unrecorded work.
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_retained_store must be called before the SessionManager is cloned");
inner.retained = Some(store);
self
}
/// Set the daemon's own HTTP base URL (#103), exported to every session
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
/// is a plain setter: the daemon runner learns its bound address after
/// construction, and clones may already exist by then.
pub fn set_daemon_url(&self, url: String) {
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
/// deduped by dir. Empty when no history is wired or it's disabled.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
@@ -246,7 +292,11 @@ impl SessionManager {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
// Record the repository, not whatever the request named: a
// spawn from inside a linked worktree passes that worktree as
// `base_repo`, and clients group sessions by this path (#99).
let repo = crate::worktree::main_checkout_of(base_repo);
(path.clone(), Some(path), Some(repo))
}
None => (req.cwd.clone(), None, None),
};
@@ -268,7 +318,7 @@ impl SessionManager {
return Err(e.into());
}
};
let session_env = match sanitize_env(&req.env) {
let mut session_env = match sanitize_env(&req.env) {
Ok(env) => env,
Err(e) => {
let _ = std::fs::remove_file(&fifo_path);
@@ -276,6 +326,19 @@ impl SessionManager {
return Err(e);
}
};
// Identity + reporting target for the in-session hook (#106). Ours
// win over whatever the request carried, so a client cannot spoof
// another session's identity (#103).
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
.inner
.daemon_url
.lock()
.expect("daemon_url mutex")
.clone()
{
session_env.insert("MAESTRO_URL".into(), url);
}
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
@@ -319,6 +382,9 @@ impl SessionManager {
last_activity_at: Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
};
if let Err(e) = self.inner.sidecar.write(&session) {
@@ -340,6 +406,7 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
},
);
@@ -391,11 +458,17 @@ impl SessionManager {
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which retains rather than destroys (WORKTREES.md).
///
/// With `force` (#81) the refusal — and nothing else — is overridden:
/// the session ends and the guarded worktree is retained into the store
/// with reason `force-killed`, never removed. The outcome names what was
/// kept so a client can say where the work went. A tree whose guard is
/// clear never needed the override; force removes it as usual.
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
/// session tmux already lost fails, and that failure is not interesting.
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
let (worktree, alive) = {
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> {
let (worktree, alive, known_branch) = {
let sessions = self.inner.sessions.lock().await;
match sessions.get(&id) {
None => return Err(SessionError::NotFound(id)),
@@ -405,26 +478,48 @@ impl SessionManager {
.clone()
.zip(record.worktree_path.clone()),
record.session.status == SessionStatus::Running,
record.worktree_branch.clone(),
),
}
};
// The guard runs before anything is torn down: refusing must leave the
// session running, exactly as the operator left it.
if let Some((_, path)) = worktree {
let probe = tmux_blocking(move || crate::worktree::unsaved_work(&path))
.await
.map_err(SessionError::Worktree)?;
if !probe.is_empty() {
return Err(SessionError::KillGuarded(probe));
}
let probe = match &worktree {
Some(wt) => Some(probe_worktree(wt).await?),
None => None,
};
let guarded = probe.as_ref().is_some_and(|p| !p.is_clear());
if guarded && !force {
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
}
let mut retained = None;
if guarded {
let entry = record_retention(
&self.inner,
worktree.as_ref().expect("guarded ⇒ worktree"),
known_branch,
RetainedReason::ForceKilled,
)
.await?;
retained = Some((entry, probe.expect("guarded ⇒ probed")));
}
let removed = {
let mut sessions = self.inner.sessions.lock().await;
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, alive).await;
Ok(())
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
Ok(KillOutcome {
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
id: entry.id,
path: entry.path,
base_repo: entry.base_repo,
branch: entry.branch,
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
}),
})
}
/// Spawn a background task that polls tmux every `interval` and reaps any
@@ -435,6 +530,17 @@ impl SessionManager {
/// `SessionManager` clone is dropped — call this once from the daemon
/// startup path after [`Self::recover`] has populated the map.
pub fn start_liveness_watcher(&self, interval: Duration) {
self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL);
}
/// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83)
/// made explicit. Tests pass a small `blocked_work_interval` to watch the
/// probe actually happen.
pub fn start_liveness_watcher_with_blocked_work(
&self,
interval: Duration,
blocked_work_interval: Duration,
) {
let weak = Arc::downgrade(&self.inner);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
@@ -442,6 +548,9 @@ impl SessionManager {
// first tick fires immediately — skip it so we don't reap before
// the caller has a chance to create anything.
ticker.tick().await;
// Start "due" so the first live tick probes: a recovered daemon
// should not wait a full interval to show what blocks.
let mut since_probe = blocked_work_interval;
loop {
ticker.tick().await;
let Some(inner) = weak.upgrade() else {
@@ -451,6 +560,11 @@ impl SessionManager {
// Same tick, no new timer (NAP-340): refresh activity for the
// survivors and emit SessionUpdated on any transition.
run_activity_pass(&inner).await;
since_probe += interval;
if since_probe >= blocked_work_interval {
since_probe = Duration::ZERO;
run_blocked_work_pass(&inner).await;
}
}
});
}
@@ -487,6 +601,7 @@ impl SessionManager {
last_activity_at: Utc::now(),
current_cwd: None,
blocked_work: None,
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -511,6 +626,9 @@ impl SessionManager {
// directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
// The sidecar carries no branch; a forced kill of a
// recovered session probes git for it at retain time.
worktree_branch: None,
bridge,
},
);
@@ -587,6 +705,49 @@ where
.expect("tmux blocking task panicked")
}
/// What would be lost if this session's worktree were removed right now.
///
/// The single [`crate::worktree_probe::in_the_way`] call site behind both
/// endings of a session — the kill guard (#79) and the natural-exit
/// classifier (#82) — so neither can grow its own answer. Runs on the
/// blocking pool; callers invoke it before anything is torn down, because
/// refusing (#79), recording a retention (#81) and classifying an exit (#82)
/// must all see the tree as it stands.
async fn probe_worktree(
(_, path): &(PathBuf, PathBuf),
) -> Result<maestro_protocol::InTheWay, SessionError> {
let path = path.clone();
tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
.await
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
}
/// Record a guarded worktree into the retained store with `reason`, BEFORE
/// the caller tears anything down: a failed write then leaves every artifact
/// untouched instead of destroying work nothing records anymore. Shared by
/// the forced kill (`ForceKilled`, #81) and the natural exit
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
async fn record_retention(
inner: &Arc<Inner>,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
reason: RetainedReason,
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
let store = inner
.retained
.clone()
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
let branch = known_branch
.or_else(|| crate::worktree_probe::current_branch(path))
.unwrap_or_default();
let base_repo = base_repo.clone();
let path = path.clone();
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
.await
.map_err(|e| SessionError::RetainFailed(e.to_string()))
}
/// One pass of the liveness watcher: diff the in-memory session set against
/// the tmux-reported one, and reap anything we still track but tmux has lost.
async fn run_liveness_pass(inner: &Arc<Inner>) {
@@ -709,18 +870,79 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
}
}
/// Refresh each running session's blocked-work indicator (#83): what its
/// worktree holds right now that would block an unforced kill. Rides the
/// liveness tick on its own (slower) cadence — see
/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git
/// subprocesses per session.
///
/// Sessions without a worktree are filtered out BEFORE any probe: they can
/// never block a kill, so their indicator must cost nothing at all. A failed
/// probe keeps the previous value rather than clearing it — a transient git
/// error must not read as "nothing blocks". Like the activity pass, this only
/// emits `SessionUpdated` when the value actually changed.
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
let sessions = inner.sessions.lock().await;
sessions
.iter()
.filter(|(_, r)| r.session.status == SessionStatus::Running)
.filter_map(|(id, r)| {
let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?;
Some((*id, wt))
})
.collect()
};
if targets.is_empty() {
return;
}
for (id, wt) in targets {
// The kill guard's own probe (#79), so the indicator can never
// disagree with what a kill attempt would find.
let breakdown = match probe_worktree(&wt).await {
Ok(breakdown) => breakdown,
Err(e) => {
tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value");
continue;
}
};
let updated = {
let mut sessions = inner.sessions.lock().await;
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
if rec.session.blocked_work == Some(breakdown) {
None
} else {
rec.session.blocked_work = Some(breakdown);
Some(rec.session.clone())
}
};
if let Some(session) = updated {
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
}
}
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and announce it, keeping everything else intact.
/// and settle its worktree per the natural-exit rule (#82).
///
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
/// still attach and read the replay buffer — the last thing a crashed agent
/// printed. Artifacts are only released when the operator dismisses the session
/// via [`SessionManager::kill`].
/// printed. The worktree, though, is settled right away: a pristine tree is
/// removed, anything else is retained into the store with reason
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
/// so there is nothing to refuse. Once settled, the session drops its
/// worktree reference so a later dismissal neither re-probes nor retains
/// twice. When settling is impossible (probe or store failure) nothing is
/// destroyed: the tree stays attached to the dead record for a later forced
/// dismissal (#81).
///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
let updated = {
let flipped = {
let mut sessions = inner.sessions.lock().await;
match sessions.get_mut(&id) {
Some(record) if record.session.status == SessionStatus::Running => {
@@ -728,19 +950,114 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
Some(record.session.clone())
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
let branch = record.worktree_branch.clone();
Some((session, worktree, branch))
}
_ => None,
}
};
let Some(session) = updated else {
let Some((mut session, worktree, branch)) = flipped else {
return;
};
tracing::info!(%id, "session vanished from tmux — marking dead");
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt, branch).await {
// The tree is settled (removed or retained); a stale "in the
// way" would describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
record.session.blocked_work = None;
record.worktree_path = None;
record.worktree_base_repo = None;
record.worktree_branch = None;
}
}
}
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
/// before anything is torn down, exactly as the forced kill does (#81). A
/// removal that fails falls back to retaining, since whatever blocked it is
/// by definition something worth keeping.
///
/// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record.
async fn settle_worktree_on_exit(
inner: &Arc<Inner>,
id: Uuid,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
) -> bool {
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
Ok(probe) => probe,
Err(e) => {
tracing::error!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not probe the worktree; leaving it untouched"
);
return false;
}
};
if probe.is_clear() {
match inner.worktree_mgr.remove(base_repo, path) {
Ok(()) => {
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
return true;
}
Err(e) => {
tracing::warn!(
%id,
path = %path.display(),
error = ?e,
"natural exit: removal failed; falling back to retention"
);
}
}
}
match record_retention(
inner,
&(base_repo.clone(), path.clone()),
known_branch,
RetainedReason::ExitedWithUnsavedWork,
)
.await
{
Ok(entry) => {
tracing::info!(
%id,
path = %path.display(),
entry = %entry.id,
"natural exit: worktree retained with unsaved work"
);
true
}
Err(e) => {
tracing::error!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not record the retained worktree; leaving it untouched"
);
false
}
}
}
/// Tear down a session's artifacts after it has already been removed from the
/// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`],
/// which previously hand-rolled the same five-step choreography and only
@@ -750,7 +1067,17 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session).
async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
///
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
/// worktree instead of removing it: the store entry is already written, so
/// only the removal is skipped.
async fn cleanup_record(
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
retain_worktree: bool,
) {
if kill_tmux {
let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
@@ -759,11 +1086,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
// The kill already passed the unsaved-work guard (#79), so this removal
// is unconditional; the manager's own nested-worktree refusal is all that
// can still leave a tree on disk, and it says where.
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if let Err(e) = inner.worktree_mgr.remove(base, path) {
if retain_worktree {
tracing::info!(
%id,
path = %path.display(),
"forced kill: worktree retained in the store"
);
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this
// removal is unconditional; the manager's own nested-worktree
// refusal is all that can still leave a tree on disk, and it
// says where.
tracing::warn!(
%id,
path = %path.display(),
+19
View File
@@ -264,6 +264,25 @@ impl Tmux {
check(out)
}
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
/// Test-facing view of what a spawned session actually inherits.
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
let name = session_name(id);
let out = self
.cmd()
.args(["show-environment", "-t", &name])
.output()?;
check(out.clone())?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut env = BTreeMap::new();
for line in stdout.lines() {
if let Some((k, v)) = line.split_once('=') {
env.insert(k.to_string(), v.to_string());
}
}
Ok(env)
}
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
pub fn kill_server(&self) -> Result<(), TmuxError> {
let out = self.cmd().arg("kill-server").output()?;
+19 -59
View File
@@ -17,6 +17,17 @@ pub enum WorktreeError {
Guarded { path: PathBuf, reason: String },
}
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
fn from(e: crate::worktree_probe::ProbeError) -> Self {
match e {
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
WorktreeError::NonZero { code, stderr }
}
}
}
}
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
/// are findable from the repo; an operator-set root overrides that.
@@ -133,7 +144,7 @@ impl WorktreeManager {
/// Unconditional (#79): the guard moved off the worktree and onto the
/// kill, so by the time removal runs the caller has already chosen to
/// destroy the tree — the kill only gets here after
/// [`unsaved_work`] came back empty. Still refused for a tree holding a
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
/// nested registered worktree, which `git status` cannot see.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
// Gone from disk already (operator `rm -rf`, or a create that failed
@@ -163,6 +174,13 @@ impl WorktreeManager {
/// The repository's main checkout. `--git-common-dir` points at the shared
/// `.git` for every linked worktree, so its parent is the main tree. Falls
/// back to the input when git can't answer.
/// The repository a path belongs to: the main checkout, even when `path` is a
/// linked worktree. Clients group sessions by this (#99), and placement uses it
/// so a worktree is never nested inside another.
pub fn main_checkout_of(path: &Path) -> PathBuf {
main_checkout(path)
}
fn main_checkout(path: &Path) -> PathBuf {
let out = Command::new("git")
.arg("-C")
@@ -248,51 +266,6 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf())
}
/// The unsaved-work probe (#79): what would be lost if `path` were deleted.
///
/// One function, three answers, shared by every caller that must make the
/// same decision — the kill guard refuses on any non-empty result and the
/// natural-exit classifier (#82) retains on it — so the two paths cannot
/// drift. A worktree whose directory is already gone holds nothing to save.
pub fn unsaved_work(path: &Path) -> Result<maestro_protocol::UnsavedWork, WorktreeError> {
if std::fs::symlink_metadata(path).is_err() {
return Ok(maestro_protocol::UnsavedWork::default());
}
// `--ignored=no` keeps build output out of it; ignored files never block
// (#79), or no Rust checkout would ever be killable.
let out = run_git(path, &["status", "--porcelain", "--ignored=no"])?;
let status = String::from_utf8_lossy(&out.stdout);
let mut dirty = 0u32;
let mut untracked = 0u32;
for line in status.lines() {
if line.starts_with("??") {
untracked += 1;
} else {
dirty += 1;
}
}
// Detached HEAD has no branch; the tip itself still works as a starting
// point for the reachability query below.
let head = run_git(path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
let count = run_git(path, &["rev-list", "--count", tip, "--not", "--remotes"])?;
let unpushed: u32 = String::from_utf8_lossy(&count.stdout)
.trim()
.parse()
.map_err(|e| WorktreeError::NonZero {
code: 0,
stderr: format!("unparseable rev-list count: {e}"),
})?;
Ok(maestro_protocol::UnsavedWork {
dirty_tracked_files: dirty,
untracked_files: untracked,
unpushed_commits: unpushed,
})
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
@@ -304,19 +277,6 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
check(out)
}
fn run_git(dir: &Path, args: &[&str]) -> Result<Output, WorktreeError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(WorktreeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
fn check(out: Output) -> Result<(), WorktreeError> {
if out.status.success() {
Ok(())
+325
View File
@@ -0,0 +1,325 @@
//! The one git probe for what a worktree holds that would be lost, plus
//! guard-free reclaim (#79, #80).
//!
//! [`in_the_way`] is the single predicate behind all three paths: the kill
//! refusal (#79), the natural-exit classifier (#82), and the retained list
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
//! answer the question differently — #79 and #80 were built in parallel and
//! each grew its own copy, which is exactly the drift this collapses.
//!
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
//! the operator chose it while looking at what was in the way.
use std::path::Path;
use maestro_protocol::InTheWay;
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
}
/// What a kill refusal would report about the worktree at `path`, right now.
///
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
/// `target/`, and counting those would put every tree permanently in the way.
/// A directory that no longer exists reports nothing in the way: there is
/// nothing left to lose, and the store's own pruning drops the entry.
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
let mut breakdown = InTheWay::default();
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
// our purposes, and following it would probe the wrong tree.
if std::fs::symlink_metadata(path).is_err() {
return Ok(breakdown);
}
let status = run_git(
path,
[
"status",
"--porcelain",
"--ignored=no",
"--untracked-files=normal",
],
)?;
for line in String::from_utf8_lossy(&status.stdout).lines() {
if line.starts_with("??") {
breakdown.untracked_files += 1;
} else if !line.is_empty() {
breakdown.uncommitted_files += 1;
}
}
// Resolved inside the worktree so a detached HEAD still has a starting
// point; asking the base repo for `refs/heads/<branch>` fails there.
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
// Commits reachable from the tip but from no remote-tracking ref. A branch
// pushed anywhere counts as pushed; there is no "which remote" question to
// get wrong.
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
.trim()
.parse()
.unwrap_or(0);
Ok(breakdown)
}
/// Remove a retained worktree's directory without consulting any guard, per
/// the reclaim contract: the operator chose this while looking at what was in
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
/// the branch survive even when they were never pushed.
///
/// A directory that is already gone only leaves a stale registration behind;
/// `worktree prune` clears it so the branch can be spawned again.
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
if !path.exists() {
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
}
run_git(
base_repo,
[
"worktree",
"remove",
"--force",
path.to_str().expect("worktree path is valid utf-8"),
],
)
.map(|_| ())
}
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
/// branch on record, so a forced kill (#81) asks the tree itself when it
/// needs one for the retained store. `None` when git cannot answer.
pub fn current_branch(path: &Path) -> Option<String> {
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let mut cmd = std::process::Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(ProbeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::process::Command;
struct Repo {
root: tempfile::TempDir,
repo: PathBuf,
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A repo with a remote, one commit on `main`, and a linked worktree for
/// branch `feature` at `<repo>/worktrees/feature`.
fn repo_with_worktree() -> Repo {
let root = tempfile::tempdir().expect("tempdir");
let repo = root.path().join("repo");
let remote = root.path().join("remote.git");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&remote).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@t"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("README"), b"hello\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
git(&remote, &["init", "-q", "--bare"]);
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
git(&repo, &["push", "-q", "-u", "origin", "main"]);
git(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"feature",
"worktrees/feature",
],
);
Repo { root, repo }
}
fn path_str(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
impl Repo {
fn wt(&self) -> PathBuf {
self.repo.join("worktrees").join("feature")
}
fn commit_in_wt(&self, msg: &str) {
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
git(&self.wt(), &["add", "-A"]);
git(&self.wt(), &["commit", "-qm", msg]);
}
}
#[test]
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
let r = repo_with_worktree();
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown, InTheWay::default());
assert!(breakdown.is_clear());
}
#[test]
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
let dirty = in_the_way(&r.wt()).expect("probe");
assert_eq!(dirty.uncommitted_files, 1);
assert_eq!(dirty.untracked_files, 1);
assert_eq!(dirty.unpushed_commits, 0);
assert!(!dirty.is_clear());
// Committing clears exactly one dimension and creates another.
git(&r.wt(), &["add", "README"]);
git(&r.wt(), &["commit", "-qm", "wip"]);
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.uncommitted_files, 0);
assert_eq!(breakdown.untracked_files, 1);
assert_eq!(breakdown.unpushed_commits, 1);
}
#[test]
fn tracked_edits_count_as_uncommitted() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.untracked_files, 0);
}
#[test]
fn pushing_clears_unpushed_but_keeps_other_entries() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
r.commit_in_wt("wip");
let before = in_the_way(&r.wt()).expect("probe");
assert_eq!(before.unpushed_commits, 1);
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
// Same stored entry, different tree: the breakdown must move with it.
let after = in_the_way(&r.wt()).expect("probe");
assert_eq!(after.unpushed_commits, 0);
assert_eq!(after.untracked_files, 0);
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
let dirty = in_the_way(&r.wt()).expect("probe");
assert_eq!(dirty.untracked_files, 1);
}
#[test]
fn ignored_files_never_count() {
let r = repo_with_worktree();
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
std::fs::create_dir_all(r.wt().join("target")).unwrap();
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(
breakdown,
InTheWay {
untracked_files: 1,
..InTheWay::default()
},
"the .gitignore itself is untracked; target/ is not"
);
}
#[test]
fn a_missing_directory_reports_nothing_in_the_way() {
let r = repo_with_worktree();
let gone = r.root.path().join("nowhere");
let breakdown = in_the_way(&gone).expect("probe");
assert!(breakdown.is_clear());
}
#[test]
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
let r = repo_with_worktree();
r.commit_in_wt("precious");
reclaim(&r.repo, &r.wt()).expect("reclaim");
assert!(!r.wt().exists());
// A second reclaim over the now-missing path just prunes.
reclaim(&r.repo, &r.wt()).expect("reclaim again");
}
#[test]
fn reclaim_keeps_the_branch_and_its_commits() {
let r = repo_with_worktree();
r.commit_in_wt("precious");
let sha = {
let out = Command::new("git")
.arg("-C")
.arg(r.wt())
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
reclaim(&r.repo, &r.wt()).expect("reclaim");
let out = Command::new("git")
.arg("-C")
.arg(&r.repo)
.args(["rev-parse", "--verify", "refs/heads/feature"])
.output()
.unwrap();
assert!(
out.status.success(),
"branch must survive reclaim: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
}
#[test]
fn reclaim_is_guard_free_on_a_dirty_tree() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
assert!(!r.wt().exists());
}
}
+5 -4
View File
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test",
Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
@@ -121,7 +122,7 @@ async fn pty_round_trip_through_real_tmux() {
"marker not seen in {} bytes",
got.len()
);
let _ = h.manager.kill(id).await;
let _ = h.manager.kill(id, false).await;
}
#[tokio::test]
@@ -169,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() {
"client B missed marker"
);
let _ = h.manager.kill(id).await;
let _ = h.manager.kill(id, false).await;
}
#[tokio::test]
@@ -218,7 +219,7 @@ async fn sends_close_frame_when_session_is_killed() {
// give the broadcast subscriber a moment to register before killing.
tokio::time::sleep(Duration::from_millis(100)).await;
h.manager.kill(id).await.unwrap();
h.manager.kill(id, false).await.unwrap();
let mut saw_close = false;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
@@ -266,7 +267,7 @@ async fn ping_text_frame_gets_pong() {
}
}
assert!(saw_pong, "no Pong frame received");
let _ = h.manager.kill(id).await;
let _ = h.manager.kill(id, false).await;
}
#[tokio::test]
@@ -0,0 +1,116 @@
//! The performance contract of #83: the blocked-work pass must cost NO git
//! probe at all for a session without a worktree, while a session with one is
//! still probed.
//!
//! `git` itself is shimmed through PATH with a wrapper that logs every
//! invocation before exec'ing the real binary, so the assertion counts actual
//! probes rather than inferring them from state. This lives in its own test
//! binary because it mutates the process environment.
mod common;
use std::collections::HashMap;
use std::process::Command;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.to_path_buf(),
branch: branch.into(),
create_branch: true,
}),
}
}
#[tokio::test]
async fn a_session_without_a_worktree_costs_no_probe() {
let fx = DaemonFixture::new();
let log = TempDir::new().unwrap();
let log_path = log.path().join("git-calls.log");
// Session A owns a real (pristine, pushed) worktree; session B has none.
let repo_a = TempDir::new().unwrap();
init_repo(repo_a.path());
init_bare_repo(&repo_a.path().join("origin.git"));
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
fx.manager
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
.await
.expect("create A");
let cwd_b = TempDir::new().unwrap();
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
req_b.worktree = None;
fx.manager.create(req_b).await.expect("create B");
// Shim `git` AFTER setup so only watcher-driven probes get logged.
let real_git = String::from_utf8(
Command::new("sh")
.arg("-c")
.arg("command -v git")
.output()
.expect("locate git")
.stdout,
)
.unwrap()
.trim()
.to_string();
let shim_dir = TempDir::new().unwrap();
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
log_path.display(),
real_git,
);
std::fs::write(shim_dir.path().join("git"), script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
shim_dir.path().join("git"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
let old_path = std::env::var("PATH").unwrap_or_default();
std::env::set_var(
"PATH",
format!("{}:{}", shim_dir.path().display(), old_path),
);
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
);
// The pass demonstrably runs and probed A's tree...
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.snapshot()
.await
.iter()
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
})
.await;
std::env::set_var("PATH", &old_path);
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!calls.is_empty(),
"the pass never probed anything — the shim saw no git call at all"
);
// ...while B — no worktree, so nothing to probe — was never touched.
assert!(
!calls.contains(cwd_b.path().to_str().unwrap()),
"a worktree-less session was probed:\n{calls}"
);
}
+1
View File
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
"/home/test",
presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
+18
View File
@@ -176,6 +176,11 @@ impl DaemonFixture {
manager =
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
}
// Same wiring as production: the manager records retained worktrees
// into the same dir the API reads back from (#78/#81).
let retained =
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
manager = manager.with_retained_store(retained.clone());
Self {
tmux,
manager,
@@ -187,6 +192,10 @@ impl DaemonFixture {
state_dir.path().join("spawn-history.jsonl")
}
fn retained_path(state_dir: &TempDir) -> PathBuf {
state_dir.path().join("retained-worktrees")
}
pub fn spawn_history_path(&self) -> PathBuf {
Self::history_path(&self.state_dir)
}
@@ -195,6 +204,15 @@ impl DaemonFixture {
self.state_dir.path().join("sessions")
}
/// Retained-worktree store rooted at the fixture's own state dir (#78).
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
}
pub fn retained_dir(&self) -> PathBuf {
self.state_dir.path().join("retained-worktrees")
}
pub fn fifo_dir(&self) -> PathBuf {
self.state_dir.path().join("fifos")
}
+296 -3
View File
@@ -25,6 +25,13 @@ struct Harness {
_fx: DaemonFixture,
}
impl Harness {
/// Store over the same dir the manager records into.
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
self._fx.retained_store()
}
}
async fn spawn(hostname: &str) -> Harness {
spawn_with(
hostname,
@@ -48,6 +55,7 @@ async fn spawn_with(
"/home/test",
presets,
projects,
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
@@ -355,7 +363,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -380,11 +387,66 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
let body: ApiError = resp.json().await.unwrap();
assert_eq!(body.error.code, "kill_guarded");
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.dirty_tracked_files, 1);
assert_eq!(breakdown.uncommitted_files, 1);
assert!(h.manager.has(session.id).await, "the session keeps running");
}
/// `?force=true` on the wire (#81): the refusal is overridden, the session
/// ends, and the answer is 200 with the retained worktree named — path,
/// branch, reason — while the directory survives on disk.
#[tokio::test]
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let wt_path = repo
.path()
.join("worktrees")
.join("feat")
.join("guarded")
.to_path_buf();
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.query(&[("force", "true")])
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
let retained = body.retained.expect("force names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/guarded");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
// The tree would have blocked; force must not have removed it.
assert!(wt_path.exists(), "a forced kill never removes the worktree");
// And the store entry exists, so reclaim can find it (#80).
let all = h.fx_retained().load_all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
}
#[tokio::test]
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
@@ -475,7 +537,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
other => panic!("expected SessionAdded, got {other:?}"),
}
h.manager.kill(created.id).await.expect("kill");
h.manager.kill(created.id, false).await.expect("kill");
// Activity ticks may interleave; the removal is what we are waiting for.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
@@ -539,3 +601,234 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
}
}
}
// ---------------------------------------------------------------------------
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
// ---------------------------------------------------------------------------
/// `git -C dir args`, asserting success — the worktree endpoints are tested
/// against real git, like everything else in this crate.
fn git(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
struct RetainedFixture {
repo: tempfile::TempDir,
wt: std::path::PathBuf,
}
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
/// an uncommitted tracked edit and an unpushed commit.
fn retained_fixture() -> RetainedFixture {
let repo = tempfile::tempdir().unwrap();
common::init_repo(repo.path());
// A pushed main, so "unpushed" means commits made on the feature branch,
// not the shared history.
let remote = repo.path().join("remote.git");
std::fs::create_dir_all(&remote).unwrap();
let remote_s = remote.to_string_lossy().into_owned();
git(&remote, &["init", "-q", "--bare"]);
git(repo.path(), &["remote", "add", "origin", &remote_s]);
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
git(
repo.path(),
&[
"worktree",
"add",
"-q",
"-b",
"feat/login",
"worktrees/feat/login",
],
);
let wt = repo.path().join("worktrees").join("feat").join("login");
RetainedFixture { repo, wt }
}
impl RetainedFixture {
fn commit_in_wt(&self, msg: &str) {
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
git(&self.wt, &["add", "-A"]);
git(&self.wt, &["commit", "-qm", msg]);
}
fn branch_sha(&self, branch: &str) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(self.repo.path())
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.unwrap();
assert!(out.status.success(), "branch {branch} must exist");
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
}
#[tokio::test]
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
// Uncommitted tracked edit + an untracked file; the branch has no commits
// beyond main yet.
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed.len(), 1);
let w = &listed[0];
assert_eq!(w.id, entry.id);
assert_eq!(w.path, fx.wt);
assert_eq!(w.base_repo, fx.repo.path());
assert_eq!(w.branch, "feat/login");
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(w.in_the_way.uncommitted_files, 1);
assert_eq!(w.in_the_way.untracked_files, 1);
assert_eq!(w.in_the_way.unpushed_commits, 0);
// The operator saves the work themselves. The stored entry is untouched —
// the next read must report the tree as it is *now*, not as it was when
// it was retained.
fx.commit_in_wt("saved");
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed.len(), 1, "same entry, still retained");
assert_eq!(listed[0].id, entry.id);
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
}
#[tokio::test]
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
fx.commit_in_wt("precious");
let sha = fx.branch_sha("feat/login");
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!fx.wt.exists(), "reclaim removes the directory");
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(listed.is_empty(), "reclaim drops the store entry");
assert_eq!(
fx.branch_sha("feat/login"),
sha,
"the branch and its commits survive"
);
}
#[tokio::test]
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(store.load_all().unwrap().is_empty());
}
#[tokio::test]
async fn reclaim_unknown_id_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
let store = h._fx.retained_store();
store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
std::fs::remove_dir_all(&fx.wt).unwrap();
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(listed.is_empty(), "a hand-removed directory is not listed");
assert!(
store.load_all().unwrap().is_empty(),
"and its entry is gone"
);
}
@@ -30,6 +30,24 @@ impl Fx {
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
}
/// Variant whose manager knows the daemon's own base URL (#103), the way
/// the production runner wires it from `[daemon].bind_addr`.
fn with_daemon_url(url: &str) -> Self {
let fx = Self::new();
fx.manager.set_daemon_url(url.to_string());
fx
}
/// The env map of the (single, expected) spawn call.
fn spawned_env(&self) -> BTreeMap<String, String> {
let calls = self.fake.calls();
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
panic!("expected a spawn call, got {calls:?}");
};
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
env.clone()
}
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
@@ -211,7 +229,7 @@ async fn killing_a_dead_session_dismisses_it_without_touching_tmux() {
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
fx.manager.kill(s.id).await.expect("dismiss");
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(fx.manager.snapshot().await.is_empty());
assert!(
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
fx.manager.create(req).await.expect("create");
let spawned = match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
other => panic!("expected a spawn call, got {other:?}"),
};
let id = spawned
.get("MAESTRO_SESSION_ID")
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
.clone();
// The daemon-owned variable rides alongside whatever was requested.
let want: BTreeMap<String, String> = [
("EMPTY".to_string(), String::new()),
("FOO".to_string(), "bar".to_string()),
("MAESTRO_SESSION_ID".to_string(), id),
]
.into_iter()
.collect();
match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
other => panic!("expected a spawn call, got {other:?}"),
}
assert_eq!(&spawned, &want);
}
#[tokio::test]
@@ -371,7 +396,7 @@ async fn killing_an_unknown_session_is_reported_as_not_found() {
let fx = Fx::new();
let err = fx
.manager
.kill(uuid::Uuid::new_v4())
.kill(uuid::Uuid::new_v4(), false)
.await
.expect_err("should fail");
assert_eq!(err.to_error_info().code, "not_found");
@@ -396,3 +421,70 @@ fn attaching_spawn_history_after_cloning_panics() {
7,
));
}
/// Every tmux-spawned session learns who it is (#103): its own UUID under
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
/// it is running inside.
#[tokio::test]
async fn spawn_exports_maestro_session_id() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_SESSION_ID").map(String::as_str),
Some(&s.id.to_string()[..]),
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
);
}
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
#[tokio::test]
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert_eq!(
env.get("MAESTRO_URL").map(String::as_str),
Some("http://127.0.0.1:7070"),
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
);
}
/// Without a wired URL (tests, or a caller that never set one) only the
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
/// #106 hook's reports nowhere useful.
#[tokio::test]
async fn spawn_omits_maestro_url_when_not_wired() {
let fx = Fx::new();
fx.manager.create(fx.request("bash")).await.expect("create");
let env = fx.spawned_env();
assert!(
!env.contains_key("MAESTRO_URL"),
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
);
}
/// The daemon-owned variables win over anything the spawner sent: a client
/// cannot spoof another session's identity.
#[tokio::test]
async fn spawn_overrides_client_supplied_maestro_variables() {
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
let mut req = fx.request("bash");
req.env.insert(
"MAESTRO_SESSION_ID".into(),
"00000000-0000-0000-0000-000000000000".into(),
);
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
let s = fx.manager.create(req).await.expect("create");
let env = fx.spawned_env();
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7070".to_string())
);
}
+476 -20
View File
@@ -8,7 +8,9 @@ use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use common::{
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
};
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -44,7 +46,7 @@ async fn create_then_kill_round_trip() {
// FIFO should also exist
assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
fx.manager.kill(session.id).await.expect("kill");
fx.manager.kill(session.id, false).await.expect("kill");
assert!(!fx.manager.has(session.id).await);
assert!(!fx
.sidecar_dir()
@@ -79,7 +81,7 @@ async fn create_appends_to_spawn_history() {
assert_eq!(recents[0].cwd, cwd.path());
assert_eq!(recents[0].last_command, "sleep 30");
fx.manager.kill(session.id).await.expect("kill");
fx.manager.kill(session.id, false).await.expect("kill");
// History is durable — it survives the kill that deletes the sidecar.
assert_eq!(fx.manager.recent_dirs().len(), 1);
}
@@ -185,7 +187,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
"first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output",
);
fx.manager.kill(s.id).await.unwrap();
fx.manager.kill(s.id, false).await.unwrap();
}
/// Regression for NAP-228: after `recover`, PTY output must reach new
@@ -251,7 +253,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
String::from_utf8_lossy(&buf),
);
revived.kill(s.id).await.unwrap();
revived.kill(s.id, false).await.unwrap();
}
#[tokio::test]
@@ -300,7 +302,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed");
assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed");
fx.manager.kill(s.id).await.unwrap();
fx.manager.kill(s.id, false).await.unwrap();
let _ = (cwd, PathBuf::from("/"));
}
@@ -359,12 +361,73 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
);
// Dismissing it is what finally releases the artifacts.
fx.manager.kill(s.id).await.expect("dismiss");
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(!fx.manager.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
/// first "nothing blocks" for a pristine tree, then the breakdown once the
/// tree picks up unsaved work, so clients can warn before the kill attempt.
#[tokio::test]
async fn watcher_refreshes_the_blocked_work_indicator() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
fx.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/indicator".into(),
create_branch: true,
}),
})
.await
.expect("create");
// Pushed and clean: nothing in the way, and the indicator must say so.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
);
wait_for_async(Duration::from_secs(5), || async {
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
})
.await;
// Unsaved work appears; the next pass must surface it.
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
wait_for_async(Duration::from_secs(5), || async {
matches!(
&fx.manager.snapshot().await[0].blocked_work,
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
)
})
.await;
// A steady state must not re-emit every tick (#83 change-detection).
let mut events = fx.manager.events();
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
"an unchanged worktree must not re-emit SessionUpdated"
);
}
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
/// that says "don't clean up" loses its worktree — the branch was pushed, so
/// nothing in the tree is unique and removal goes ahead.
@@ -385,7 +448,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/keep".into(),
cleanup_on_kill: false,
create_branch: true,
}),
})
@@ -398,7 +460,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("kill");
fx.manager.kill(s.id, false).await.expect("kill");
assert!(!wt_path.exists(), "worktree must be removed on kill");
}
@@ -423,7 +485,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -432,11 +493,15 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
let err = fx.manager.kill(s.id).await.expect_err("kill must refuse");
let err = fx
.manager
.kill(s.id, false)
.await
.expect_err("kill must refuse");
let SessionError::KillGuarded(breakdown) = err else {
panic!("expected KillGuarded, got {err:?}");
};
assert_eq!(breakdown.dirty_tracked_files, 1);
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.untracked_files, 1);
assert!(
breakdown.unpushed_commits >= 1,
@@ -456,7 +521,10 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("kill after saving");
fx.manager
.kill(s.id, false)
.await
.expect("kill after saving");
assert!(!fx.manager.has(s.id).await);
}
@@ -480,18 +548,21 @@ async fn killing_refuses_until_the_branch_is_pushed() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/pushed".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks");
let err = fx
.manager
.kill(s.id, false)
.await
.expect_err("unpushed blocks");
let SessionError::KillGuarded(breakdown) = err else {
panic!("expected KillGuarded, got {err:?}");
};
assert_eq!(breakdown.dirty_tracked_files, 0);
assert_eq!(breakdown.uncommitted_files, 0);
assert_eq!(breakdown.untracked_files, 0);
assert!(breakdown.unpushed_commits >= 1);
@@ -499,11 +570,116 @@ async fn killing_refuses_until_the_branch_is_pushed() {
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("pushed branch kills");
fx.manager
.kill(s.id, false)
.await
.expect("pushed branch kills");
assert!(!wt_path.exists());
assert!(!fx.manager.has(s.id).await);
}
/// `--force` overrides the refusal and nothing else (#81): the session ends,
/// but the guarded worktree is RETAINED — never removed — and enters the
/// retained store with reason `force-killed`. The outcome names what was
/// kept, so a client can say where the work went.
#[tokio::test]
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("forced");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/forced".into(),
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
// Without force the same kill is refused (#79).
assert!(fx.manager.kill(s.id, false).await.is_err());
let outcome = fx
.manager
.kill(s.id, true)
.await
.expect("force overrides the refusal");
// The tree would have blocked, so force must NOT have removed it...
assert!(
wt_path.exists(),
"a forced kill never removes a tree that would have blocked"
);
// ...but the session itself still ends.
assert!(!fx.manager.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
let retained = outcome.retained.expect("the outcome names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/forced");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(
retained.in_the_way.uncommitted_files >= 1,
"the outcome carries what blocked the unforced kill"
);
// The store holds the same entry, so reclaim can find it later (#80).
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
assert_eq!(all[0].path, wt_path);
assert_eq!(all[0].branch, "feat/forced");
}
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
/// guard is clear never needed the override, so a forced kill removes it as
/// usual and retains nothing.
#[tokio::test]
async fn force_on_a_clear_worktree_removes_it_as_usual() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("clear");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/clear".into(),
create_branch: true,
}),
})
.await
.expect("create");
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
assert!(!wt_path.exists(), "a clear tree needs no override");
assert_eq!(outcome.retained, None);
assert!(fx.retained_store().load_all().unwrap().is_empty());
}
/// Ignored files NEVER block (#79): build output like `target/` exists in
/// every Rust checkout, and an ignored-only worktree stays killable.
#[tokio::test]
@@ -530,7 +706,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/rusty".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
@@ -543,13 +718,196 @@ async fn killing_an_ignored_only_worktree_succeeds() {
push_all_to_origin(repo.path(), origin.path());
fx.manager
.kill(s.id)
.kill(s.id, false)
.await
.expect("ignored files never block");
assert!(!wt_path.exists());
assert!(!fx.manager.has(s.id).await);
}
/// Natural exit (#82): a session that ends on its own never reaches the kill
/// path, so the watcher applies the kill's rule itself. A worktree holding
/// unsaved work is RETAINED into the store with reason
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
/// until dismissed — and dismissing it afterwards neither refuses nor
/// retains a second time.
#[tokio::test]
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("exited");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/exited".into(),
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
// The command finishes / the operator types exit: tmux loses the session.
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
// The tree would have blocked a kill, so the exit kept it...
assert!(
wt_path.exists(),
"a dirty worktree must be retained on natural exit"
);
// ...in the store, under the exit reason.
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].path, wt_path);
assert_eq!(all[0].branch, "feat/exited");
assert_eq!(
all[0].reason,
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
);
// The session itself stays until dismissed; its worktree reference is
// settled, so dismissal is neither refused nor retained twice.
// (`Dead` is observable a moment before the settle step finishes.)
wait_for_async(Duration::from_secs(3), || async {
let s = &fx.manager.snapshot().await[0];
s.worktree.is_none() && s.blocked_work.is_none()
})
.await;
let snap = fx.manager.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].worktree, None);
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(!fx.manager.has(s.id).await);
assert!(
wt_path.exists(),
"dismissal must not remove what was retained"
);
assert_eq!(
fx.retained_store().load_all().unwrap().len(),
1,
"dismissal must not record a second entry for the same tree"
);
}
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
/// nothing to keep — the worktree is removed and no store entry appears.
#[tokio::test]
async fn natural_exit_of_a_pristine_worktree_removes_it() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("clean");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/clean".into(),
create_branch: true,
}),
})
.await
.expect("create");
// Push everything so the branch has no unpushed commits: fully pristine.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(
!wt_path.exists(),
"a pristine worktree leaves nothing behind"
);
assert!(
fx.retained_store().load_all().unwrap().is_empty(),
"nothing was retained"
);
assert!(
fx.manager.has(s.id).await,
"the session stays until dismissed"
);
}
/// When nothing can be recorded — here: no retained store wired — the exit
/// destroys nothing. The tree stays on disk AND stays attached to the dead
/// record, where dismissing it later still goes through #81's forced-kill
/// retention. Losing the store must not lose the work.
#[tokio::test]
async fn natural_exit_without_a_retained_store_destroys_nothing() {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let manager = maestro_daemon::SessionManager::new(
Arc::new(tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
maestro_daemon::worktree::WorktreeManager::new(None),
state_dir.path().join("fifos"),
);
// Deliberately NO with_retained_store.
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
let s = manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/nostore".into(),
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(
wt_path.exists(),
"nothing may be destroyed when it cannot be recorded"
);
let snap = manager.snapshot().await;
assert!(
snap[0].worktree.is_some(),
"the tree must stay attached to the record so a later forced kill can retain it"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
@@ -587,5 +945,103 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(fx.manager.has(s.id).await, "live session got reaped");
fx.manager.kill(s.id).await.unwrap();
fx.manager.kill(s.id, false).await.unwrap();
}
/// #99: clients group a session under `worktree.base_repo`, so that field has
/// to name the repository even when the spawn request pointed at a linked
/// worktree. Recording the request verbatim put the session in a folder of its
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
#[tokio::test]
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
// A linked worktree of that repo, then a spawn whose `base_repo` names it
// rather than the main checkout — what happens when the operator spawns
// from inside a worktree.
let first = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/one".into(),
create_branch: true,
}),
})
.await
.expect("create first");
let inner_worktree = first.worktree.expect("first has a worktree").path;
let second = fx
.manager
.create(CreateSessionRequest {
cwd: inner_worktree.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: inner_worktree.clone(),
branch: "feat/two".into(),
create_branch: true,
}),
})
.await
.expect("create second");
let wt = second.worktree.expect("second has a worktree");
assert_eq!(
wt.base_repo,
repo.path()
.canonicalize()
.unwrap_or_else(|_| repo.path().to_path_buf()),
"base_repo must be the repository, not the worktree the request named",
);
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
}
/// #103: every tmux-spawned session carries the daemon's identity variables in
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
/// both without any out-of-band channel.
#[tokio::test]
async fn spawned_sessions_export_maestro_session_id_and_url() {
let fx = DaemonFixture::new();
fx.manager
.set_daemon_url("http://127.0.0.1:7071".to_string());
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let env = fx
.tmux
.tmux
.show_environment(s.id)
.expect("show-environment");
assert_eq!(
env.get("MAESTRO_SESSION_ID"),
Some(&s.id.to_string()),
"session env: {env:?}"
);
assert_eq!(
env.get("MAESTRO_URL"),
Some(&"http://127.0.0.1:7071".to_string())
);
fx.manager.kill(s.id, false).await.expect("kill");
}
+1
View File
@@ -28,6 +28,7 @@ fn sample(id: Uuid) -> Session {
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
}
}
+17 -16
View File
@@ -7,7 +7,8 @@ use std::path::PathBuf;
use std::process::Command;
use common::{init_bare_repo, init_repo, push_all_to_origin};
use maestro_daemon::worktree::{unsaved_work, WorktreeManager};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::worktree_probe::in_the_way;
use tempfile::TempDir;
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
@@ -145,8 +146,8 @@ fn the_probe_counts_uncommitted_tracked_changes() {
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 1);
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 0);
}
@@ -162,13 +163,13 @@ fn the_probe_counts_untracked_files() {
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
let w = unsaved_work(&path).expect("probe");
let w = in_the_way(&path).expect("probe");
assert_eq!(w.untracked_files, 2);
assert_eq!(w.dirty_tracked_files, 0);
assert_eq!(w.uncommitted_files, 0);
run_git(&path, &["add", "notes.txt"]);
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 1, "staged counts as uncommitted");
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
assert_eq!(w.untracked_files, 1);
}
@@ -190,16 +191,16 @@ fn the_probe_ignores_ignored_files() {
std::fs::create_dir_all(path.join("target")).unwrap();
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 0);
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 0);
assert_eq!(w.untracked_files, 0);
// With the branch pushed too, an ignored-only tree reads fully clean.
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = unsaved_work(&path).expect("probe after push");
assert!(w.is_empty(), "ignored-only worktree must read clean: {w:?}");
let w = in_the_way(&path).expect("probe after push");
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
}
/// Commits reachable from no remote-tracking ref block; pushing the branch
@@ -214,7 +215,7 @@ fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/branch").expect("add");
let w = unsaved_work(&path).expect("probe");
let w = in_the_way(&path).expect("probe");
assert!(
w.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref: {w:?}"
@@ -224,9 +225,9 @@ fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = unsaved_work(&path).expect("probe after push");
let w = in_the_way(&path).expect("probe after push");
assert_eq!(w.unpushed_commits, 0, "{w:?}");
assert!(w.is_empty(), "pushed and clean must be empty: {w:?}");
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
}
/// The full breakdown names each blocker with its count.
@@ -241,8 +242,8 @@ fn the_probe_reports_every_blocker_at_once() {
std::fs::write(path.join("README"), b"edited\n").unwrap();
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
let w = unsaved_work(&path).expect("probe");
assert_eq!(w.dirty_tracked_files, 1);
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 1);
assert!(w.unpushed_commits >= 1);
let summary = w.to_string();
+17 -2
View File
@@ -12,7 +12,8 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
RetainedWorktree, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -61,7 +62,21 @@ impl TestClient {
}
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.kill_session(id).await?)
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
}
/// Kill with `force=true`: overrides a kill refusal and retains the
/// guarded worktree (#81). Answers with what was kept.
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
Ok(self.daemon.kill_session(id, true).await?)
}
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
Ok(self.daemon.list_worktrees().await?)
}
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.reclaim_worktree(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
+523 -34
View File
@@ -35,6 +35,17 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
/// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours.
fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec {
WorktreeSpec {
base_repo: base_repo.to_path_buf(),
branch: branch.into(),
create_branch,
}
}
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
@@ -42,12 +53,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.to_path_buf(),
branch: branch.into(),
cleanup_on_kill: true,
create_branch,
}),
worktree: Some(worktree_spec(base, branch, create_branch)),
}
}
@@ -141,12 +147,7 @@ async fn kill_removes_clean_worktree() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/e2e".into(),
cleanup_on_kill: true,
create_branch: true,
}),
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -160,7 +161,7 @@ async fn kill_removes_clean_worktree() {
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Give the daemon a moment to remove the worktree, then verify.
@@ -199,12 +200,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
@@ -219,7 +215,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.dirty_tracked_files, 1);
assert_eq!(breakdown.uncommitted_files, 1);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
@@ -262,12 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
};
let s = client
@@ -280,7 +271,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -328,7 +319,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
)
.await;
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -376,7 +367,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
"original worktree must be untouched"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill");
}
@@ -419,10 +410,481 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
"worktrees must never nest inside another worktree"
);
push_to_throwaway_origin(base.path());
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
// ---------------------------------------------------------------------------
// Worktree lifecycle: collisions, retention, reclaim (#88)
// ---------------------------------------------------------------------------
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
/// registered worktree the operator made by hand. The daemon sidesteps to the
/// suffixed directory and never touches the occupant — and both trees keep
/// working: two registered worktrees, two sessions, each running in its own.
#[tokio::test(flavor = "multi_thread")]
async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
// An operator-made worktree sits exactly where `feature/collide` would go.
// Its branch is a different one, so the requested branch stays creatable.
let squatter = base
.path()
.join("worktrees")
.join("feature")
.join("collide");
run_git(
&[
"worktree",
"add",
"-q",
"-b",
"operator/manual",
&squatter.to_string_lossy(),
],
base.path(),
);
let s = client
.spawn_session(&worktree_req(base.path(), "feature/collide", true))
.await
.expect("spawn onto occupied path");
let suffixed = base
.path()
.join("worktrees")
.join("feature")
.join("collide-2");
assert_eq!(
s.cwd, suffixed,
"the daemon must take the suffixed directory, not the occupied one"
);
assert!(suffixed.is_dir(), "suffixed worktree must exist on disk");
// Both trees are registered, distinct, and the squatter is byte-intact.
let registrations = git_worktree_list(base.path());
assert!(
registrations
.iter()
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
"operator's worktree must stay registered: {registrations:?}"
);
assert!(
registrations.iter().any(|l| l.contains("collide-2")),
"suffixed worktree must be registered too: {registrations:?}"
);
// Both sessions run inside their own tree at the same time.
let sessions = client.list_sessions().await.expect("list");
assert!(
sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed),
"session must keep running in its suffixed tree"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Only the daemon-made trees go; the operator's manual one survives.
let after = git_worktree_list(base.path());
assert!(
after
.iter()
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
"operator's worktree must outlive the sessions: {after:?}"
);
}
/// #77/#88: the target path holds a plain directory the daemon never made and
/// git has never heard of. The daemon neither adopts it nor errors — it builds
/// beside it and leaves every byte of it alone.
#[tokio::test(flavor = "multi_thread")]
async fn foreign_directory_is_sidestepped_never_adopted() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let squatter = base
.path()
.join("worktrees")
.join("feature")
.join("foreign");
std::fs::create_dir_all(&squatter).unwrap();
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
let s = client
.spawn_session(&worktree_req(base.path(), "feature/foreign", true))
.await
.expect("spawn must sidestep, not adopt or fail");
let suffixed = base
.path()
.join("worktrees")
.join("feature")
.join("foreign-2");
assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree");
assert_eq!(
std::fs::read(squatter.join("operator-notes.txt")).unwrap(),
b"mine\n",
"the foreign directory must be untouched"
);
let registrations = git_worktree_list(base.path());
let registered_paths: Vec<&str> = registrations
.iter()
.filter_map(|l| l.strip_prefix("worktree "))
.collect();
assert!(
!registered_paths
.iter()
.any(|p| *p == squatter.canonicalize().unwrap().display().to_string()),
"a plain directory must never be adopted as a worktree: {registered_paths:?}"
);
let _origin = throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
assert!(squatter.join("operator-notes.txt").exists());
}
/// #79/#88: killing with uncommitted changes is refused; committing and
/// pushing that work clears the guard and the very same kill then succeeds
/// and removes the tree. The session is the handle on the work: while it is
/// unsaved the session stays, once it is saved the tree goes.
#[tokio::test(flavor = "multi_thread")]
async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("guard");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/guard", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"work in progress\n").unwrap();
// Refused: the edit is uncommitted.
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.unpushed_commits, 0);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
assert!(
client
.list_sessions()
.await
.unwrap()
.iter()
.any(|x| x.id == s.id),
"a refused kill keeps the session running"
);
assert_eq!(
std::fs::read(wt.join("README")).unwrap(),
b"work in progress\n",
"the worktree is untouched by the refusal"
);
// The operator does exactly what the refusal asked for: save and push.
commit_all(&wt, "wip");
push_all(base.path());
client.kill_session(s.id).await.expect("kill after saving");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if !wt.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
!wt.exists(),
"a cleared worktree must be removed after kill"
);
let registrations = git_worktree_list(base.path());
assert!(
!registrations.iter().any(|l| l.contains("feature/guard")),
"removal must deregister the tree: {registrations:?}"
);
}
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
/// session anyway but RETAINS the worktree — force never destroys blocked
/// work, it parks it in the retained store with reason `force-killed`.
#[tokio::test(flavor = "multi_thread")]
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("local");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/local", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"local only\n").unwrap();
commit_all(&wt, "not pushed yet");
// Refused: the commit is on no remote-tracking ref.
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.unpushed_commits, 1);
assert_eq!(breakdown.uncommitted_files, 0);
}
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// Force overrides the refusal and nothing else: session gone, tree kept.
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
let retained = outcome
.retained
.expect("force must retain the guarded tree");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert_eq!(retained.path, wt);
assert_eq!(retained.branch, "feature/local");
assert_eq!(retained.in_the_way.unpushed_commits, 1);
assert!(
!client
.list_sessions()
.await
.unwrap()
.iter()
.any(|x| x.id == s.id),
"the forced kill ends the session"
);
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
assert!(
git_worktree_list(base.path())
.iter()
.any(|l| l.contains("feature/local")),
"the retained tree stays registered in git"
);
}
/// #82/#88: a session that exits on its own never reaches the kill path, but
/// its worktree follows the same rule. Dirty → retained with reason
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
#[tokio::test(flavor = "multi_thread")]
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let (_daemon, client) = bring_up().await;
// Two repos so the two cases cannot see each other's state.
let dirty_repo = TempDir::new().unwrap();
init_repo(dirty_repo.path());
let _origin = throwaway_origin(dirty_repo.path());
let pristine_repo = TempDir::new().unwrap();
init_repo(pristine_repo.path());
let _origin_pristine = throwaway_origin(pristine_repo.path());
let dirty_wt = dirty_repo
.path()
.join("worktrees")
.join("feature")
.join("messy");
let pristine_wt = pristine_repo
.path()
.join("worktrees")
.join("feature")
.join("tidy");
let _dirty = client
.spawn_session(&CreateSessionRequest {
cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
})
.await
.expect("spawn dirty");
let _pristine = client
.spawn_session(&create_req_cmd(
pristine_repo.path(),
"true",
"feature/tidy",
))
.await
.expect("spawn pristine");
assert!(dirty_wt.is_dir() && pristine_wt.is_dir());
// The watcher reaps dead tmux sessions every couple of ticks and settles
// each worktree accordingly. Poll for both outcomes.
wait_for_async(
"dirty exit retains its worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let path = dirty_wt.clone();
async move {
client
.list_worktrees()
.await
.ok()
.map(|wts| {
wts.iter().any(|w| {
w.path == path
&& w.reason
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
})
})
.unwrap_or(false)
}
},
)
.await;
wait_for_async(
"pristine exit removes its worktree entirely",
Duration::from_secs(20),
|| {
let client = client.clone();
let repo = pristine_repo.path().to_path_buf();
let path = pristine_wt.clone();
async move {
if path.exists() {
return false;
}
let listed = client.list_worktrees().await.ok();
let listed_clear = listed
.map(|wts| !wts.iter().any(|w| w.path == path))
.unwrap_or(false);
let registered = git_worktree_list(&repo);
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
}
},
)
.await;
// The dirty tree is still fully there, file included.
assert!(dirty_wt.is_dir());
assert_eq!(
std::fs::read(dirty_wt.join("scratch.txt")).unwrap(),
b"unsaved\n"
);
}
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(worktree_spec(cwd, branch, true)),
}
}
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
/// reason and a live in-the-way breakdown; reclaim removes the directory and
/// the store entry but NEVER the branch — its commits outlive the reclaim.
#[tokio::test(flavor = "multi_thread")]
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("parked");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
commit_all(&wt, "worth keeping");
let sha = branch_sha(&wt, "feature/parked");
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
assert!(outcome.retained.is_some());
let listed = client.list_worktrees().await.expect("list worktrees");
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
let entry = &listed[0];
assert_eq!(entry.path, wt);
assert_eq!(entry.branch, "feature/parked");
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(entry.in_the_way.unpushed_commits, 1);
client.reclaim_worktree(entry.id).await.expect("reclaim");
assert!(!wt.exists(), "reclaim removes the directory");
assert!(
client.list_worktrees().await.unwrap().is_empty(),
"reclaim drops the store entry"
);
// The whole point of reclaim: the branch and its commits survive.
assert_eq!(
branch_sha(base.path(), "feature/parked"),
sha,
"the branch must keep pointing at the same commit after reclaim"
);
}
/// The retained store is state-dir backed (#80): a daemon restart must not
/// orphan the trees it is holding — they are still on disk with entries
/// pointing at them, and still reclaimable afterwards.
#[tokio::test(flavor = "multi_thread")]
async fn retained_store_survives_a_daemon_restart() {
let (mut daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base
.path()
.join("worktrees")
.join("feature")
.join("durable");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
commit_all(&wt, "survives restarts");
client.kill_session_forced(s.id).await.expect("force kill");
daemon.restart().await;
let listed = client.list_worktrees().await.expect("list after restart");
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
assert_eq!(listed[0].path, wt);
assert_eq!(
listed[0].reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(wt.is_dir(), "the tree itself must survive the restart too");
client
.reclaim_worktree(listed[0].id)
.await
.expect("reclaim");
assert!(!wt.exists());
}
// ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66)
// ---------------------------------------------------------------------------
@@ -788,10 +1250,12 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
/// reachable from remote-tracking refs. Without one the kill guard (#79)
/// reads every commit as unpushed, and any worktree session refuses to die.
fn push_to_throwaway_origin(repo: &std::path::Path) {
/// Bare remote registered as `origin` on `repo`, seeded with every current
/// branch, so existing commits are reachable from remote-tracking refs.
/// Without one the kill guard (#79) reads every commit as unpushed, and any
/// worktree session refuses to die. The caller must keep the `TempDir` alive
/// while the remote is still needed; later pushes go through [`push_all`].
fn throwaway_origin(repo: &std::path::Path) -> TempDir {
let origin = tempfile::TempDir::new().unwrap();
run_git(
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
@@ -803,9 +1267,34 @@ fn push_to_throwaway_origin(repo: &std::path::Path) {
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
.output()
.expect("git remote add");
push_all(repo);
origin
}
fn push_all(repo: &std::path::Path) {
run_git(&["push", "-q", "--all", "origin"], repo);
}
fn commit_all(dir: &std::path::Path, message: &str) {
run_git(&["add", "-A"], dir);
run_git(&["commit", "-q", "-m", message], dir);
}
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.expect("git rev-parse");
assert!(
out.status.success(),
"branch {branch} missing: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
+34 -5
View File
@@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, Session,
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -114,12 +114,24 @@ impl DaemonClient {
self.post(routes::SESSIONS, req).await
}
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
/// Kill a session. With `force`, the daemon overrides the kill refusal
/// (#79) and retains the worktree instead of removing it (#81); the
/// outcome then names what was kept and where.
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
let query: &[(&str, &str)] = if force {
&[(routes::FORCE_QUERY, "true")]
} else {
&[]
};
let resp = self
.send(Method::DELETE, &routes::session(id), &[], None::<&()>)
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
.await?;
check(resp).await?;
Ok(())
let resp = check(resp).await?;
// An unforced kill keeps its historical `204 No Content`.
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(KillOutcome { retained: None });
}
json(resp).await
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
@@ -148,6 +160,23 @@ impl DaemonClient {
.await
}
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
/// breakdown as of this read.
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
self.get(routes::WORKTREES, &[]).await
}
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
/// daemon removes the directory and drops the store entry; the branch is
/// never deleted.
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
.await?;
check(resp).await?;
Ok(())
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+173 -37
View File
@@ -87,6 +87,13 @@ pub struct Session {
/// from daemons that predate the field.
#[serde(default)]
pub current_cwd: Option<PathBuf>,
/// Whether the session's worktree currently holds anything that would
/// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`.
/// Refreshed by the daemon's watcher; `None` for sessions without a
/// worktree and until the first probe. Defaults on the wire so payloads
/// from daemons that predate the field stay forward-compatible.
#[serde(default)]
pub blocked_work: Option<InTheWay>,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -119,11 +126,6 @@ pub enum ActivityState {
pub struct WorktreeSpec {
pub base_repo: PathBuf,
pub branch: String,
/// Kept on the wire for CLI compatibility (#56) but no longer a
/// user-facing choice: the daemon always cleans up on kill, guarded
/// against unsaved work. Defaults to `true`.
#[serde(default = "default_true")]
pub cleanup_on_kill: bool,
/// When false, `branch` must already exist and is checked out as-is
/// (#56); when true (the default, and what every payload meant before
/// the field existed) the daemon creates it with `worktree add -b`.
@@ -143,41 +145,79 @@ pub struct SessionWorktree {
pub base_repo: PathBuf,
}
// ---------------------------------------------------------------------------
// Unsaved-work breakdown (#79)
// ---------------------------------------------------------------------------
/// What a worktree holds that would be lost if its directory were destroyed.
///
/// The kill guard refuses `DELETE /api/sessions/:id` with this breakdown, and
/// natural exits (#82) classify with the same daemon-side probe, so the two
/// paths cannot drift. Ignored files never count: every Rust checkout has a
/// `target/`, and counting those would leave no session killable.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnsavedWork {
/// Tracked files with uncommitted modifications or staged changes.
#[serde(default)]
pub dirty_tracked_files: u32,
/// Untracked files that are not ignored.
#[serde(default)]
pub untracked_files: u32,
/// Commits on the branch reachable from no remote-tracking ref.
#[serde(default)]
pub unpushed_commits: u32,
/// Why a retained worktree outlived its session (#80). Same values the
/// daemon's retained store persists; new variants are additive but clients
/// should render unknown reasons generically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetainedReason {
/// The session was killed with `--force` while its worktree held unsaved
/// work.
ForceKilled,
/// The session ended by itself and its worktree held unsaved work.
ExitedWithUnsavedWork,
}
impl UnsavedWork {
pub fn is_empty(&self) -> bool {
self.dirty_tracked_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0
/// What a kill refusal would report about a worktree right now (#80) — the
/// same breakdown that blocks an unforced kill, recomputed on every read of
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
/// retained.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InTheWay {
/// Tracked files with uncommitted modifications.
#[serde(default)]
pub uncommitted_files: usize,
/// Untracked files that are not ignored (ignored files never block).
#[serde(default)]
pub untracked_files: usize,
/// Commits on the branch reachable from no remote-tracking ref.
#[serde(default)]
pub unpushed_commits: usize,
}
impl InTheWay {
/// Nothing blocks any more — the work has since been committed and pushed.
pub fn is_clear(&self) -> bool {
*self == Self::default()
}
}
impl std::fmt::Display for UnsavedWork {
/// "3 uncommitted files, 2 untracked files, 2 unpushed commits" — zero
/// parts are omitted; all-zero renders "no unsaved work".
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
/// in-the-way state as of this read. Reclaiming it via
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedWorktree {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
#[serde(default)]
pub in_the_way: InTheWay,
}
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
/// A forced kill ends the session and retains its worktree instead of
/// removing it; this names what was kept, where, and why, so a client can say
/// where the work went rather than leaving the operator to wonder.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KillOutcome {
/// Set when the worktree was retained with reason `force-killed`; `None`
/// when there was nothing to retain — no worktree at all, or one whose
/// guard was clear, which a forced kill removes as usual.
#[serde(default)]
pub retained: Option<RetainedWorktree>,
}
impl std::fmt::Display for InTheWay {
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
/// all-zero renders "no unsaved work". This is the phrasing every client
/// uses for both the kill refusal and the retained list, so the two
/// cannot describe the same tree differently.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts = [
(self.dirty_tracked_files, "uncommitted file"),
(self.uncommitted_files, "uncommitted file"),
(self.untracked_files, "untracked file"),
(self.unpushed_commits, "unpushed commit"),
];
@@ -307,7 +347,7 @@ pub struct ErrorInfo {
/// Set only by the kill guard (#79): the unsaved work that blocked
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unsaved_work: Option<UnsavedWork>,
pub unsaved_work: Option<InTheWay>,
}
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
@@ -498,6 +538,7 @@ mod tests {
.unwrap()
.with_timezone(&Utc),
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
}
}
@@ -517,6 +558,34 @@ mod tests {
assert_eq!(s.activity, ActivityState::Idle);
assert_eq!(s.last_activity_at, epoch());
assert!(s.current_cwd.is_none());
assert!(s.blocked_work.is_none());
}
#[test]
fn blocked_work_defaults_to_none_on_legacy_payload() {
// A pre-#83 daemon omits the indicator; it must deserialize to `None`
// rather than failing, and a present value round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.blocked_work, None);
let mut with_blocker = sample_session();
with_blocker.blocked_work = Some(InTheWay {
uncommitted_files: 3,
..InTheWay::default()
});
assert_eq!(
with_blocker.clone().blocked_work,
serde_json::from_value::<Session>(serde_json::to_value(&with_blocker).unwrap())
.unwrap()
.blocked_work
);
}
#[test]
@@ -556,7 +625,6 @@ mod tests {
worktree: Some(WorktreeSpec {
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
@@ -586,7 +654,6 @@ mod tests {
});
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
assert!(spec.create_branch);
assert!(spec.cleanup_on_kill);
}
#[test]
@@ -594,12 +661,81 @@ mod tests {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
}
fn sample_retained_worktree() -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
base_repo: PathBuf::from("/work/maestro"),
branch: "feat/login".into(),
reason: RetainedReason::ForceKilled,
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
.unwrap()
.with_timezone(&Utc),
in_the_way: InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
}
}
#[test]
fn retained_worktree_round_trips() {
let wt = sample_retained_worktree();
assert_eq!(wt, round_trip(&wt));
}
/// A payload from a daemon predating `in_the_way` still decodes; the
/// breakdown defaults to "nothing in the way".
#[test]
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
let legacy = serde_json::json!({
"id": Uuid::from_u128(1),
"path": "/w/t",
"base_repo": "/w",
"branch": "feat/login",
"reason": "force-killed",
"retained_at": "2026-08-24T10:00:00Z",
});
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
assert_eq!(wt.reason, RetainedReason::ForceKilled);
assert_eq!(wt.in_the_way, InTheWay::default());
assert!(wt.in_the_way.is_clear());
}
#[test]
fn retained_reason_serializes_kebab_case() {
assert_eq!(
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
serde_json::json!("force-killed")
);
assert_eq!(
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
serde_json::json!("exited-with-unsaved-work")
);
}
#[test]
fn kill_outcome_round_trips_a_retained_worktree() {
let outcome = KillOutcome {
retained: Some(sample_retained_worktree()),
};
assert_eq!(outcome, round_trip(&outcome));
}
/// A kill with nothing to retain — no worktree, or a clean one removed as
/// usual — decodes from an absent field.
#[test]
fn kill_outcome_defaults_to_nothing_retained() {
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(outcome.retained, None);
}
#[test]
fn catalog_event_round_trips_and_tags() {
let session = sample_session();
+22
View File
@@ -26,6 +26,9 @@ pub const BRANCHES: &str = "/api/branches";
/// `GET` a batch of generated session handles (#53). Takes a `count` query
/// param (default 1, capped at [`maestro_names`]' batch cap).
pub const NAMES: &str = "/api/names";
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
/// breakdown recomputed on read.
pub const WORKTREES: &str = "/api/worktrees";
/// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events";
@@ -35,11 +38,20 @@ pub const PATH_QUERY: &str = "path";
/// Name of the query parameter [`NAMES`] takes.
pub const COUNT_QUERY: &str = "count";
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
/// `force=true` overrides the kill refusal and retains the worktree.
pub const FORCE_QUERY: &str = "force";
/// `DELETE /api/sessions/:id`.
pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
}
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
pub fn worktree(id: Uuid) -> String {
format!("{WORKTREES}/{id}")
}
/// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
@@ -50,6 +62,7 @@ pub fn attach(id: Uuid) -> String {
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const ATTACH: &str = "/api/attach/:id";
pub const WORKTREE: &str = "/api/worktrees/:id";
}
/// A daemon base URL that could not be turned into a WebSocket base.
@@ -160,6 +173,10 @@ mod tests {
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
worktree(id()),
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -178,6 +195,10 @@ mod tests {
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::WORKTREE.trim_end_matches(":id"),
worktree(id()).trim_end_matches(&id().to_string())
);
}
#[test]
@@ -191,6 +212,7 @@ mod tests {
GITROOT,
BRANCHES,
NAMES,
WORKTREES,
EVENTS,
] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
+1
View File
@@ -43,6 +43,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
activity: Default::default(),
last_activity_at: Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
+3
View File
@@ -43,6 +43,8 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
@@ -84,6 +86,7 @@ impl Action {
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
+323 -22
View File
@@ -18,7 +18,9 @@ use std::time::Duration;
use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
use maestro_protocol::{
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
@@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::worktrees::WorktreesView;
use crate::TuiError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -55,7 +58,8 @@ pub struct App {
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
/// Results of draft data loads, delivered back to the event loop.
/// Results of draft data loads (and the worktrees view fetch, #85),
/// delivered back to the event loop.
///
/// These are network calls — `/api/projects` on a cold cache walks all of
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
@@ -66,6 +70,9 @@ pub struct App {
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
/// `GET /api/names` (#64).
http: reqwest::Client,
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
/// rather than in `AppState` so the overlay kind stays payload-free.
worktrees: WorktreesView,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -109,6 +116,11 @@ pub enum Effect {
},
/// Fetch the draft target's branches from `/api/branches` (#65).
LoadBranches(usize),
/// Fetch `/api/worktrees` for the retained-worktrees view (#85).
LoadWorktrees,
/// Reclaim one retained worktree (#85) — removes its directory; the
/// branch is never deleted.
ReclaimWorktree(Uuid),
}
impl App {
@@ -132,6 +144,7 @@ impl App {
chord: ChordState::new(chord_timeout),
daemon,
http,
worktrees: WorktreesView::default(),
draft_tx,
draft_rx,
region: RegionMap::default(),
@@ -162,7 +175,8 @@ impl App {
// Render on next loop iteration.
}
_ = tokio::time::sleep(Duration::from_millis(33)) => {
// 30 Hz heartbeat.
let effects = self.poll_due_probes_at(std::time::Instant::now());
self.perform(effects).await;
}
}
}
@@ -225,6 +239,8 @@ impl App {
self.spawn_probe_git_root(tab, seq, path)
}
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::LoadWorktrees => self.spawn_load_worktrees(),
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -247,6 +263,44 @@ impl App {
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Worktrees) => match key {
InputKey::Char { c: 'j', .. } | InputKey::Down => {
self.worktrees.move_highlight(1);
(OverlayDecision::Consumed, Vec::new())
}
InputKey::Char { c: 'k', .. } | InputKey::Up => {
self.worktrees.move_highlight(-1);
(OverlayDecision::Consumed, Vec::new())
}
InputKey::Char { c: 'r', .. } => {
if let Some(wt) = self.worktrees.selected() {
self.state.show_overlay(OverlayKind::ReclaimConfirm {
id: wt.id,
branch: wt.branch.clone(),
// Shared phrasing: InTheWay's Display impl is what
// the kill refusal and the web client use too.
blockers: wt.in_the_way.to_string(),
});
}
(OverlayDecision::Consumed, Vec::new())
}
InputKey::Char { c: 'q', .. } | InputKey::Esc => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::ReclaimConfirm { id, .. }) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)])
}
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
self.state.show_overlay(OverlayKind::Worktrees);
(OverlayDecision::Consumed, Vec::new())
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Help) => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
@@ -317,6 +371,10 @@ impl App {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
}
Action::ShowWorktrees => {
self.state.show_overlay(OverlayKind::Worktrees);
effects.push(Effect::LoadWorktrees);
}
}
effects
}
@@ -382,7 +440,7 @@ impl App {
if draft.take_name_refill() {
effects.push(Effect::RefillNames(tab_idx));
}
if let Some((seq, path)) = draft.take_gitroot_probe() {
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
effects.push(Effect::ProbeGitRoot {
tab: tab_idx,
seq,
@@ -395,6 +453,26 @@ impl App {
effects
}
/// Fire any draft gitroot probes whose debounce window has elapsed (#74).
/// The 30 Hz heartbeat calls this so a burst of typing issues its one
/// probe even after the last keystroke.
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let mut effects = Vec::new();
for tab_idx in 0..self.state.tabs.len() {
let Some(PaneRef::Draft(draft)) = self.state.tabs[tab_idx].pane.as_mut() else {
continue;
};
if let Some((seq, path)) = draft.take_gitroot_probe(now) {
effects.push(Effect::ProbeGitRoot {
tab: tab_idx,
seq,
path,
});
}
}
effects
}
/// Populate a freshly opened draft: presets from `/api/info` (falling back
/// to the built-ins already in place), projects from `/api/projects`, and
/// a first name batch. Applies only if that tab still shows the draft —
@@ -505,13 +583,22 @@ impl App {
self.fast_forward_spawn(info, projects, names).await;
return;
}
if let DraftUpdate::WorktreesLoaded {
worktrees,
home_dir,
} = update
{
self.worktrees.apply(worktrees);
self.worktrees.home_dir = home_dir;
return;
}
let want = match &update {
DraftUpdate::Data { draft, .. }
| DraftUpdate::Names { draft, .. }
| DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
};
// Find it by identity: closing a tab shifts every index to its right,
// so an in-flight load addressed by index would be dropped or land on
@@ -566,7 +653,7 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches);
}
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
}
if follow_up_branches {
self.spawn_load_branches(tab_idx);
@@ -667,12 +754,50 @@ impl App {
}
async fn submit_kill(&mut self, sid: Uuid) {
match self.daemon.kill_session(sid).await {
Ok(()) => self.release_session(sid),
match self.daemon.kill_session(sid, false).await {
Ok(outcome) => {
if let Some(w) = outcome.retained {
self.state
.toast(format!("worktree retained: {}", w.path.display()));
}
self.release_session(sid)
}
Err(e) => self.state.toast(format!("kill failed: {e}")),
}
}
/// Fetch the retained-worktree store without blocking the loop (#85) —
/// same off-loop pattern as the draft loaders.
fn spawn_load_worktrees(&self) {
let (daemon, _, _, tx) = self.loader_handles();
tokio::spawn(async move {
let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info());
let _ = tx.send(DraftUpdate::WorktreesLoaded {
worktrees: worktrees.unwrap_or_default(),
home_dir: info.map(|i| i.home_dir).unwrap_or_default(),
});
});
}
/// Reclaim one retained worktree (#85): the directory goes, the branch
/// never does. The open view drops the entry so the list tells the truth
/// without waiting for a re-fetch.
async fn submit_reclaim(&mut self, id: Uuid) {
match self.daemon.reclaim_worktree(id).await {
Ok(()) => {
self.worktrees.remove(id);
self.state.toast("worktree reclaimed; branch kept");
}
Err(e) => {
let message = e
.daemon_message()
.map(String::from)
.unwrap_or_else(|| e.to_string());
self.state.toast(format!("reclaim failed: {message}"));
}
}
}
fn redraw(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
@@ -713,6 +838,7 @@ impl App {
state: &self.state,
draft,
help: &self.help,
worktrees: Some(&self.worktrees),
};
let mut new_region = RegionMap::default();
@@ -857,6 +983,11 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
},
/// The retained-worktree store arrived for the worktrees view (#85).
WorktreesLoaded {
worktrees: Vec<RetainedWorktree>,
home_dir: String,
},
}
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
@@ -997,6 +1128,7 @@ mod tests {
activity: Default::default(),
last_activity_at: Utc::now(),
current_cwd: None,
blocked_work: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -1217,9 +1349,7 @@ mod tests {
for c in "crimson-otter".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // → worktree toggle
app.handle_draft_input(&InputKey::Tab); // → branch mode
app.handle_draft_input(&InputKey::Tab); // → new-branch name
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
app.handle_draft_input(&InputKey::Tab); // → submit
match app.handle_draft_input(&InputKey::Enter).as_slice() {
@@ -1236,10 +1366,11 @@ mod tests {
}
}
/// Editing the raw path with the worktree toggle on schedules a
/// `/api/gitroot` probe so the section learns its base repo (#65).
/// Editing the raw path schedules a `/api/gitroot` probe only once the
/// keystroke burst settles (#74): nothing fires inside the debounce
/// window; the heartbeat picks the due probe up afterwards.
#[test]
fn typing_a_raw_path_schedules_a_gitroot_probe() {
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else {
@@ -1248,13 +1379,24 @@ mod tests {
draft.cwd_raw_visible = true;
draft.focused_field = DraftField::CwdRaw;
let effects = app.handle_draft_input(&InputKey::char('/'));
// A burst of keystrokes fires nothing.
for c in "/opt/tooling".chars() {
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
}
// Once the quiet period passes, exactly one probe is due.
let due = std::time::Instant::now()
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
match app.poll_due_probes_at(due).as_slice() {
[Effect::ProbeGitRoot { tab: 0, path, .. }] => {
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
}
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
}
assert!(
matches!(
effects.as_slice(),
[Effect::ProbeGitRoot { tab: 0, seq: 2, .. }]
),
"{effects:?}"
app.poll_due_probes_at(due).is_empty(),
"the probe is consumed once"
);
}
@@ -1409,8 +1551,8 @@ mod tests {
panic!("expected a draft pane");
};
assert_eq!(draft.command(), "aider");
assert_eq!(draft.rows.len(), 1);
assert_eq!(draft.rows[0].project.name, "maestro");
assert_eq!(draft.groups.len(), 1);
assert_eq!(draft.groups[0].main.name, "maestro");
assert_eq!(draft.home_dir, "/home/u");
assert_eq!(draft.name.value(), "crimson-otter");
}
@@ -1504,9 +1646,168 @@ mod tests {
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}],
});
app.dispatch(Action::KillHighlighted);
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
}
// -- retained worktrees (#85) -------------------------------------------
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
use chrono::TimeZone;
maestro_protocol::RetainedWorktree {
id: Uuid::from_u128(id),
path: "/home/naps/tea/maestro/worktrees/feat/login".into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: "feat/login".into(),
reason: maestro_protocol::RetainedReason::ForceKilled,
retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: maestro_protocol::InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
}
}
fn app_with_one_retained_worktree() -> App {
let mut app = make_app();
app.worktrees.home_dir = "/home/naps".into();
app.worktrees.apply(vec![sample_worktree(42)]);
app.state.show_overlay(OverlayKind::Worktrees);
app
}
#[test]
fn show_worktrees_opens_the_view_and_fetches() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::ShowWorktrees),
vec![Effect::LoadWorktrees]
);
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
}
#[test]
fn r_over_the_highlighted_worktree_asks_for_a_confirmation() {
let mut app = app_with_one_retained_worktree();
let (decision, effects) = app.handle_overlay_input(&InputKey::char('r'));
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
match app.state.overlay() {
Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => {
assert_eq!(*id, Uuid::from_u128(42));
assert_eq!(branch, "feat/login");
}
other => panic!("expected a reclaim confirm, got {other:?}"),
}
}
/// The confirmation names what is in the way using the shared phrasing,
/// so the confirm step cannot drift from the list or the CLI.
#[test]
fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
match app.state.overlay() {
Some(OverlayKind::ReclaimConfirm { blockers, .. }) => {
assert_eq!(
blockers,
"2 uncommitted files, 1 untracked file, 3 unpushed commits"
);
}
other => panic!("expected a reclaim confirm, got {other:?}"),
}
}
#[test]
fn confirming_a_reclaim_asks_for_the_reclaim() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
let (decision, effects) = app.handle_overlay_input(&InputKey::char('y'));
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]);
assert!(app.state.overlay().is_none());
}
#[test]
fn declining_a_reclaim_returns_to_the_list() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
let (decision, effects) = app.handle_overlay_input(&InputKey::char('n'));
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
}
#[test]
fn j_and_k_move_the_highlight_inside_the_view() {
let mut app = make_app();
app.worktrees
.apply(vec![sample_worktree(1), sample_worktree(2)]);
// Distinct ids so movement is observable.
app.worktrees.items[1].branch = "other".into();
app.state.show_overlay(OverlayKind::Worktrees);
app.handle_overlay_input(&InputKey::char('j'));
assert_eq!(app.worktrees.selected().unwrap().branch, "other");
app.handle_overlay_input(&InputKey::char('k'));
assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login");
}
#[tokio::test]
async fn loading_worktrees_fills_the_view_from_the_daemon() {
use axum::routing::{delete, get};
use axum::Json;
use serde_json::json;
async fn info() -> Json<serde_json::Value> {
Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"}))
}
async fn worktrees() -> Json<serde_json::Value> {
Json(json!([{
"id": Uuid::from_u128(42).to_string(),
"path": "/home/naps/tea/maestro/worktrees/feat/login",
"base_repo": "/home/naps/tea/maestro",
"branch": "feat/login",
"reason": "force-killed",
"retained_at": "2026-08-24T10:00:00Z",
"in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3},
}]))
}
async fn reclaim() -> Json<serde_json::Value> {
Json(json!(null))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::WORKTREES, get(worktrees))
.route("/api/worktrees/:id", delete(reclaim));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::ShowWorktrees);
app.perform(effects).await;
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
.await
.expect("load completed")
.expect("channel open");
app.apply_draft_update(update).await;
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
assert_eq!(app.worktrees.items.len(), 1);
assert_eq!(app.worktrees.items[0].branch, "feat/login");
assert_eq!(app.worktrees.home_dir, "/home/naps");
// Reclaiming drops the entry from the open view.
app.submit_reclaim(Uuid::from_u128(42)).await;
assert!(app.worktrees.items.is_empty());
}
}
+1
View File
@@ -195,6 +195,7 @@ mod tests {
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
+25
View File
@@ -50,6 +50,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
@@ -166,6 +167,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "w"),
Some(Action::ShowWorktrees),
);
}
#[test]
@@ -233,6 +238,26 @@ mod tests {
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
/// Regression for #70: the previous canonical form of a real Shift+S
/// keypress was `"shift-S"`, which the default `"S"` binding never
/// matched. Drive the actual crossterm event, not the binding string,
/// so the dead-key bug can't come back unnoticed.
#[test]
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use crate::input::{canonicalise, Region};
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
assert_eq!(
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
Some(Action::FastForwardSpawn),
);
}
#[test]
fn custom_chord_timeout_is_applied() {
let mut section = TuiSection::default();
File diff suppressed because it is too large Load Diff
-1
View File
@@ -80,7 +80,6 @@ impl FastForwardDefaults {
.clone()
.unwrap_or_else(|| project.path.clone()),
branch: name.to_string(),
cleanup_on_kill: true,
create_branch: true,
}),
})
+20 -2
View File
@@ -117,7 +117,9 @@ impl InputKey {
if *alt {
out.push_str("alt-");
}
if *shift {
// Uppercase already implies shift; crossterm sets the flag
// on every such char, so skip the redundant prefix (#70).
if *shift && !c.is_uppercase() {
out.push_str("shift-");
}
out.push(*c);
@@ -580,12 +582,28 @@ mod tests {
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make the default `S` binding unreachable from the keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
c: 'S',
ctrl: false,
alt: false,
shift: true,
};
assert_eq!(k.canonical(), "S");
}
#[test]
fn canonical_round_trips_specials() {
for s in [
+1
View File
@@ -22,6 +22,7 @@ pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
use std::path::PathBuf;
use std::time::Duration;
+32 -10
View File
@@ -14,23 +14,28 @@ use ratatui::Frame;
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
/// #83: what the daemon's watcher last saw in the worktree, phrased by
/// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused"
/// is visible before the attempt. `None` while nothing blocks.
pub blockers: Option<String>,
}
impl KillConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 50, 5);
let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 });
f.render_widget(Clear, area);
let block = Block::default().title(" kill ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(vec![
Line::from(format!("Kill session {}?", self.label)),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
if let Some(blockers) = &self.blockers {
// Same phrasing the reclaim confirmation uses for its own guard.
lines.push(Line::from(format!("Currently in the way: {blockers}")));
lines.push(Line::from("The kill will be refused unless forced."));
}
lines.push(Line::from(""));
lines.push(Line::from("[y] yes [n] no"));
f.render_widget(Paragraph::new(lines), inner);
}
}
@@ -82,7 +87,7 @@ impl Toast {
}
}
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect {
let w = w.min(parent.width);
let h = h.min(parent.height);
Rect {
@@ -121,6 +126,7 @@ mod tests {
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
label: "session-7".into(),
blockers: None,
};
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
assert!(rendered.contains("Kill"));
@@ -129,6 +135,22 @@ mod tests {
assert!(rendered.contains("no"));
}
/// #83: when the worktree blocks, the confirm names it before the
/// operator commits — in `InTheWay`'s own words.
#[test]
fn kill_confirm_names_what_blocks() {
let modal = KillConfirm {
label: "session-7".into(),
blockers: Some("2 uncommitted files".into()),
};
let rendered = render_to_string(70, 10, |f| modal.render(f, f.area()));
assert!(
rendered.contains("Currently in the way: 2 uncommitted files"),
"{rendered}"
);
assert!(rendered.contains("refused"), "{rendered}");
}
#[test]
fn help_overlay_lists_lines() {
let help = HelpOverlay {
+31
View File
@@ -15,6 +15,7 @@ use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
@@ -67,6 +68,8 @@ pub struct RenderInputs<'a> {
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
@@ -143,8 +146,19 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
match inputs.state.overlay() {
Some(OverlayKind::KillConfirm(id)) => {
// #83: surface what the watcher last saw in the worktree, in
// InTheWay's own words, before the operator commits to the kill.
let blockers = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.and_then(|s| s.blocked_work)
.filter(|b| !b.is_clear())
.map(|b| b.to_string());
let kc = KillConfirm {
label: id.to_string(),
blockers,
};
kc.render(f, area);
map.modal = Some(area);
@@ -153,6 +167,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Worktrees) => {
if let Some(view) = inputs.worktrees {
view.render(f, area);
map.modal = Some(area);
}
}
Some(OverlayKind::ReclaimConfirm {
branch, blockers, ..
}) => {
let rc = ReclaimConfirm {
branch: branch.clone(),
blockers: blockers.clone(),
};
rc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
@@ -298,6 +328,7 @@ mod tests {
draft: Some(draft),
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
+44 -1
View File
@@ -52,6 +52,13 @@ impl Sidebar {
(SessionStatus::Running, false) => "○",
};
let label = name_for(s);
// #83: a session whose worktree blocks a kill gets a warning
// glyph; the kill confirm names the specifics.
let blocked = s
.blocked_work
.filter(|b| !b.is_clear())
.map(|_| " ⚠")
.unwrap_or_default();
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
@@ -59,7 +66,7 @@ impl Sidebar {
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
Span::styled(format!("{glyph} {label}{blocked}"), style),
]));
}
f.render_widget(Paragraph::new(lines), inner);
@@ -99,6 +106,7 @@ mod tests {
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
@@ -172,6 +180,40 @@ mod tests {
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
}
/// #83: a session whose worktree blocks a kill carries the warning glyph;
/// a clear or unprobed one does not.
#[test]
fn blocked_session_gets_a_warning_glyph() {
use maestro_protocol::InTheWay;
let mut blocked = session_named(1, "claude");
blocked.blocked_work = Some(InTheWay {
unpushed_commits: 4,
..InTheWay::default()
});
let mut clear = session_named(2, "bash");
clear.blocked_work = Some(InTheWay::default());
let unprobed = session_named(3, "vim");
let sb = Sidebar {
sessions: vec![blocked, clear, unprobed],
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 40, 8);
let line = rendered
.lines()
.find(|l| l.contains("claude"))
.expect("blocked row");
assert!(line.contains('⚠'), "expected warning glyph in: {line:?}");
for name in ["bash", "vim"] {
let line = rendered
.lines()
.find(|l| l.contains(name))
.expect("clear row");
assert!(!line.contains('⚠'), "{name} must not warn: {line:?}");
}
}
#[test]
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
@@ -188,6 +230,7 @@ mod tests {
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
};
let sb = Sidebar {
sessions: vec![s],
+11
View File
@@ -50,6 +50,17 @@ pub enum OverlayKind {
KillConfirm(Uuid),
Help,
Toast(String),
/// The retained-worktrees view (#85); its entries live on `App` because
/// they are daemon data, not UI state.
Worktrees,
/// Reclaim confirmation for the worktree with this id (#85), guarding
/// the destructive key like [`OverlayKind::KillConfirm`] does.
ReclaimConfirm {
id: Uuid,
branch: String,
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
blockers: String,
},
}
#[derive(Debug)]
+259
View File
@@ -0,0 +1,259 @@
//! Retained-worktrees view (#85): an overlay listing every retained worktree
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
//! `r` to reclaim behind a confirmation, esc to close.
//!
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
//! the same source the kill refusal and the web client use, so no two clients
//! can describe the same tree differently.
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use maestro_protocol::{RetainedReason, RetainedWorktree};
use crate::draft::abbreviate_path;
/// The open worktrees overlay: its entries and which one is highlighted.
#[derive(Debug, Clone, Default)]
pub struct WorktreesView {
pub items: Vec<RetainedWorktree>,
pub highlight: usize,
/// Daemon-reported home dir, for `~` abbreviation of paths.
pub home_dir: String,
}
impl WorktreesView {
/// Replace the listing and keep the highlight pointing at something real.
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
self.items = items;
if !self.items.is_empty() {
self.highlight = self.highlight.min(self.items.len() - 1);
} else {
self.highlight = 0;
}
}
pub fn remove(&mut self, id: uuid::Uuid) {
let idx = self.items.iter().position(|w| w.id == id);
self.items.retain(|w| w.id != id);
if let Some(i) = idx {
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
let _ = i;
}
}
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
pub fn move_highlight(&mut self, delta: i32) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as i32;
let next = (self.highlight as i32 + delta).rem_euclid(len);
self.highlight = next as usize;
}
pub fn selected(&self) -> Option<&RetainedWorktree> {
self.items.get(self.highlight)
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
f.render_widget(Clear, area);
let block = Block::default()
.title(" retained worktrees ")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
if self.items.is_empty() {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
lines.push(Line::from("no retained worktrees"));
} else {
lines.push(Line::from(format!(
" {:<26} {:<34} {:<24} IN THE WAY",
"BRANCH", "PATH", "REASON"
)));
for (i, w) in self.items.iter().enumerate() {
let marker = if i == self.highlight { ">" } else { " " };
lines.push(Line::from(format!(
"{marker} {:<25} {:<34} {:<24} {}",
w.branch,
abbreviate_path(&w.path, &self.home_dir),
reason_label(w.reason),
w.in_the_way,
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from("j/k: select r: reclaim esc: close"));
f.render_widget(Paragraph::new(lines), inner);
}
}
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
/// It names the branch and says out loud what reclaim does not take with it —
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
#[derive(Debug, Clone)]
pub struct ReclaimConfirm {
pub branch: String,
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
pub blockers: String,
}
impl ReclaimConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = super::overlays::centered(parent, 70, 7);
f.render_widget(Clear, area);
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
ratatui::widgets::Paragraph::new(vec![
Line::from(format!("Reclaim worktree {}?", self.branch)),
Line::from(format!("Currently in the way: {}", self.blockers)),
Line::from("The directory is removed; the branch is never deleted."),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
}
}
fn reason_label(reason: RetainedReason) -> &'static str {
match reason {
RetainedReason::ForceKilled => "force-killed",
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use maestro_protocol::{InTheWay, RetainedReason};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
where
F: FnOnce(&mut Frame),
{
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: uuid::Uuid::from_u128(id),
path: path.into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
#[test]
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
let mut view = WorktreesView {
home_dir: "/home/naps".into(),
..Default::default()
};
let mut wt = worktree(
"feat/login",
"/home/naps/tea/maestro/worktrees/feat/login",
1,
);
wt.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
view.apply(vec![wt]);
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
assert!(rendered.contains("feat/login"), "{rendered}");
// Path abbreviated against the daemon's home dir, same helper as the
// draft pane's project list.
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
assert!(rendered.contains("force-killed"), "{rendered}");
// Phrasing comes from InTheWay's Display impl — shared with the kill
// refusal and the web client.
assert!(
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
"{rendered}"
);
}
#[test]
fn empty_store_looks_like_a_normal_state_not_an_error() {
let view = WorktreesView::default();
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
assert!(rendered.contains("no retained worktrees"), "{rendered}");
assert!(!rendered.contains("error"), "{rendered}");
assert!(!rendered.contains("failed"), "{rendered}");
}
#[test]
fn highlight_moves_and_wraps() {
let mut view = WorktreesView::default();
view.apply(vec![
worktree("a", "/r/a", 1),
worktree("b", "/r/b", 2),
worktree("c", "/r/c", 3),
]);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "b");
view.move_highlight(-1);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(-1);
// Wraps backward to the last entry.
assert_eq!(view.selected().unwrap().branch, "c");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "a");
}
#[test]
fn apply_clamps_a_stale_highlight() {
let mut view = WorktreesView::default();
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
view.move_highlight(1);
view.apply(vec![worktree("c", "/r/c", 3)]);
assert_eq!(view.selected().unwrap().branch, "c");
}
#[test]
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
let confirm = ReclaimConfirm {
branch: "feat/login".into(),
blockers: "2 uncommitted files".into(),
};
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
assert!(
rendered.contains("Reclaim worktree feat/login?"),
"{rendered}"
);
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
assert!(rendered.contains("never deleted"), "{rendered}");
assert!(rendered.contains("yes"), "{rendered}");
assert!(rendered.contains("no"), "{rendered}");
}
}
+20 -4
View File
@@ -75,11 +75,16 @@ That is only safe because force retains.
`--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.
`force-killed`. Force never removes a tree that would have blocked. A tree
whose guard is clear never needed the override, so a forced kill removes it as
usual — force changes what happens to blocked work, not ordinary cleanup.
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.
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.
A forced kill answers with what was retained and why, so a client can say where
the work went rather than leaving the operator to wonder.
### Exiting on its own
@@ -92,6 +97,17 @@ Nobody is waiting for an answer here, so there is nothing to refuse; the choice
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 session itself stays in the catalog marked dead until it is dismissed — the
replay buffer of a crashed agent outlives it — but its worktree is settled at
once. Once settled, the session drops its worktree reference, so dismissing it
afterwards neither refuses (there is no guard left to trip) nor records a second
store entry.
If settling is impossible — the probe fails, or the store cannot take the entry —
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
record, where dismissing it later still goes through `--force`'s
retain-before-teardown path. Losing the store must never mean losing the work.
## The retained store
A `state_dir`-backed record of worktrees the daemon created that no longer have a
+1 -1
View File
@@ -125,7 +125,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
};
buildPhase = ''
+19
View File
@@ -9,6 +9,7 @@ import type {
NameBatch,
Projects,
RecentDirs,
RetainedWorktree,
Session,
Uuid,
} from "./types.js";
@@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise<Branches> {
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** The daemon's retained worktrees (#80): trees kept after a session ended
* with unsaved work, with what is currently in the way. Drives the
* retained-worktrees view (#84). */
export async function listWorktrees(): Promise<RetainedWorktree[]> {
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
}
/** Reclaim a retained worktree: removes its directory and drops the store
* entry. Never deletes the branch (docs/WORKTREES.md). */
export async function reclaimWorktree(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.worktree(id), {
method: "DELETE",
credentials: "same-origin",
}),
);
}
export { eventsWsUrl } from "./routes.js";
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
// happy-dom runs without a backing store; renderSidebar reads the persisted
// sidebar width on mount.
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
// No daemon here; renderSidebar treats a failed info fetch as "absolute
// paths are a fine fallback".
window.fetch = () => Promise.reject(new Error("offline"));
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
// Like the real store, deliver an immediate snapshot so the sidebar
// renders its rows during construction.
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session with the shared phrasing", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toContain("⚠");
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
});
it("does not mark clear or unprobed sessions", () => {
const clear = session({
id: "aaaaaaaa-0000-0000-0000-000000000002",
blocked_work: {} as InTheWay,
});
const unprobed = session({
id: "aaaaaaaa-0000-0000-0000-000000000003",
});
const el = renderSidebar(storeWith([clear, unprobed])).el;
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
});
it("restates the warning at the kill confirm", async () => {
const confirmations: string[] = [];
window.confirm = (msg?: string) => {
confirmations.push(msg ?? "");
return false;
};
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
kill?.click();
await Promise.resolve();
expect(confirmations).toHaveLength(1);
expect(confirmations[0]).toContain("1 untracked file");
expect(confirmations[0]).toContain("refused unless forced");
});
it("confirms plainly when nothing blocks", async () => {
const confirmations: string[] = [];
window.confirm = (msg?: string) => {
confirmations.push(msg ?? "");
return false;
};
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await Promise.resolve();
expect(confirmations).toEqual(['Kill session "claude"?']);
});
});
+20 -2
View File
@@ -3,7 +3,7 @@ import { getDaemonInfo, killSession } from "./api.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { abbreviateFolderName, sessionName } from "./types.js";
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -64,10 +64,14 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
// #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so
// "this will be refused" is visible before the kill is attempted.
const blockers = sessionBlockers(s);
row.innerHTML = `
<span class="leaf-name">${escapeHtml(name)}</span>
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
${running ? "" : `<span class="leaf-badge">exited</span>`}
${blockers ? `<span class="leaf-badge leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>` : ""}
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
@@ -83,7 +87,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
killBtn.addEventListener("click", async (ev) => {
ev.preventDefault();
ev.stopPropagation();
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
// #83: the confirm restates the warning at the moment of decision.
const blockers = sessionBlockers(s);
const question = blockers
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
: `Kill session "${name}"?`;
if (!confirm(question)) return;
try {
await killSession(s.id);
} catch (e) {
@@ -180,6 +189,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
renderStatus();
const actions = header.querySelector(".app-actions") as HTMLElement;
// Retained worktrees (#84): a section reachable from the header, not a
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
// compete with the session catalog for attention.
const worktreesLink = document.createElement("a");
worktreesLink.className = "btn btn-ghost";
worktreesLink.href = "#/worktrees";
worktreesLink.textContent = "worktrees";
worktreesLink.title = "Retained worktrees";
actions.appendChild(worktreesLink);
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
-1
View File
@@ -43,7 +43,6 @@ describe("resolveFastForward (#67)", () => {
expect(req.worktree).toEqual({
base_repo: "/repos/fresh",
branch: "amber-falcon",
cleanup_on_kill: true,
create_branch: true,
});
});
-1
View File
@@ -42,7 +42,6 @@ export function buildFastForwardRequest(
worktree: {
base_repo: project.base_repo ?? project.path,
branch: name,
cleanup_on_kill: true,
create_branch: true,
},
};
+42 -1
View File
@@ -7,7 +7,9 @@ import {
createSession,
fetchNames,
getDaemonInfo,
listWorktrees,
projects as fetchProjects,
reclaimWorktree,
} from "./api.js";
import {
buildFastForwardRequest,
@@ -22,6 +24,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
@@ -269,7 +272,11 @@ function showMultiView(): void {
// cloned session's dir + command.
function requestOpenSpawn(): void {
showMultiView();
mv.openDraft();
// A new tab, not a split of the pane you were reading (#100). "Spawn
// similar" and the fast-forward key below deliberately keep the old
// behaviour: both act on the session in front of you, so a split beside it
// is the point.
mv.openDraft(undefined, { newTab: true });
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener("catalog-spawn-similar", (ev) => {
@@ -345,16 +352,47 @@ document.addEventListener("keydown", (e) => {
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "worktrees" }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
if (hash === "/worktrees") return { kind: "worktrees" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash };
}
// Retained worktrees (#84): built lazily on first visit, then kept so a
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
// `/api/info` answers.
let worktreesView: WorktreesViewHandle | undefined;
let homeDir: string | undefined;
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
})
.catch(() => {
/* absolute paths are a fine fallback */
});
function showWorktrees(): void {
pool.setVisible([]);
pool.setFocus(null);
contentShowsMv = false;
if (!worktreesView) {
worktreesView = createWorktreesView({
load: listWorktrees,
reclaim: reclaimWorktree,
homeDir: () => homeDir,
});
}
content.replaceChildren(worktreesView.el);
shellEl.dataset.route = "worktrees";
void worktreesView.refresh();
}
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
@@ -376,6 +414,9 @@ function dispatch(): void {
// tears panes down.
showMultiView();
return;
case "worktrees":
showWorktrees();
return;
}
}
+173
View File
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
});
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
// Let the mounted form's loadProjects() settle and auto-sync its picked
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
await flush();
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
mv.openDraft();
await flush();
expect(leaves(tab.root)).toHaveLength(2);
expect(tab.focusedLeafId).toBe(empty);
});
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
const empty = splitLeaf(tab, first, null, "row", true);
tab.focusedLeafId = empty;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
await flush();
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
expect(leaf.draft?.cwd).toBe("/home/user/proj");
expect(leaf.draft?.command).toBe("claude");
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
expect(cwdRaw.value).toBe("/home/user/proj");
});
it("seeds a clone's cwd and command into the draft", async () => {
const layout = emptyLayout();
const mv = makeMv(layout);
@@ -160,3 +202,134 @@ describe("multiview openDraft (#62)", () => {
expect(leaf.draft?.command).toBe("claude");
});
});
describe("multiview pane-drag swap (#73)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
* drag handler does — modelled after the pointer sequence in runDrag()/
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
// no such element, so stand one in for the gate check itself.
const marker = document.createElement("div");
marker.className = "attach-header";
sourcePane.appendChild(marker);
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
left: 100,
top: 0,
width: 100,
height: 100,
right: 200,
bottom: 100,
x: 100,
y: 0,
toJSON() {},
} as DOMRect);
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
targetPane,
];
marker.dispatchEvent(
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
);
window.dispatchEvent(
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
await flush();
}
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", null, "row", true);
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
nameInputOf(leaf1).value = "west";
nameInputOf(leaf1).dispatchEvent(new Event("input"));
nameInputOf(leaf2).value = "east";
nameInputOf(leaf2).dispatchEvent(new Event("input"));
await flush();
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
// The leaf data swaps (already correct pre-fix)...
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
// ...and the mounted forms must follow, not stay behind showing the old
// pane's values (#73).
expect(nameInputOf(leaf1).value).toBe("east");
expect(nameInputOf(leaf2).value).toBe("west");
});
});
describe("openDraft newTab (#100)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("opens its own tab instead of splitting the focused session", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft(undefined, { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(2);
// The tab you were reading is untouched: still one pane, still its session.
const original = leaves(layout.tabs[0].root);
expect(original).toHaveLength(1);
expect(original[0].sessionId).toBe("s1");
// The draft is alone in the new, now-active tab.
const fresh = leaves(layout.tabs[1].root);
expect(fresh).toHaveLength(1);
expect(fresh[0].sessionId).toBeNull();
expect(fresh[0].draft).not.toBeNull();
expect(layout.activeTabId).toBe(layout.tabs[1].id);
});
it("still splits when newTab is not asked for", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openDraft();
await flush();
expect(layout.tabs).toHaveLength(1);
expect(leaves(tab.root)).toHaveLength(2);
});
});
+30 -5
View File
@@ -57,7 +57,7 @@ export interface MultiView {
/** Open a draft pane (issue #62): reuse the focused pane if it is already
* empty, otherwise split the focused pane. `seed` pre-fills the form
* ("Spawn similar" clones a session's cwd + command). */
openDraft: (seed?: Partial<Draft>) => void;
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
opts.onOpenSessionsChange?.();
}
function openDraft(seed?: Partial<Draft>): void {
const tab = activeTab(layout);
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
if (opts?.newTab) {
// Spawn opens its own tab rather than halving whatever you were reading
// (#100). The empty-pane reuse below still applies within a tab.
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
render();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
// Empty tab (or stale focus): the draft becomes the first pane.
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
@@ -423,9 +432,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
if (!target.sessionId && !target.draft) {
// An already-empty pane becomes the draft in place.
if (!target.sessionId) {
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
// picked defaults into `draft` once ready, so that's never reliably null.
target.draft = draft();
const stale = draftForms.get(target.id);
if (stale) {
stale.destroy();
draftForms.delete(target.id);
}
} else {
// Split a fresh draft beside the focused pane.
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
@@ -695,6 +710,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
[dragged.draft, target.draft] = [target.draft, dragged.draft];
// The leaf data moved but each pane's mounted form is still keyed to
// its old leaf (#73) — tear both down like openDraft's reuse case
// (#72) so render() remounts fresh forms seeded from the swapped data.
for (const id of [dragged.id, target.id]) {
const stale = draftForms.get(id);
if (stale) {
stale.destroy();
draftForms.delete(id);
}
}
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
+41 -25
View File
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
);
}
function rowPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
(r) => r.dataset.cwd ?? "",
function boxPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
(b) => b.dataset.cwd ?? "",
);
}
describe("spawnForm project picker", () => {
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
return handle;
}
it("renders one row per project, fresh before stale", async () => {
it("renders one box per project, fresh before stale", async () => {
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
const stale = project({
name: "stale",
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
last_activity: "2026-01-01T00:00:00Z",
});
const form = (await mount([stale, fresh])).el;
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
expect(rows[1].classList.contains("is-stale")).toBe(true);
expect(rows[0].classList.contains("is-stale")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("nests worktrees under their base repository", async () => {
it("shows the name as the primary line and the full path unabbreviated", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
// projects sharing a basename (#95).
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
const main = project({ name: "maestro", path: "/home/user/maestro" });
const wt = project({
name: "wt",
path: "/elsewhere/wt",
path: "/home/user/maestro/worktrees/wt",
is_worktree: true,
base_repo: main.path,
});
const form = (await mount([wt, main])).el;
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
const [mainRow, wtRow] = [
...form.querySelectorAll(".project-row:not(.project-other)"),
];
expect(wtRow.classList.contains("is-worktree")).toBe(true);
expect(mainRow.classList.contains("is-worktree")).toBe(false);
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
});
it("filters rows as the operator types", async () => {
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
const a = project({ name: "maestro", path: "/home/user/maestro" });
const b = project({ name: "tea", path: "/home/user/work/tea" });
const form = (await mount([a, b])).el;
const wt = project({
name: "bulgarians",
path: "/home/user/maestro/worktrees/bulgarians",
is_worktree: true,
base_repo: a.path,
});
const form = (await mount([a, b, wt])).el;
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "tea";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
// A query that only matches a worktree still surfaces its repository box.
filter.value = "bulgarians";
filter.dispatchEvent(new Event("input"));
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
expect(rowPaths(form)).toEqual([]);
expect(boxPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
});
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
row.click();
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
expect(last.command).toBe("claude");
expect(values).toBeTruthy();
// The raw input stays hidden — the picked row is the source of truth.
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(row.classList.contains("is-active")).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
});
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
+6
View File
@@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects";
export const GITROOT = "/api/gitroot";
export const NAMES = "/api/names";
export const BRANCHES = "/api/branches";
export const WORKTREES = "/api/worktrees";
export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
@@ -29,6 +30,11 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
}
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
export function worktree(id: Uuid): string {
return `${WORKTREES}/${encodeURIComponent(id)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
-2
View File
@@ -38,7 +38,6 @@ describe("buildSpawnRequest", () => {
worktree: {
base_repo: "/home/user/project",
branch: "feat/foo",
cleanup_on_kill: true,
create_branch: true,
},
},
@@ -63,7 +62,6 @@ describe("buildSpawnRequest", () => {
worktree: {
base_repo: "/home/user/project",
branch: "spawn-v2",
cleanup_on_kill: true,
create_branch: false,
},
},
+294 -87
View File
@@ -9,8 +9,8 @@ import {
} from "./api.js";
import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
@@ -75,9 +75,6 @@ export function buildSpawnRequest(
req.worktree = {
base_repo: baseRepo,
branch,
// The daemon always cleans up, guarded against unsaved work (#56);
// the wire field stays for older daemons.
cleanup_on_kill: true,
// Existing-mode picks must check out as-is; the daemon creates
// anything else with `worktree add -b` (#56/#61).
create_branch: mode !== "existing",
@@ -97,6 +94,11 @@ export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
];
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
const GITROOT_PROBE_DEBOUNCE_MS = 250;
export interface SpawnFormHandle {
el: HTMLFormElement;
/** Remove global listeners (dir autocomplete) — call when unmounting. */
@@ -128,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field">
<span class="field-label">Working directory</span>
<span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
autocomplete="off" />
<div class="picker projects"></div>
<button type="button" class="project-other">Other directory…</button>
<div class="raw-wrap cwd-raw-wrap" hidden>
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div>
</div>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
</div>
<div class="field">
<span class="field-label">Name</span>
<div class="name-row">
@@ -145,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
</div>
</div>
<fieldset class="worktree">
<legend>
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
<span class="folder-name">Advanced options</span>
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<label class="row-inline">
<input type="checkbox" name="worktree_enabled" />
Create git worktree
</label>
</legend>
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
<p class="wt-hint" hidden></p>
<div class="wt-body" hidden>
<p class="wt-repo"></p>
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="new" />
New branch
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
</label>
</div>
<label class="wt-new-wrap">
New branch name
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="row-inline">
<input type="radio" name="worktree_branch_mode" value="existing" />
Existing branch
<label class="wt-exist-wrap" hidden>
Pick a branch
<select class="branch-select"></select>
</label>
</div>
<label class="wt-new-wrap">
Branch
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
</label>
<label class="wt-exist-wrap" hidden>
Branch
<select class="branch-select"></select>
</label>
</div>
</fieldset>
<p class="form-error" role="alert" aria-live="polite"></p>
@@ -185,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const errEl = form.querySelector(".form-error") as HTMLElement;
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
const recentsEl = form.querySelector(".projects") as HTMLElement;
const projectsEl = form.querySelector(".projects") as HTMLElement;
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
const wtStep = form.querySelector(".wt-step") as HTMLElement;
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
const wtBody = form.querySelector(".wt-body") as HTMLElement;
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
@@ -340,6 +358,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
}
}
function renderCommandTiles(): void {
cmdTiles.replaceChildren();
for (const p of presets) {
@@ -348,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => selectPreset(p.id));
tile.addEventListener("click", () => {
commandTouched = true;
selectPreset(p.id);
});
cmdTiles.appendChild(tile);
}
}
// Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the
// operator has already touched (#76).
let commandTouched = false;
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
commandTouched = true;
command = cmdRaw.value.trim();
emit();
}
@@ -370,7 +407,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
applyInitialCommand();
if (commandTouched) {
reapplyActiveTile();
} else {
applyInitialCommand();
}
}
})
.catch(() => {
@@ -397,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
// ---- working-directory picker (#95) ----------------------------------
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
// 1. Step one is one box per repository (name, then full path). A worktree
// is a box only when its main checkout is missing from the scan.
// 2. The filter keeps a repository when the query matches it or any of its
// worktrees, so a worktree name still finds its box.
// 3. Picking a box reveals step two: "+ new", then that repository's
// worktrees newest-first. "Other directory…" clears both steps.
// 4. "+ new" is the default selection — except on a box that is itself a
// linked worktree, which defaults to that worktree.
// 5. "+ new" means create: cwd is the repository, create-worktree on.
// An existing worktree means run in it: cwd is that worktree,
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
// these are separate acts — #91's rule, now an explicit choice.
// 6. Neither step probes /api/gitroot; the scan already reports repository
// membership. Only the free-text hatch pays for a probe.
/** "new" (create a worktree) or the path of an existing worktree to run in. */
type WorktreeChoice = "new" | string;
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
let groups: ProjectGroup[] = []; // rebuilt on every render
let selectedGroup: ProjectGroup | null = null;
let worktreeChoice: WorktreeChoice = "new";
function showRawCwd(prefillValue?: string, focus = true): void {
cwdRawWrap.hidden = false;
if (prefillValue != null) cwd = prefillValue;
cwdRaw.value = cwd;
markActiveProject(null);
clearProjectSelection();
if (focus) {
cwdRaw.focus();
cwdRaw.select();
@@ -416,55 +481,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwd = cwdRaw.value.trim();
branchCache = null;
emit();
if (wtEnabled.checked) probeGitRoot();
if (wtEnabled.checked) scheduleGitRootProbe();
});
function markActiveProject(activeCwd: string | null): void {
for (const row of recentsEl.querySelectorAll(".project-row")) {
row.classList.toggle(
"is-active",
(row as HTMLElement).dataset.cwd === activeCwd,
);
/** Every worktree of a group, including the group's own box when the scan
* found no main checkout to nest it under. */
function worktreesOf(group: ProjectGroup): Project[] {
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
}
function markActiveBox(): void {
const active = selectedGroup?.main.path ?? null;
for (const box of projectsEl.querySelectorAll(".project-box")) {
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
}
}
function selectProject(project: Project): void {
cwd = project.path;
function markActiveChoice(): void {
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
const el = btn as HTMLElement;
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
el.classList.toggle("is-active", value === worktreeChoice);
}
}
function clearProjectSelection(): void {
selectedGroup = null;
worktreeChoice = "new";
wtChoices.replaceChildren();
wtStep.hidden = true;
markActiveBox();
}
function selectProject(group: ProjectGroup): void {
selectedGroup = group;
cwdRawWrap.hidden = true;
markActiveProject(project.path);
// The scan already knows this directory is inside a repo (#61): the
// toggle defaults back on and no /api/gitroot round trip is needed.
wtEnabled.checked = true;
applyProjectRepo(project.base_repo ?? project.path);
renderWorktreeChoices(group);
markActiveBox();
// Rule 4: a box that is itself a linked worktree defaults to running there.
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
}
function applyWorktreeChoice(choice: WorktreeChoice): void {
const group = selectedGroup;
if (!group) return;
const target =
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
if (!target) return;
worktreeChoice = choice;
cwd = target.path;
branchCache = null;
markActiveChoice();
if (choice === "new") {
wtEnabled.checked = true;
updateWtSummary();
applyProjectRepo(group.main.base_repo ?? group.main.path);
} else {
wtEnabled.checked = false;
updateWtSummary();
inferredBaseRepo = null;
wtBody.hidden = true;
wtHint.hidden = true;
}
emit();
// Picking a project pre-selects the command it was last used with.
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
// The chosen directory pre-selects the command it was last used with.
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
if (match) selectPreset(match.id);
}
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
/** Groups with at least one member (itself or a worktree) matching `query`. */
function visibleGroups(query: string): ProjectGroup[] {
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
}
function renderProjects(): void {
recentsEl.replaceChildren();
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
for (const group of groupProjects(sorted)) {
recentsEl.appendChild(projectRow(group.main));
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
}
if (discovered.length > 0 && sorted.length === 0) {
projectsEl.replaceChildren();
groups = groupProjects(sortProjects(discovered, new Date()));
const visible = visibleGroups(filterInput.value);
for (const group of visible) projectsEl.appendChild(projectBox(group));
if (discovered.length > 0 && visible.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-empty";
empty.textContent = "No projects match.";
recentsEl.appendChild(empty);
projectsEl.appendChild(empty);
}
recentsEl.appendChild(otherDirRow());
markActiveProject(cwdRawWrap.hidden ? cwd : null);
markActiveBox();
}
filterInput.addEventListener("input", renderProjects);
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
async function loadProjects(): Promise<void> {
recentsEl.replaceChildren(loadingRow());
projectsEl.replaceChildren(loadingRow());
try {
discovered = await fetchProjects();
} catch {
@@ -475,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (seedCwd) {
// A seed sets the directory verbatim, regardless of what the scan found.
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
// picked row: keep the picker and don't steal focus. Otherwise land
// picked box: keep the picker and don't steal focus. Otherwise land
// focused in the raw input (clone / no-match flows).
if (prefill.cwdRawVisible === false) {
cwd = seedCwd;
cwdRaw.value = seedCwd;
markActiveProject(cwd);
// Restoring must not touch the worktree toggle — the seed carries it.
if (!restoreSelection(seedCwd)) clearProjectSelection();
} else {
showRawCwd(seedCwd, false);
}
@@ -497,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
emit();
}
function projectRow(project: Project): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
isStale(project, new Date()) ? " is-stale" : ""
}`;
row.dataset.cwd = project.path;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
row.innerHTML = `
<span class="project-info">
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
</span>
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
row.addEventListener("click", () => selectProject(project));
return row;
/** Put the two steps back on a restored draft's cwd, without re-running the
* side effects of a fresh pick. */
function restoreSelection(path: string): boolean {
for (const group of groups) {
const wt = worktreesOf(group).find((p) => p.path === path);
const choice: WorktreeChoice | null =
wt ? wt.path : group.main.path === path ? "new" : null;
if (choice === null) continue;
selectedGroup = group;
worktreeChoice = choice;
renderWorktreeChoices(group);
markActiveChoice();
markActiveBox();
return true;
}
return false;
}
function otherDirRow(): HTMLElement {
const row = document.createElement("button");
row.type = "button";
row.className = "project-row project-other";
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
function projectBox(group: ProjectGroup): HTMLElement {
const project = group.main;
const box = document.createElement("button");
box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
// tells two projects sharing a basename apart (#95).
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
.filter(Boolean)
.join(" · ");
box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(project.path)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group));
return box;
}
function renderWorktreeChoices(group: ProjectGroup): void {
wtChoices.replaceChildren();
wtChoices.appendChild(
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
);
for (const wt of worktreesOf(group)) {
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
}
wtStep.hidden = false;
}
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
btn.dataset.kind = choice === "new" ? "new" : "existing";
if (choice !== "new") btn.dataset.cwd = choice;
btn.innerHTML = `
<span class="wt-choice-name">${escapeHtml(label)}</span>
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
btn.addEventListener("click", () => applyWorktreeChoice(choice));
return btn;
}
function loadingRow(): HTMLElement {
@@ -534,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
return p;
}
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
let advancedCollapsed = true;
function setAdvancedCollapsed(collapsed: boolean): void {
advancedCollapsed = collapsed;
wtToggle.setAttribute("aria-expanded", String(!collapsed));
wtCollapsible.hidden = collapsed;
}
function updateWtSummary(): void {
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
}
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
// The project scan already reports repo membership, so a picked project
// resolves its base repo locally (#61); only the free-text escape hatch
@@ -572,7 +725,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
applyProjectRepo(project.base_repo ?? project.path);
return;
}
void probeGitRoot();
scheduleGitRootProbe();
}
// Every keystroke resets this timer, so a burst of typing issues one
// request once the operator pauses. `probeToken` inside probeGitRoot still
// owns ordering: a scheduled probe that fires after a newer one started
// loses the token race and is dropped.
let probeTimer: number | undefined;
function scheduleGitRootProbe(): void {
window.clearTimeout(probeTimer);
probeTimer = window.setTimeout(() => {
probeTimer = undefined;
void probeGitRoot();
}, GITROOT_PROBE_DEBOUNCE_MS);
}
async function probeGitRoot(): Promise<void> {
@@ -601,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// Publish the untick too, or the next render seeds the box checked with
// no inferred repo and submitting fails.
wtEnabled.checked = false;
updateWtSummary();
emit();
wtBody.hidden = true;
wtHint.hidden = false;
@@ -674,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
branchSelect.addEventListener("change", () => emit());
wtEnabled.addEventListener("change", () => {
updateWtSummary();
emit();
if (wtEnabled.checked) {
resolveBaseRepo();
@@ -687,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// The toggle defaults ON (#61): the operator almost always wants a
// worktree, and every non-repo target unticks it via the gitroot probe.
wtEnabled.checked = prefill.worktreeEnabled ?? true;
updateWtSummary();
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
setBranchMode(branchMode);
@@ -697,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocomplete = attachDirAutocomplete(cwdRaw);
// ---- Enter submits from anywhere (#97) --------------------------------
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
// choose it instead; the next Enter, now that it's active, submits.
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
// when it handles the key, which the `e.defaultPrevented` guard defers to.
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
// does the same) rather than folding it into the blanket-submit rule below.
form.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target === wtEnabled) {
e.preventDefault();
wtEnabled.checked = !wtEnabled.checked;
wtEnabled.dispatchEvent(new Event("change"));
return;
}
if (target instanceof HTMLButtonElement) {
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
e.preventDefault();
if (!target.classList.contains("is-active")) {
target.click();
return;
}
form.requestSubmit();
return;
}
e.preventDefault();
if (target === filterInput) {
const top = visibleGroups(filterInput.value)[0];
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
}
form.requestSubmit();
});
// ---- submit ----------------------------------------------------------
form.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -733,6 +939,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
el: form,
destroy() {
destroyed = true;
window.clearTimeout(probeTimer);
autocomplete.destroy();
form.remove();
},
+258
View File
@@ -0,0 +1,258 @@
// @vitest-environment happy-dom
// Enter submits the draft form from any field, at any point (#97). The two
// exceptions: a picker button that isn't yet the active choice takes the
// Enter to *choose* it instead (the next Enter submits), and the project
// filter must resolve to the highlighted project rather than sending
// whatever cwd was already set.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: url.includes("/api/dirs")
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
describe("spawnForm Enter-to-submit (#97)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
function submitSpy(): ReturnType<typeof vi.fn> {
return vi.fn();
}
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
el.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(toggle);
expect(toggle.checked).toBe(false);
expect(spy).not.toHaveBeenCalled();
pressEnter(toggle);
expect(toggle.checked).toBe(true);
expect(spy).not.toHaveBeenCalled();
});
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("raw cwd field once the autocomplete dropdown is closed", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/xyz-nothing-here";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
const form = await mount([MAESTRO]);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.focus();
raw.value = "/opt/";
raw.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
const { defaultPrevented } = pressEnter(raw);
expect(defaultPrevented).toBe(true);
expect(spy).not.toHaveBeenCalled();
expect(raw.value).toBe("/opt/nomatch-zzz/");
});
});
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO.path);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
const first = pressEnter(b);
expect(first.defaultPrevented).toBe(true);
expect(b.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(b);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(choice);
expect(choice.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(choice);
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe("project filter", () => {
it("submits with the highlighted (first matching) project, not the filter text", async () => {
const form = await mount([MAESTRO, OTHER]);
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "rev";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
await drain();
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "maestro";
filter.dispatchEvent(new Event("input"));
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
"is-active",
),
).toBe(true);
});
});
});
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
* everything else (names, projects, gitroot) answers immediately with an
* empty/default body. Lets a test pick a preset (or type a custom command)
* mid-flight, then observe what the late `/api/info` resolution does to it. */
function stubDeferredInfo(): { resolveInfo: () => void } {
let releaseInfo: () => void = () => {};
const infoGate = new Promise<void>((resolve) => {
releaseInfo = resolve;
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/info")) {
await infoGate;
return new Response(
JSON.stringify({
hostname: "h",
version: "0",
home_dir: "/home/user",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "fish", label: "Fish", command: "fish" },
{ id: "custom", label: "Custom…", command: "" },
],
}),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return { resolveInfo: releaseInfo };
}
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
let handle: SpawnFormHandle | null = null;
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
});
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
it("keeps an operator-picked tile after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
expect(zshTile.classList.contains("is-active")).toBe(true);
// /api/info lands late with a preset list that doesn't even include "zsh".
resolveInfo();
await drain();
// The operator's pick must not be discarded back to the new list's first
// entry — `onChange` only starts firing once the initial load settles
// (`ready`), which happens right here, so this is the first place the
// published command is observable.
expect(values?.command).toBe("zsh");
});
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
cmdRaw.value = "htop";
cmdRaw.dispatchEvent(new Event("input"));
resolveInfo();
await drain();
expect(values?.command).toBe("htop");
expect(cmdRaw.value).toBe("htop");
});
it("still applies the real presets' first entry when the operator never touched the command", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
resolveInfo();
await drain();
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
});
});
+148
View File
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
* spawnForm.ts (#74). */
const DEBOUNCE_MS = 250;
const PROJECTS = [
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
];
/** Stub the fetches the form makes. Gitroot responses for a given path can be
* held until `releaseGitRoot(path)` is called, so tests can land answers out
* of order. */
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
urls: string[];
gitRootCalls: string[];
releaseGitRoot: (path: string) => void;
} {
const urls: string[] = [];
const gitRootCalls: string[] = [];
const releases = new Map<string, () => void>();
const held = new Map<string, Promise<void>>();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.includes("/api/dirs")) {
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
}
if (url.includes("/api/gitroot")) {
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
gitRootCalls.push(path);
if (opts?.holdGitRootFor?.includes(path)) {
let release!: () => void;
held.set(
path,
new Promise<void>((r) => {
release = r;
}),
);
releases.set(path, release);
}
await held.get(path);
return new Response(JSON.stringify({ root: path }), { status: 200 });
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
{ status: 200 },
);
}),
);
return {
urls,
gitRootCalls,
releaseGitRoot: (path: string) => releases.get(path)?.(),
};
}
describe("spawnForm gitroot probe debounce (#74)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
const stub = stubFetch(opts);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return { form: handle.el, stub };
}
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
function typeRaw(form: HTMLFormElement, value: string): void {
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = value;
raw.dispatchEvent(new Event("input"));
}
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
form.querySelector<HTMLElement>(".project-other")!.click();
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
for (let i = 1; i <= "/opt/somewhere".length; i++) {
typeRaw(form, "/opt/somewhere".slice(0, i));
await drain();
expect(stub.gitRootCalls).toEqual([]);
}
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
});
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
form.querySelector<HTMLElement>(".project-other")!.click();
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Newer target supersedes it and answers immediately.
typeRaw(form, "/repo/new");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
// The late answer to the old target lands last but must lose.
stub.releaseGitRoot("/repo/old");
await drain();
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
});
});
+105 -6
View File
@@ -18,6 +18,7 @@ const BRANCHES = [
* its branch list is still in flight. */
function stubFetch(opts?: {
gateBranches?: (release: () => void) => void;
projects?: unknown[];
}): string[] {
const urls: string[] = [];
const gated = new Promise<void>((resolve) => {
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
return new Response(JSON.stringify(BRANCHES), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
@@ -79,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
};
function pickProject(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
form.querySelector<HTMLButtonElement>(".project-other")!.click();
form
.querySelectorAll<HTMLButtonElement>(".project-row")
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
);
});
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
false,
);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("still probes the gitroot for a free-text directory", async () => {
const form = await mount();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
// fire — debounced, so the quiet period must elapse first (#74).
form.querySelector<HTMLElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(250);
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
@@ -149,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
expect(modes.at(-1)).toBe("existing");
});
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
const form = await mount();
pickProject(form);
await drain();
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
expect(newLabel).not.toBe("Branch");
expect(existLabel).not.toBe("Branch");
expect(newLabel).not.toBe(existLabel);
expect(newLabel).toMatch(/name/i);
expect(existLabel).toMatch(/pick|choose|select/i);
});
it("collapses the advanced (worktree) section by default (#93)", async () => {
const form = await mount();
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
});
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
const form = await mount();
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
});
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
await drain();
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
// Still collapsed — picking a project must not force it open.
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"false",
);
});
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
const form = await mount();
const before = [...form.querySelectorAll(".field")];
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"true",
);
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
});
it("does not repopulate a destroyed form when the branch list lands late", async () => {
const gate: { release?: () => void } = {};
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
+296 -45
View File
@@ -727,10 +727,8 @@ textarea {
}
/* Earlier path segments collapse to their initial; the trailing segment (the
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
by the sidebar tree and the spawn dialog's recent-directory picker. */
.folder-name .folder-stem,
.recent-dir .folder-stem {
one that actually names the dir) stays full. Dim the collapsed prefix. */
.folder-name .folder-stem {
color: var(--faint);
}
@@ -831,6 +829,13 @@ textarea {
flex: 0 0 auto;
}
/* #83: the session row's blocked-work warning — an unforced kill will be
refused, and the operator should see that before pressing the button. */
.leaf-badge.leaf-blocked {
color: var(--warn, #b45309);
border-color: var(--warn, #b45309);
}
.leaf-actions {
position: absolute;
right: var(--sp-1);
@@ -1208,6 +1213,9 @@ textarea {
/* ---------- forms ---------- */
.spawn-form {
width: 100%;
max-width: 60ch;
margin: 0 auto;
padding: var(--sp-3);
overflow: auto;
display: flex;
@@ -1276,18 +1284,33 @@ textarea {
.worktree {
border: 1px solid var(--border);
border-radius: var(--r-2);
padding: var(--sp-3);
padding: 0 var(--sp-3);
margin: 0;
display: flex;
flex-direction: column;
gap: var(--sp-3);
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
}
.worktree legend {
padding: 0 6px;
color: var(--ink);
font-size: var(--fs-13);
.worktree .wt-toggle {
margin: 0 calc(var(--sp-3) * -1);
padding-left: var(--sp-3);
width: calc(100% + var(--sp-3) * 2);
}
.wt-summary {
color: var(--muted);
font-size: var(--fs-12);
}
.wt-collapsible {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
}
.wt-collapsible[hidden] {
display: none;
}
.form-error {
@@ -1371,8 +1394,9 @@ textarea {
color: var(--muted);
}
/* Project picker rows (#59). Worktree rows are indented under their base
repository; rows stale beyond 7 days are dimmed but still selectable. */
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
picked project's worktree choices. Stale beyond 7 days dims but stays
selectable. */
.picker {
display: flex;
flex-direction: column;
@@ -1387,73 +1411,155 @@ textarea {
width: 100%;
}
.project-row {
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
step two and the raw-cwd hatch stayed visible while hidden. */
.wt-step[hidden],
.raw-wrap[hidden] {
display: none;
}
.picker.projects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start;
gap: var(--sp-2);
max-height: 300px;
padding: 1px;
}
.project-box {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: var(--sp-2) var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
text-align: left;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.project-box:hover {
border-color: var(--border-strong);
}
.project-box.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-box.is-stale {
opacity: 0.55;
}
.project-name {
font-size: var(--fs-13);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The whole path, never abbreviated — it is what tells two projects sharing a
basename apart. Wraps rather than truncating so none of it is hidden. */
.project-path {
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.35;
color: var(--muted);
overflow-wrap: anywhere;
}
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other {
align-self: flex-start;
padding: 4px 0;
min-height: 30px;
background: none;
border: 0;
color: var(--muted);
font-family: inherit;
font-size: var(--fs-13);
font-style: italic;
text-align: left;
cursor: pointer;
}
.project-other:hover {
color: var(--ink);
}
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
.picker.wt-choices {
max-height: 200px;
}
.wt-choice {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
min-height: 32px;
padding: 6px var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: left;
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.project-row:hover {
.wt-choice:hover {
border-color: var(--border-strong);
}
.project-row.is-active {
.wt-choice.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.project-row.is-worktree {
margin-left: var(--sp-4);
}
.project-row.is-stale {
opacity: 0.55;
}
.project-info {
display: flex;
align-items: baseline;
gap: var(--sp-2);
min-width: 0;
}
.project-name {
.wt-choice-name {
flex: 0 0 auto;
font-size: var(--fs-13);
font-weight: 600;
white-space: nowrap;
}
.recent-dir {
.wt-choice-new .wt-choice-name {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
}
.recent-cmd {
flex: 0 0 auto;
.wt-choice-sub {
min-width: 0;
font-family: var(--font-mono);
font-size: var(--fs-12);
line-height: 1.35;
color: var(--muted);
}
.project-other .recent-dir {
font-family: inherit;
font-style: italic;
text-align: right;
overflow-wrap: anywhere;
}
.picker-empty {
@@ -2225,3 +2331,148 @@ textarea {
background: oklch(0 0 0 / 0.5);
}
}
/* ---------- retained worktrees (#84) ---------- */
.worktrees {
padding: var(--sp-4);
max-width: 960px;
margin: 0 auto;
}
.wt-head {
margin-bottom: var(--sp-4);
}
.wt-title {
font-family: var(--font-mono);
font-size: var(--fs-15);
font-weight: 600;
color: var(--ink);
margin: 0 0 var(--sp-1);
}
.wt-sub {
color: var(--muted);
font-size: var(--fs-13);
margin: 0;
}
.wt-row {
display: flex;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-2);
border-bottom: 1px solid var(--surface-2);
font-family: var(--font-mono);
font-size: var(--fs-13);
flex-wrap: wrap;
}
.wt-branch {
color: var(--ink);
font-weight: 600;
}
.wt-path {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wt-reason {
color: var(--faint);
}
/* Status never rides on color alone: the label itself says what blocks. */
.wt-way[data-clear] {
color: var(--faint);
}
.wt-way:not([data-clear]) {
color: var(--warn);
}
.wt-row .wt-reclaim-btn {
margin-left: auto;
}
.wt-row .wt-reclaim-btn::before {
content: "[";
color: var(--danger);
}
.wt-row .wt-reclaim-btn::after {
content: "]";
color: var(--danger);
}
.wt-row .wt-reclaim-btn:hover {
color: var(--danger);
background: var(--danger-tint);
}
.btn-danger {
color: var(--danger);
font-weight: 600;
}
.btn-danger:hover {
background: var(--danger-tint);
}
/* The destructive confirmation, centered over the section. */
.wt-confirm {
position: fixed;
inset: 0;
z-index: var(--z-backdrop);
display: flex;
align-items: center;
justify-content: center;
background: oklch(0 0 0 / 0.5);
padding: var(--sp-4);
}
.wt-confirm[hidden] {
display: none;
}
.wt-confirm-panel {
pointer-events: auto;
background: var(--bg);
border: 1px solid var(--surface-2);
padding: var(--sp-4);
max-width: 480px;
font-family: var(--font-sans);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.wt-confirm-q {
margin: 0;
font-family: var(--font-mono);
font-weight: 600;
color: var(--ink);
}
.wt-confirm-way {
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-13);
color: var(--warn);
}
.wt-confirm-note {
margin: 0;
font-size: var(--fs-13);
color: var(--muted);
}
.wt-confirm-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
}
+56
View File
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
expect(merged.path).toBe("a/b");
});
});
describe("worktree sessions group under their repository (#99)", () => {
/** A session the daemon spawned into a worktree of `repo`. */
function worktreeSession(id: string, repo: string, wt: string): Session {
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
}
/** Top-level folder names, after linear chains are collapsed. */
function topFolders(root: TreeFolder): string[] {
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
}
it("nests under the repo, not the worktree directory", () => {
const root = buildTree(
[
worktreeSession(
"a",
"/home/n/tea/agent-skills",
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
const repo = root.children[0] as TreeFolder;
expect(repo.children).toHaveLength(1);
expect(repo.children[0].kind).toBe("leaf");
});
it("puts a repo's plain and worktree sessions side by side", () => {
const root = buildTree(
[
session("plain", "/home/n/tea/agent-skills"),
worktreeSession(
"wt",
"/home/n/tea/agent-skills",
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
),
],
"/home/n",
"",
) as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
const repo = root.children[0] as TreeFolder;
expect(repo.children).toHaveLength(2);
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
});
it("leaves a session without a worktree where it is", () => {
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
expect(topFolders(root)).toEqual(["~/tea/arr"]);
});
});
+4 -2
View File
@@ -2,7 +2,7 @@
// so it can be tested directly.
import type { Session } from "./types.js";
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
export interface TreeFolder {
kind: "folder";
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
const cwd = sessionCwd(s);
// Grouped by repository, so a worktree session lands under its repo
// rather than in a folder of its own (#99).
const cwd = sessionTreePath(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
+88 -2
View File
@@ -31,6 +31,21 @@ export interface Session {
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
* Drives the directory-based session tree. Absent until first observed. */
current_cwd?: string | null;
/** What currently blocks an unforced kill of this session (#83), as the
* daemon's watcher last probed it. Absent until probed and for sessions
* without a worktree; all counts default to 0 on the wire for older
* daemons. */
blocked_work?: InTheWay | null;
/** The worktree the daemon created for this session, absent when it did not
* create one. `base_repo` is the repository's main checkout, which is what
* the session tree groups by (#99). */
worktree?: SessionWorktree | null;
}
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
export interface SessionWorktree {
path: string;
base_repo: string;
}
/** One directory's immediate subdirectories (NAP-329). */
@@ -113,7 +128,6 @@ export type Projects = Project[];
export interface WorktreeSpec {
base_repo: string;
branch: string;
cleanup_on_kill?: boolean;
/** When false, `branch` already exists and is checked out as-is (#61).
* Defaults to true (create a new branch) on older daemons. */
create_branch?: boolean;
@@ -133,6 +147,67 @@ export interface BranchInfo {
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
export type Branches = BranchInfo[];
/** Why a retained worktree outlived its session (#80). New values are
* additive; unknown reasons render generically. */
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
/** What currently blocks an unforced kill of a worktree's session (#80),
* recomputed by the daemon on every read. All counts default to 0 on the
* wire for older daemons. */
export interface InTheWay {
uncommitted_files?: number;
untracked_files?: number;
unpushed_commits?: number;
}
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
* in-the-way state as of this read. Reclaiming it removes the directory but
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
export interface RetainedWorktree {
id: Uuid;
path: string;
base_repo: string;
branch: string;
reason: RetainedReason;
retained_at: IsoTimestamp;
in_the_way?: InTheWay;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
* the kill refusal, the TUI and this client, so no two clients can describe
* the same tree differently. Zero parts are omitted; all-zero renders
* "no unsaved work". */
export function formatInTheWay(w: InTheWay): string {
const parts: string[] = [];
const push = (n: number | undefined, unit: string): void => {
if (!n || n <= 0) return;
parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`);
};
push(w.uncommitted_files, "uncommitted file");
push(w.untracked_files, "untracked file");
push(w.unpushed_commits, "unpushed commit");
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
}
/** Same reason labels the CLI table and the TUI overlay use; unknown future
* reasons render their raw wire value. */
export function reasonLabel(reason: RetainedReason): string {
return reason;
}
/** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. Render it with
* {@link formatInTheWay} so every client phrases it identically. */
export function sessionBlockers(s: Session): InTheWay | null {
const w = s.blocked_work;
if (!w) return null;
const blocked =
(w.uncommitted_files ?? 0) > 0 ||
(w.untracked_files ?? 0) > 0 ||
(w.unpushed_commits ?? 0) > 0;
return blocked ? w : null;
}
export interface CreateSessionRequest {
cwd: string;
command: string;
@@ -166,12 +241,23 @@ export function sessionName(s: Session): string {
}
/** The directory a session lives in: its live `current_cwd` if the daemon has
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
* observed one, else the spawn `cwd`. */
export function sessionCwd(s: Session): string | undefined {
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
return s.cwd || undefined;
}
/** Where a session sits in the directory tree. A worktree session belongs to
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
* session of the same name, and the repository stops being where you look for
* the work. `base_repo` is the main checkout — the daemon resolves it. */
export function sessionTreePath(s: Session): string | undefined {
const repo = s.worktree?.base_repo;
if (typeof repo === "string" && repo) return repo;
return sessionCwd(s);
}
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
export function abbreviateCwd(cwd: string, home?: string): string {
+233
View File
@@ -0,0 +1,233 @@
// @vitest-environment happy-dom
// Second step of the working-directory picker (#95). See the interaction rules
// block in spawnForm.ts for what each choice means.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
last_command: "claude",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const WT_B: Project = {
path: "/home/user/tea/maestro/worktrees/spawn-v2",
name: "spawn-v2",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-21T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): string[] {
const urls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
return urls;
}
describe("spawnForm worktree step (#95)", () => {
let handle: SpawnFormHandle | null = null;
let urls: string[] = [];
let changes: SpawnFormValues[] = [];
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
changes = [];
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(projects: Project[]): Promise<HTMLFormElement> {
urls = stubFetch(projects);
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
const choices = (form: HTMLFormElement) => [
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
];
const activeChoice = (form: HTMLFormElement) =>
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
const toggle = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
it("hides the worktree step until a project is picked", async () => {
const form = await mount([MAESTRO, WT_A]);
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
box(form, MAESTRO.path).click();
await drain();
expect(step(form).hidden).toBe(false);
});
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
await drain();
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
expect(activeChoice(form)!.dataset.kind).toBe("new");
});
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
// The scan already knows the repo — no probe.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
});
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
await drain();
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
existing.click();
await drain();
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
expect(toggle(form).checked).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
expect(existing.classList.contains("is-active")).toBe(true);
// Still no probe: the scan reported this worktree's repo membership.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
// The project box stays marked — it is what the worktree list belongs to.
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
});
it("switching back to '+ new' re-enables the create toggle", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
await drain();
choices(form)
.find((c) => c.dataset.kind === "new")!
.click();
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
});
it("repopulates and resets to '+ new' when another project is picked", async () => {
const form = await mount([MAESTRO, WT_A, OTHER]);
box(form, MAESTRO.path).click();
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
await drain();
box(form, OTHER.path).click();
await drain();
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
expect(activeChoice(form)!.dataset.kind).toBe("new");
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
});
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
// No main checkout in the scan, so the orphan worktree is its own box.
const form = await mount([WT_A]);
box(form, WT_A.path).click();
await drain();
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
expect(toggle(form).checked).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
await drain();
expect(step(form).hidden).toBe(false);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
await drain();
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
expect(form.querySelector(".project-box.is-active")).toBeNull();
});
it("restores the picked worktree from a draft seed", async () => {
urls = stubFetch([MAESTRO, WT_A, WT_B]);
handle = createSpawnForm({
onSuccess() {},
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
});
document.body.appendChild(handle.el);
await drain();
const form = handle.el;
expect(step(form).hidden).toBe(false);
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
expect(toggle(form).checked).toBe(false);
});
});
+114
View File
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { createWorktreesView } from "./worktreesView.js";
import type { RetainedWorktree } from "./types.js";
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
return {
id: "11111111-2222-3333-4444-555555555555",
path: "/home/naps/tea/maestro/worktrees/feat/login",
base_repo: "/home/naps/tea/maestro",
branch: "feat/login",
reason: "force-killed",
retained_at: "2026-08-24T10:00:00Z",
in_the_way: {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
...overrides,
};
}
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
const reclaimed: string[] = [];
const view = createWorktreesView({
load: async () => items,
reclaim: async (id) => {
reclaimed.push(id);
items.splice(
items.findIndex((w) => w.id === id),
1,
);
},
homeDir: () => homeDir,
});
document.body.appendChild(view.el);
return { view, reclaimed };
}
describe("retained-worktrees view (#84)", () => {
it("lists branch, abbreviated path, reason and what is in the way", async () => {
const { view } = mount([
worktree({
path: "/home/naps/tea/maestro/worktrees/feat/login",
}),
]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("feat/login");
// Sidebar-style abbreviation of the path against the daemon's home dir,
// full path kept in the tooltip.
expect(text).toContain("t/m/w/f/login");
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
// Same reason labels as `maestro worktrees list` and the TUI overlay.
expect(text).toContain("force-killed");
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
});
it("says a tree whose work has since been pushed is clear", async () => {
const { view } = mount([worktree({ in_the_way: {} })]);
await view.refresh();
expect(view.el.textContent).toContain("no unsaved work");
});
it("renders an empty store as a normal state, not an error", async () => {
const { view } = mount([]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("no retained worktrees");
expect(text.toLowerCase()).not.toContain("error");
expect(text.toLowerCase()).not.toContain("failed");
});
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
const wt = worktree({ branch: "feat/login" });
const { view, reclaimed } = mount([wt]);
await view.refresh();
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
expect(confirmText).toContain("Reclaim worktree feat/login?");
// Names what will be lost before it happens…
expect(confirmText).toContain(
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
);
// …and says reclaim does not take the commits too.
expect(confirmText).toContain("never deleted");
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
expect(reclaimed).toEqual([]);
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
await Promise.resolve();
await Promise.resolve();
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
// The row is gone once the refresh lands.
expect(view.el.textContent).toContain("no retained worktrees");
});
it("sorts oldest-retained first, like every other client", async () => {
const { view } = mount([
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
]);
await view.refresh();
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
expect(branches).toEqual(["older", "newer"]);
});
});
+166
View File
@@ -0,0 +1,166 @@
// Retained-worktrees view (#84): every worktree the daemon is holding that no
// session uses, listed from `GET /api/worktrees` with a reclaim action.
//
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
// overlay: same reason labels, same in-the-way phrasing via
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
// as what it is — destructive — naming what will be lost before it happens
// and saying out loud that the branch survives (docs/WORKTREES.md).
import { escapeHtml } from "./html.js";
import {
abbreviateCwd,
abbreviateFolderName,
formatInTheWay,
reasonLabel,
type RetainedWorktree,
type Uuid,
} from "./types.js";
export interface WorktreesDeps {
/** `GET /api/worktrees`. */
load(): Promise<RetainedWorktree[]>;
/** `DELETE /api/worktrees/:id`. */
reclaim(id: Uuid): Promise<void>;
/** The daemon's home dir, for `~` abbreviation of paths. */
homeDir(): string | undefined;
}
export interface WorktreesViewHandle {
el: HTMLElement;
/** Re-fetch the store and re-render. Safe to call repeatedly. */
refresh(): Promise<void>;
dispose(): void;
}
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
return [...items].sort(
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
);
}
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
const el = document.createElement("section");
el.className = "worktrees";
el.innerHTML = `
<header class="wt-head">
<h2 class="wt-title">retained worktrees</h2>
<p class="wt-sub">
Kept when a session ended with unsaved work. Reclaiming removes the
directory only; branches are never deleted.
</p>
</header>
<div class="wt-list"></div>
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
`;
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
let pendingId: string | null = null;
const render = (items: RetainedWorktree[]) => {
listHost.replaceChildren();
if (items.length === 0) {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌂</div>
<div class="empty-title">no retained worktrees</div>
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
listHost.appendChild(empty);
return;
}
const home = deps.homeDir();
for (const w of sorted(items)) {
listHost.appendChild(renderRow(w, home));
}
};
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
const row = document.createElement("div");
row.className = "wt-row";
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
row.innerHTML = `
<span class="wt-branch">${escapeHtml(w.branch)}</span>
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
`;
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
showConfirm(w);
});
return row;
};
const showConfirm = (w: RetainedWorktree) => {
pendingId = w.id;
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
// in the way right now, and that the branch survives reclaim.
confirmEl.innerHTML = `
<div class="wt-confirm-panel">
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
<div class="wt-confirm-actions">
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
</div>
</div>`;
confirmEl.hidden = false;
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
};
const hideConfirm = () => {
pendingId = null;
confirmEl.hidden = true;
confirmEl.replaceChildren();
};
confirmEl.addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
if (target.closest(".wt-confirm-cancel")) {
hideConfirm();
return;
}
if (!target.closest(".wt-confirm-go") || !pendingId) return;
const id = pendingId;
hideConfirm();
try {
await deps.reclaim(id);
} catch (err) {
alert(`Failed to reclaim: ${(err as Error).message}`);
}
await refresh();
});
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
};
document.addEventListener("keydown", onKey);
const refresh = async () => {
try {
render(await deps.load());
} catch (err) {
listHost.replaceChildren();
const fail = document.createElement("div");
fail.className = "empty";
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
listHost.appendChild(fail);
}
};
return {
el,
refresh,
dispose() {
document.removeEventListener("keydown", onKey);
el.remove();
},
};
}