From b80d604408e37bea63382176b888f4c8f9782165 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 14:52:17 +0100 Subject: [PATCH] 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. --- crates/maestro-tui/src/draft.rs | 39 +++++++++++++++++++++++++++++++ web/src/spawnForm.ts | 16 ++++++++++--- web/src/spawnFormWorktree.test.ts | 31 +++++++++++++++++++++++- 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 3e26386..6604326 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -654,6 +654,11 @@ impl DraftPane { let project = visible[sel].project.clone(); self.picked_cwd = Some(project.path.clone()); self.cwd_raw_visible = false; + // A directory that *is* a linked worktree defaults to spawning in + // place (#91): creating a second worktree of its branch fails. + // Turning it back on by hand still tries (git's own message), + // which is deliberate (#91 non-goal). + self.worktree_enabled = !project.is_worktree; // The scan already knows this directory's repo (#61): no probe // round trip needed, exactly like the web form. let repo = project @@ -1689,6 +1694,40 @@ mod tests { assert!(wt.create_branch); } + #[test] + fn picking_a_worktree_project_defaults_the_toggle_off() { + let mut draft = DraftPane::default(); + let mut wt = repo_project("wt-91", "/repos/wt91", "/repos/main", 1, None); + wt.is_worktree = true; + draft.set_projects( + vec![ + repo_project("main", "/repos/main", "/repos/main", 2, None), + wt, + ], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + typed(&mut draft, "wt-91"); + draft.on_input(&InputKey::Enter); + + assert!( + !draft.worktree_enabled, + "the directory is already a worktree; spawning in place" + ); + assert!( + draft.build_request().unwrap().worktree.is_none(), + "no worktree record — the session just runs there (#91)" + ); + + // Deliberately re-enabling still works, and the scan-known base repo + // survives so the toggle-on path needs no new probe. + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + assert!(draft.worktree_enabled); + assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/repos/main"))); + assert!(draft.take_gitroot_probe().is_none()); + } + #[test] fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() { let mut draft = DraftPane { diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 194d71c..1b130ce 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -430,9 +430,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { 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); + // toggle defaults back on and no /api/gitroot round trip is needed — + // unless the directory *is* a linked worktree itself (#91): spawning + // in place is the only thing that works there, so default the toggle + // off. Turning it back on by hand still tries (and fails with git's + // own message), which is deliberate (#91 non-goal). + wtEnabled.checked = !project.is_worktree; + if (project.is_worktree) { + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = true; + } else { + applyProjectRepo(project.base_repo ?? project.path); + } 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 !== ""); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts index 76fd12c..b8012e1 100644 --- a/web/src/spawnFormWorktree.test.ts +++ b/web/src/spawnFormWorktree.test.ts @@ -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((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: [] }), @@ -100,6 +101,34 @@ 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(".project-row") + .forEach((row) => row.dataset.cwd === "/home/user/wt-91" && row.click()); + await drain(); + + expect(handle.el.querySelector("input[name=worktree_enabled]")!.checked).toBe( + false, + ); + expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false); + expect(handle.el.querySelector(".wt-body")!.hidden).toBe(true); + }); + it("still probes the gitroot for a free-text directory", async () => { const form = await mount();