Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/cli-commands.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"cliVersion": "1.19.2",
"cliVersion": "1.20.0",
"commands": [
{
"path": "add",
Expand Down
74 changes: 60 additions & 14 deletions src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -209,28 +213,72 @@ export async function executeRemove(
promptHandlers?: {
confirm: (message: string, defaultValue?: boolean) => Promise<PromptOutcome<boolean>>;
multiSelect: (message: string, choices: Choice<string>[]) => Promise<PromptOutcome<string[]>>;
select?: (message: string, choices: Choice<string>[]) => Promise<PromptOutcome<string>>;
},
): Promise<number> {
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 prunable = worktrees.filter((entry) => entry.pruneReason || !existsSync(entry.path));
const selectable = worktrees.filter(
(entry) =>
!entry.pruneReason &&
existsSync(entry.path) &&
resolve(entry.path) !== resolve(workspaceContext.mainRoot),
);
Comment thread
corwinm marked this conversation as resolved.
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;
}

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 ?? "",
Expand All @@ -248,8 +296,6 @@ export async function executeRemove(
const branchPresence: Record<string, string[]> = 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({
Expand Down Expand Up @@ -342,7 +388,7 @@ export async function executeRemove(
await runStandaloneGlobalHooks(
workspaceContext,
"pre-remove",
target.branch ?? branchArg,
targetLabel,
target.path,
false,
options.json === true,
Expand Down Expand Up @@ -403,7 +449,7 @@ export async function executeRemove(
const postHookFailures = await runStandaloneGlobalHooks(
workspaceContext,
"post-remove",
target.branch ?? branchArg,
targetLabel,
target.path,
false,
options.json === true,
Expand Down
12 changes: 10 additions & 2 deletions src/lib/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
134 changes: 134 additions & 0 deletions tests/integration/standalone-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,140 @@ 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 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"]);
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);
Expand Down
Loading