The cleanup guard plus deterministic paths had created a trap: one
untracked file an agent left behind — a log, a .env, a stray artifact —
made the guard refuse removal, the directory stayed, and every later
spawn of that branch hit PathExists. The branch was burned permanently
and the product offered no way to clear it. A leftover tree registered
for the same branch is now adopted, so the operator lands back in their
own unsaved work. A tree a *live* session holds is still rejected, since
two sessions must not share a checkout.
Also:
- Fast-forward still awaited /api/info, /api/projects and /api/names
inline, the exact freeze the draft loaders were restructured to avoid.
It posts to the same channel now.
- Draft updates were routed by tab index, which shifts when a tab closes:
an in-flight load could be dropped or land on the wrong pane. Drafts
carry an id and are found by it.
- extend_names left the name it had just displayed at the head of the
queue, so the next reroll produced it again and the key looked dead.
- The fast-forward fallback discarded info.home_dir, so its draft showed
unabbreviated paths and seeded "Other directory…" with / — disagreeing
with the normal draft entry point.
- Web fast-forward mounted via openSession, which replaces the focused
pane: pressing it while attached evicted that session from the layout.
It reuses an empty pane or splits, as the TUI does.
- branchEdited was inferred from the seeded branch value, but in "new"
mode that value is the auto-synced session name — so any form rebuild
froze the branch against later rerolls.
- resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset
expanded under an unticked box and probing gitroot for a worktree the
operator had turned off.
- short_hash used DefaultHasher, whose algorithm is not stable across
Rust releases, in a directory name — a toolchain bump would orphan
every worktree under the override root. Now FNV-1a.
The existing-branch picker could never succeed in either client: both
treated the main checkout's HEAD as available, and since that is usually
`main` it became the default pick — git then refuses the second checkout.
A TUI test had baked the bug in by asserting the selection lands on the
`is_head` entry. Both pickers now treat "checked out anywhere" as held.
Worktree paths were still not injective. Flattening `/` and `.` to `-`
made `feat/x`, `feat.x` and `feat-x` share one directory, and the
override root keyed on the repo basename alone, so `~/a/maestro` and
`~/b/maestro` collided. A branch name is already a valid relative path —
git stores it as refs/heads/feat/example — so it is used verbatim and
nests under `worktrees/feat/`; the override root gains a digest of the
full repo path. Git forbids holding both `feat` and `feat/x`, so the
nesting cannot collide for valid refs.
Also:
- Recovered sessions dropped their worktree association, so after a
restart a kill cleaned up nothing and the leftover directory blocked
that branch forever. It is persisted on Session now.
- The nesting guard compared git's resolved paths against an unresolved
one, so it silently passed on any symlinked repo path — and that guard
is what stands between a kill and an inner tree's uncommitted work.
- Draft loads ran on the TUI event loop: /api/projects on a cold cache
walks all of $HOME, freezing keys, frames and attached panes for up to
the request timeout. They are detached tasks posting to a channel now.
- Fast-forward in the web client created the session and never mounted
it, so `N` looked like a dead key while the TUI attached.
- The fast-forward fallback seed forced the worktree toggle off, against
both a normal draft and the TUI.
- The projects background refresh honoured the TTL, so two of every three
ticks were no-ops and requests paid for the rescan instead.
Worktree on by default over a git repository with Enter/Space to spawn
in the checkout itself; base repo inferred from picked projects or a
guarded /api/gitroot probe for raw paths (a miss unticks, web parity);
new-branch name tracking the generated session name until touched;
Left/Right switches to picking an existing branch from GET /api/branches,
with held branches skipped and rendered dimmed beside their holder.
No cleanup option: cleanup is unconditional daemon-side (#56).
Action::OpenSpawnModal becomes NewDraftPane: pressing s turns the empty
pane into an inline draft form (or opens a new tab when the pane shows a
session). The form is a pane-level widget with its own field focus; a
successful submit replaces the draft with the new session in place,
matching the web client's draft panes. SpawnModal and OverlayKind::Spawn
are removed; kill-confirm and help overlays stay.