Add worktree destroy lifecycle commands (#23)
This commit is contained in:
@@ -323,6 +323,70 @@ describe("paseo worktree manager", () => {
|
|||||||
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
|
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", () => {
|
describe("slugify", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
|||||||
interface PaseoConfig {
|
interface PaseoConfig {
|
||||||
worktree?: {
|
worktree?: {
|
||||||
setup?: string[];
|
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 {
|
export interface PaseoWorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
@@ -84,6 +97,15 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
|||||||
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
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(
|
async function execSetupCommand(
|
||||||
command: string,
|
command: string,
|
||||||
options: { cwd: string; env: NodeJS.ProcessEnv }
|
options: { cwd: string; env: NodeJS.ProcessEnv }
|
||||||
@@ -194,6 +216,67 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
|
||||||
|
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<WorktreeDestroyCommandResult[]> {
|
||||||
|
// 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.
|
* Get the git common directory (shared across worktrees) for a given cwd.
|
||||||
* This is where refs, objects, etc. are stored.
|
* 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");
|
throw new Error("Refusing to delete non-Paseo worktree");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await runWorktreeDestroyCommands({
|
||||||
|
worktreePath: resolvedWorktree,
|
||||||
|
});
|
||||||
|
|
||||||
await execAsync(`git worktree remove "${resolvedWorktree}" --force`, {
|
await execAsync(`git worktree remove "${resolvedWorktree}" --force`, {
|
||||||
cwd,
|
cwd,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user