diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 0effe817a..56a2b6a6b 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -323,6 +323,70 @@ describe("paseo worktree manager", () => { expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false); }); + it("runs destroy commands from paseo.json before deleting a worktree", async () => { + const paseoConfig = { + worktree: { + destroy: [ + 'echo "root=$PASEO_ROOT_PATH" > "$PASEO_ROOT_PATH/destroy.log"', + 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_ROOT_PATH/destroy.log"', + 'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_ROOT_PATH/destroy.log"', + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync( + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", + { cwd: repoDir } + ); + + const created = await createWorktree({ + branchName: "destroy-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "destroy-test", + paseoHome, + }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + expect(existsSync(created.worktreePath)).toBe(false); + + const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8"); + expect(destroyLog).toContain(`root=${repoDir}`); + expect(destroyLog).toContain(`worktree=${created.worktreePath}`); + expect(destroyLog).toContain("branch=destroy-branch"); + }); + + it("does not remove worktree when a destroy command fails", async () => { + const paseoConfig = { + worktree: { + destroy: [ + 'echo "started" > "$PASEO_ROOT_PATH/destroy-start.log"', + "echo boom 1>&2; exit 9", + ], + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync( + "git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing destroy commands'", + { cwd: repoDir } + ); + + const created = await createWorktree({ + branchName: "destroy-failure-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "destroy-failure-test", + paseoHome, + }); + + await expect( + deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }) + ).rejects.toThrow("Worktree destroy command failed"); + + expect(existsSync(created.worktreePath)).toBe(true); + expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true); + }); + }); describe("slugify", () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 5cf1da7fd..03175e400 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -9,6 +9,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js"; interface PaseoConfig { worktree?: { setup?: string[]; + destroy?: string[]; }; } @@ -41,6 +42,18 @@ export class WorktreeSetupError extends Error { } } +export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult; + +export class WorktreeDestroyError extends Error { + readonly results: WorktreeDestroyCommandResult[]; + + constructor(message: string, results: WorktreeDestroyCommandResult[]) { + super(message); + this.name = "WorktreeDestroyError"; + this.results = results; + } +} + export interface PaseoWorktreeInfo { path: string; branchName?: string; @@ -84,6 +97,15 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] { return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); } +export function getWorktreeDestroyCommands(repoRoot: string): string[] { + const config = readPaseoConfig(repoRoot); + const destroyCommands = config?.worktree?.destroy; + if (!destroyCommands || destroyCommands.length === 0) { + return []; + } + return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); +} + async function execSetupCommand( command: string, options: { cwd: string; env: NodeJS.ProcessEnv } @@ -194,6 +216,67 @@ export async function runWorktreeSetupCommands(options: { return results; } +async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { + try { + const { stdout } = await execAsync("git branch --show-current", { + cwd: worktreePath, + env: READ_ONLY_GIT_ENV, + }); + const branchName = stdout.trim(); + if (branchName.length > 0) { + return branchName; + } + } catch { + // ignore + } + + return basename(worktreePath); +} + +export async function runWorktreeDestroyCommands(options: { + worktreePath: string; + branchName?: string; + repoRootPath?: string; +}): Promise { + // Read paseo.json from the worktree (it will have the same content as the source repo) + const destroyCommands = getWorktreeDestroyCommands(options.worktreePath); + if (destroyCommands.length === 0) { + return []; + } + + const repoRootPath = + options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); + const branchName = + options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); + + const destroyEnv = { + ...process.env, + // Root is the original git repo root (shared across worktrees), not the worktree itself. + // This allows destroy scripts to clean resources using paths from the main checkout. + PASEO_ROOT_PATH: repoRootPath, + PASEO_WORKTREE_PATH: options.worktreePath, + PASEO_BRANCH_NAME: branchName, + }; + + const results: WorktreeDestroyCommandResult[] = []; + for (const cmd of destroyCommands) { + const result = await execSetupCommand(cmd, { + cwd: options.worktreePath, + env: destroyEnv, + }); + results.push(result); + + if (result.exitCode !== 0) { + throw new WorktreeDestroyError( + `Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(), + results + ); + } + } + + return results; +} + /** * Get the git common directory (shared across worktrees) for a given cwd. * This is where refs, objects, etc. are stored. @@ -542,6 +625,10 @@ export async function deletePaseoWorktree({ throw new Error("Refusing to delete non-Paseo worktree"); } + await runWorktreeDestroyCommands({ + worktreePath: resolvedWorktree, + }); + await execAsync(`git worktree remove "${resolvedWorktree}" --force`, { cwd, });