refactor(server): rename worktree destroy → teardown, add port to env, extract session code
- Rename `worktree.destroy` config field to `worktree.teardown` (breaking) - Rename getWorktreeDestroyCommands/runWorktreeDestroyCommands → teardown - Add PASEO_WORKTREE_PORT to teardown env from persisted metadata (omit if missing) - Extract worktree orchestration from session.ts into worktree-session.ts - Document teardown lifecycle hooks in worktree docs
This commit is contained in:
@@ -11,7 +11,9 @@ import {
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
} from "./worktree";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeMetadataPath,
|
||||
} from "./worktree-metadata.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
@@ -527,69 +529,106 @@ 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 () => {
|
||||
it("runs teardown commands from paseo.json before deleting a worktree", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
teardown: [
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.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,
|
||||
});
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
|
||||
{
|
||||
cwd: repoDir,
|
||||
},
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "destroy-branch",
|
||||
branchName: "teardown-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "destroy-test",
|
||||
worktreeSlug: "teardown-test",
|
||||
paseoHome,
|
||||
});
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: created.worktreePath,
|
||||
branchName: created.branchName,
|
||||
});
|
||||
|
||||
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(`source=${repoDir}`);
|
||||
expect(destroyLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(destroyLog).toContain("branch=destroy-branch");
|
||||
const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
|
||||
expect(teardownLog).toContain(`source=${repoDir}`);
|
||||
expect(teardownLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(teardownLog).toContain("branch=teardown-branch");
|
||||
expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`);
|
||||
});
|
||||
|
||||
it("does not remove worktree when a destroy command fails", async () => {
|
||||
it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"',
|
||||
teardown: [
|
||||
'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"',
|
||||
],
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'",
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "teardown-port-missing-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "teardown-port-missing-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
|
||||
expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset");
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not remove worktree when a teardown command fails", async () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
teardown: [
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-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'",
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'",
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
const created = await createWorktree({
|
||||
branchName: "destroy-failure-branch",
|
||||
branchName: "teardown-failure-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "destroy-failure-test",
|
||||
worktreeSlug: "teardown-failure-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
await expect(
|
||||
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
|
||||
).rejects.toThrow("Worktree destroy command failed");
|
||||
).rejects.toThrow("Worktree teardown command failed");
|
||||
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true);
|
||||
expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
interface PaseoConfig {
|
||||
worktree?: {
|
||||
setup?: string[];
|
||||
destroy?: string[];
|
||||
teardown?: string[];
|
||||
terminals?: WorktreeTerminalConfig[];
|
||||
};
|
||||
}
|
||||
@@ -94,14 +94,14 @@ export class WorktreeSetupError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult;
|
||||
export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
|
||||
|
||||
export class WorktreeDestroyError extends Error {
|
||||
readonly results: WorktreeDestroyCommandResult[];
|
||||
export class WorktreeTeardownError extends Error {
|
||||
readonly results: WorktreeTeardownCommandResult[];
|
||||
|
||||
constructor(message: string, results: WorktreeDestroyCommandResult[]) {
|
||||
constructor(message: string, results: WorktreeTeardownCommandResult[]) {
|
||||
super(message);
|
||||
this.name = "WorktreeDestroyError";
|
||||
this.name = "WorktreeTeardownError";
|
||||
this.results = results;
|
||||
}
|
||||
}
|
||||
@@ -150,13 +150,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeDestroyCommands(repoRoot: string): string[] {
|
||||
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
||||
const config = readPaseoConfig(repoRoot);
|
||||
const destroyCommands = config?.worktree?.destroy;
|
||||
if (!destroyCommands || destroyCommands.length === 0) {
|
||||
const teardownCommands = config?.worktree?.teardown;
|
||||
if (!teardownCommands || teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||
@@ -506,14 +506,14 @@ export async function resolveWorktreeRuntimeEnv(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorktreeDestroyCommands(options: {
|
||||
export async function runWorktreeTeardownCommands(options: {
|
||||
worktreePath: string;
|
||||
branchName?: string;
|
||||
repoRootPath?: string;
|
||||
}): Promise<WorktreeDestroyCommandResult[]> {
|
||||
}): Promise<WorktreeTeardownCommandResult[]> {
|
||||
// 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) {
|
||||
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
|
||||
if (teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -521,30 +521,32 @@ export async function runWorktreeDestroyCommands(options: {
|
||||
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
|
||||
const branchName =
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const destroyEnv = {
|
||||
const teardownEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows destroy scripts to clean resources using paths from the
|
||||
// source checkout.
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// from the source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
};
|
||||
|
||||
const results: WorktreeDestroyCommandResult[] = [];
|
||||
for (const cmd of destroyCommands) {
|
||||
const results: WorktreeTeardownCommandResult[] = [];
|
||||
for (const cmd of teardownCommands) {
|
||||
const result = await execSetupCommand(cmd, {
|
||||
cwd: options.worktreePath,
|
||||
env: destroyEnv,
|
||||
env: teardownEnv,
|
||||
});
|
||||
results.push(result);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new WorktreeDestroyError(
|
||||
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
throw new WorktreeTeardownError(
|
||||
`Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
|
||||
results,
|
||||
);
|
||||
}
|
||||
@@ -884,7 +886,7 @@ export async function deletePaseoWorktree({
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
await runWorktreeDestroyCommands({
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user