From d50c1f5c362498ebe32629bca7d38cd9aa3b2960 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Wed, 15 Jul 2026 17:10:05 -0700 Subject: [PATCH 1/2] fix: prompt for standalone worktree removal --- src/commands/remove.ts | 67 ++++++++++++---- .../integration/standalone-lifecycle.test.ts | 80 +++++++++++++++++++ 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 07c938b..e5e3dbe 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -50,7 +50,11 @@ import { resolveWorkspaceContext, workspaceJsonMetadata } from "../lib/workspace import { runStandaloneGlobalHooks, standaloneWorktrees } from "../lib/standalone.ts"; import { exec as standaloneGitExec, getDefaultBranch } from "../lib/git.ts"; import { info, error as logError, spinner, warn } from "../lib/logger.ts"; -import { confirm as promptConfirm, multiSelect as promptMultiSelect } from "../lib/prompts.ts"; +import { + confirm as promptConfirm, + multiSelect as promptMultiSelect, + select as promptSelect, +} from "../lib/prompts.ts"; import { Command } from "commander"; import chalk from "chalk"; import { existsSync } from "fs"; @@ -209,28 +213,65 @@ export async function executeRemove( promptHandlers?: { confirm: (message: string, defaultValue?: boolean) => Promise>; multiSelect: (message: string, choices: Choice[]) => Promise>; + select?: (message: string, choices: Choice[]) => Promise>; }, ): Promise { const startTime = Date.now(); const workspaceContext = await resolveWorkspaceContext(); if (workspaceContext.mode === "standalone") { + const prompt = promptHandlers || { + confirm: promptConfirm, + multiSelect: promptMultiSelect, + select: promptSelect, + }; + const allowNonInteractive = Boolean(promptHandlers); + const worktrees = await standaloneWorktrees(workspaceContext); + let selectedTargetPath: string | undefined = undefined; if (!branchArg) { - throw new RemoveCommandError( - "A branch or worktree path is required in non-interactive standalone mode", - RemoveCommandErrorCode.NON_INTERACTIVE, + if (options.json) { + writeJsonEnvelope(unsupportedJsonModeError("remove", "interactive-selection")); + return ONE; + } + + const selectable = worktrees.filter( + (entry) => resolve(entry.path) !== resolve(workspaceContext.mainRoot), + ); + if (selectable.length === ZERO) { + info("No worktrees found to remove"); + return ZERO; + } + + ensureInteractive(allowNonInteractive); + const selection = await (prompt.select ?? promptSelect)( + "Select a worktree to remove:", + selectable.map((entry) => ({ + description: entry.path, + name: entry.branch || basename(entry.path), + value: entry.path, + })), ); + if (selection.status === "cancelled") { + info("Selection cancelled"); + return ZERO; + } + selectedTargetPath = selection.value; } - const worktrees = await standaloneWorktrees(workspaceContext); - const target = worktrees.find((entry) => - options.path ? resolve(entry.path) === resolve(branchArg) : entry.branch === branchArg, - ); - if (!target || target.path === workspaceContext.mainRoot) { + const target = worktrees.find((entry) => { + if (selectedTargetPath) { + return resolve(entry.path) === resolve(selectedTargetPath); + } + return options.path + ? resolve(entry.path) === resolve(branchArg!) + : entry.branch === branchArg; + }); + if (!target || resolve(target.path) === resolve(workspaceContext.mainRoot)) { throw new RemoveCommandError( - `Standalone worktree not found: ${branchArg}`, + `Standalone worktree not found: ${branchArg ?? selectedTargetPath}`, RemoveCommandErrorCode.BRANCH_NOT_FOUND, ); } + const targetLabel = target.branch ?? branchArg ?? selectedTargetPath ?? target.path; const repositoryName = workspaceContext.repository.name; const targetEntry: WorktreeEntry = { branch: target.branch ?? "", @@ -248,8 +289,6 @@ export async function executeRemove( const branchPresence: Record = target.branch ? { [target.branch]: [repositoryName] } : {}; - const prompt = promptHandlers || { confirm: promptConfirm, multiSelect: promptMultiSelect }; - const allowNonInteractive = Boolean(promptHandlers); if (!options.force && !options.dryRun && !(options.keepBranches && options.keepWorktrees)) { ensureInteractive(allowNonInteractive); const confirmation = await promptConfirmation({ @@ -342,7 +381,7 @@ export async function executeRemove( await runStandaloneGlobalHooks( workspaceContext, "pre-remove", - target.branch ?? branchArg, + targetLabel, target.path, false, options.json === true, @@ -403,7 +442,7 @@ export async function executeRemove( const postHookFailures = await runStandaloneGlobalHooks( workspaceContext, "post-remove", - target.branch ?? branchArg, + targetLabel, target.path, false, options.json === true, diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index d7743f9..34d16a8 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -225,6 +225,86 @@ describe("standalone lifecycle", () => { } }); + test("standalone explicit detached path preserves the hook target label", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + const relativeTarget = join(".worktrees", "detached-remove"); + const linked = join(root, relativeTarget); + expect((await run(root, ["git", "worktree", "add", "--detach", linked, "HEAD"])).exitCode).toBe( + 0, + ); + + const home = await mkdtemp(join(tmpdir(), "arashi-remove-hook-home-")); + roots.push(home); + const hookDirectory = join(home, ".arashi", "hooks"); + const hook = join(hookDirectory, "pre-remove.sh"); + const record = join(home, "branch-name"); + await mkdir(hookDirectory, { recursive: true }); + await writeFile(hook, `#!/bin/sh\nprintf '%s' "$ARASHI_BRANCH_NAME" > "${record}"\n`); + await chmod(hook, 0o755); + + const result = await arashi(root, ["remove", relativeTarget, "--path", "--force", "--json"], { + HOME: home, + }); + + expect(result.exitCode).toBe(0); + expect(await readFile(record, "utf8")).toBe(relativeTarget); + await expect(access(linked)).rejects.toThrow(); + }); + + test("standalone remove without a target prompts for a worktree", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "interactive-remove", "--json"]); + const linked = join(root, ".worktrees", "interactive-remove"); + const canonicalLinked = await realpath(linked); + + const originalCwd = process.cwd(); + process.chdir(root); + try { + const promptHandlers = { + confirm: async () => ({ status: "ok" as const, value: true }), + multiSelect: async () => ({ status: "ok" as const, value: [] }), + select: async (message: string, choices: { name: string; value: string }[]) => { + expect(message).toBe("Select a worktree to remove:"); + expect(choices).toEqual([ + expect.objectContaining({ + name: expect.stringContaining("interactive-remove"), + value: canonicalLinked, + }), + ]); + return { status: "ok" as const, value: canonicalLinked }; + }, + }; + + expect(await executeRemove(undefined, { force: true }, promptHandlers)).toBe(0); + } finally { + process.chdir(originalCwd); + } + + await expect(access(linked)).rejects.toThrow(); + expect((await run(root, ["git", "branch", "--list", "interactive-remove"])).stdout).toBe(""); + }); + + test("standalone remove JSON requires an explicit target", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "json-remove", "--json"]); + + const result = await arashi(root, ["remove", "--json"]); + + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + command: "remove", + error: { + code: "JSON_UNSUPPORTED_FOR_MODE", + details: { mode: "interactive-selection" }, + }, + ok: false, + }); + await expect(access(join(root, ".worktrees", "json-remove"))).resolves.toBeUndefined(); + }); + test("standalone remove dry-run reports a complete non-mutating plan", async () => { const root = await repository(); const canonicalRoot = await realpath(root); From 72902629076e612a4cf9ed9e5fabb9f03f38e1a4 Mon Sep 17 00:00:00 2001 From: Corwin Marsh Date: Wed, 15 Jul 2026 19:03:13 -0700 Subject: [PATCH 2/2] fix: exclude stale standalone remove targets --- contracts/cli-commands.json | 2 +- src/commands/remove.ts | 9 +++- src/lib/standalone.ts | 12 ++++- .../integration/standalone-lifecycle.test.ts | 54 +++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/contracts/cli-commands.json b/contracts/cli-commands.json index e55b6ad..4f9d839 100644 --- a/contracts/cli-commands.json +++ b/contracts/cli-commands.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "cliVersion": "1.19.2", + "cliVersion": "1.20.0", "commands": [ { "path": "add", diff --git a/src/commands/remove.ts b/src/commands/remove.ts index e5e3dbe..e6d5fbe 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -234,11 +234,18 @@ export async function executeRemove( return ONE; } + const prunable = worktrees.filter((entry) => entry.pruneReason || !existsSync(entry.path)); const selectable = worktrees.filter( - (entry) => resolve(entry.path) !== resolve(workspaceContext.mainRoot), + (entry) => + !entry.pruneReason && + existsSync(entry.path) && + resolve(entry.path) !== resolve(workspaceContext.mainRoot), ); if (selectable.length === ZERO) { info("No worktrees found to remove"); + if (prunable.length > ZERO) { + info("Stale worktree metadata found; run 'arashi prune' to clean it up"); + } return ZERO; } diff --git a/src/lib/standalone.ts b/src/lib/standalone.ts index 91c8b13..c6aec10 100644 --- a/src/lib/standalone.ts +++ b/src/lib/standalone.ts @@ -99,14 +99,22 @@ export async function standaloneWorktrees(context: StandaloneWorkspaceContext) { ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"], context.mainRoot, ); - const records: Array<{ branch: string | null; head: string; path: string }> = []; - let current: { branch: string | null; head: string; path: string } | null = null; + const records: Array<{ + branch: string | null; + head: string; + path: string; + pruneReason?: string; + }> = []; + let current: (typeof records)[number] | null = null; for (const line of result.stdout.split(/\r?\n/)) { if (line.startsWith("worktree ")) { current = { branch: null, head: "", path: line.slice(9) }; records.push(current); } else if (current && line.startsWith("HEAD ")) current.head = line.slice(5); else if (current && line.startsWith("branch refs/heads/")) current.branch = line.slice(18); + else if (current && line.startsWith("prunable")) { + current.pruneReason = line.slice("prunable".length).trim() || "stale worktree metadata"; + } } return records; } diff --git a/tests/integration/standalone-lifecycle.test.ts b/tests/integration/standalone-lifecycle.test.ts index 34d16a8..3f84700 100644 --- a/tests/integration/standalone-lifecycle.test.ts +++ b/tests/integration/standalone-lifecycle.test.ts @@ -286,6 +286,60 @@ describe("standalone lifecycle", () => { expect((await run(root, ["git", "branch", "--list", "interactive-remove"])).stdout).toBe(""); }); + test("standalone remove excludes prunable worktrees from selection", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + await arashi(root, ["create", "selectable-remove", "--json"]); + const selectable = await realpath(join(root, ".worktrees", "selectable-remove")); + const stale = join(root, ".worktrees", "stale-remove"); + expect( + (await run(root, ["git", "worktree", "add", "-b", "stale-remove", stale])).exitCode, + ).toBe(0); + await rm(stale, { force: true, recursive: true }); + + const originalCwd = process.cwd(); + process.chdir(root); + try { + const promptHandlers = { + confirm: async () => ({ status: "ok" as const, value: true }), + multiSelect: async () => ({ status: "ok" as const, value: [] }), + select: async (_message: string, choices: { name: string; value: string }[]) => { + expect(choices).toEqual([ + expect.objectContaining({ name: "selectable-remove", value: selectable }), + ]); + return { reason: "exit" as const, status: "cancelled" as const }; + }, + }; + + expect(await executeRemove(undefined, { force: true }, promptHandlers)).toBe(0); + } finally { + process.chdir(originalCwd); + } + + await expect(access(selectable)).resolves.toBeUndefined(); + expect((await run(root, ["git", "branch", "--list", "stale-remove"])).stdout).toContain( + "stale-remove", + ); + }); + + test("standalone remove directs stale-only selection to prune", async () => { + const root = await repository(); + await arashi(root, ["init", "--zero-config"]); + const stale = join(root, ".worktrees", "stale-only-remove"); + expect( + (await run(root, ["git", "worktree", "add", "-b", "stale-only-remove", stale])).exitCode, + ).toBe(0); + await rm(stale, { force: true, recursive: true }); + + const result = await arashi(root, ["remove", "--force"]); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("arashi prune"); + expect((await run(root, ["git", "branch", "--list", "stale-only-remove"])).stdout).toContain( + "stale-only-remove", + ); + }); + test("standalone remove JSON requires an explicit target", async () => { const root = await repository(); await arashi(root, ["init", "--zero-config"]);