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:
Mohamed Boudra
2026-03-30 14:53:27 +07:00
parent 7504d20b67
commit a94bc0720c
6 changed files with 953 additions and 580 deletions
@@ -730,7 +730,7 @@ describe("daemon E2E", () => {
});
describe("archivePaseoWorktree", () => {
test("archives worktree by running destroy commands and shutting down worktree terminals", async () => {
test("archives worktree by running teardown commands and shutting down worktree terminals", async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
@@ -749,7 +749,7 @@ describe("daemon E2E", () => {
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt");
const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt");
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
@@ -760,12 +760,12 @@ describe("daemon E2E", () => {
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
destroy: [`echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`],
teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`],
},
}),
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", {
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", {
cwd: repoRoot,
stdio: "pipe",
});
@@ -813,8 +813,8 @@ describe("daemon E2E", () => {
expect(archive.removedAgents).toContain(agent.id);
expect(existsSync(agent.cwd)).toBe(false);
expect(existsSync(destroyMarkerPath)).toBe(true);
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd);
expect(existsSync(teardownMarkerPath)).toBe(true);
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd);
+86 -522
View File
@@ -136,22 +136,12 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
type WorktreeConfig,
computeWorktreePath,
getWorktreeSetupCommands,
resolveWorktreeRuntimeEnv,
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
getCheckoutShortstat,
getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions,
commitChanges,
mergeToBase,
@@ -159,7 +149,6 @@ import {
pushCurrentBranch,
createPullRequest,
getPullRequestStatus,
resolveRepositoryDefaultBranch,
} from "../utils/checkout-git.js";
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
@@ -182,6 +171,16 @@ import {
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import {
assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
createPaseoWorktreeInBackground as createWorktreeInBackgroundSession,
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession,
} from "./worktree-session.js";
const execAsync = promisify(exec);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
@@ -238,14 +237,6 @@ type WorkspaceGitWatchTarget = {
latestFingerprint: string | null;
};
type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
type ActiveTerminalStream = {
terminalId: string;
slot: number;
@@ -330,7 +321,6 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
const AgentIdSchema = z.string().uuid();
const VOICE_MCP_SERVER_NAME = "paseo_voice";
const VOICE_INTERRUPT_CONFIRMATION_MS = 500;
@@ -2904,70 +2894,18 @@ export class Session {
legacyWorktreeName?: string,
_labels?: Record<string, string>,
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
let cwd = expandTilde(config.cwd);
const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName);
let worktreeConfig: WorktreeConfig | undefined;
if (!normalized) {
return {
sessionConfig: {
...config,
cwd,
},
};
}
if (normalized.createWorktree) {
let targetBranch: string;
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
// Resolve current branch name from HEAD
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
env: READ_ONLY_GIT_ENV,
});
targetBranch = stdout.trim();
}
if (!targetBranch) {
throw new Error("A branch name is required when creating a worktree.");
}
this.sessionLogger.info(
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`,
);
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
});
cwd = createdWorktree.worktreePath;
worktreeConfig = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd));
await this.createBranchFromBase({
cwd,
baseBranch,
newBranchName: normalized.newBranchName!,
});
} else if (normalized.baseBranch) {
await this.checkoutExistingBranch(cwd, normalized.baseBranch);
}
return {
sessionConfig: {
...config,
cwd,
sessionLogger: this.sessionLogger,
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
},
worktreeConfig,
};
config,
gitOptions,
legacyWorktreeName,
_labels,
);
}
private async handleListProviderModelsRequest(
@@ -3034,84 +2972,8 @@ export class Session {
}
}
private normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
): NormalizedGitOptions | null {
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
? {
createWorktree: true,
createNewBranch: true,
newBranchName: legacyWorktreeName,
worktreeSlug: legacyWorktreeName,
}
: undefined;
const merged = gitOptions ?? fallbackOptions;
if (!merged) {
return null;
}
const baseBranch = merged.baseBranch?.trim() || undefined;
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
const normalizedWorktreeSlug = merged.worktreeSlug
? slugify(merged.worktreeSlug)
: normalizedBranchName;
if (!createWorktree && !createNewBranch && !baseBranch) {
return null;
}
if (baseBranch) {
this.assertSafeGitRef(baseBranch, "base branch");
}
if (createNewBranch) {
if (!normalizedBranchName) {
throw new Error("New branch name is required");
}
const validation = validateBranchSlug(normalizedBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
if (normalizedWorktreeSlug) {
const validation = validateBranchSlug(normalizedWorktreeSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
}
return {
baseBranch,
createNewBranch,
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
};
}
private assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
throw new Error(`Invalid ${label}: ${ref}`);
}
}
private async resolveGitCreateBaseBranch(cwd: string): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome });
if (!checkout.isGit) {
throw new Error("Cannot create a worktree outside a git repository");
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
return baseBranch;
assertWorktreeSafeGitRef(ref, label);
}
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
@@ -4346,193 +4208,32 @@ export class Session {
private async handlePaseoWorktreeListRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
const { requestId } = msg;
const cwd = msg.repoRoot ?? msg.cwd;
if (!cwd) {
this.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: [],
error: { code: "UNKNOWN", message: "cwd or repoRoot is required" },
requestId,
},
});
return;
}
try {
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: this.paseoHome });
this.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: worktrees.map((entry) => ({
worktreePath: entry.path,
createdAt: entry.createdAt,
branchName: entry.branchName ?? null,
head: entry.head ?? null,
})),
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: [],
error: toCheckoutError(error),
requestId,
},
});
}
}
private async archivePaseoWorktree(options: {
targetPath: string;
repoRoot: string;
requestId: string;
}): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: this.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const agents = this.agentManager.listAgents();
for (const agent of agents) {
if (this.isPathWithinRoot(targetPath, agent.cwd)) {
removedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
try {
await this.agentManager.closeAgent(agent.id);
} catch {
// ignore cleanup errors
}
try {
await this.agentStorage.remove(agent.id);
} catch {
// ignore cleanup errors
}
}
}
const registryRecords = await this.agentStorage.list();
for (const record of registryRecords) {
if (this.isPathWithinRoot(targetPath, record.cwd)) {
removedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
try {
await this.agentStorage.remove(record.id);
} catch {
// ignore cleanup errors
}
}
}
await this.killTerminalsUnderPath(targetPath);
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
paseoHome: this.paseoHome,
});
for (const workspaceId of affectedWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
}
for (const agentId of removedAgents) {
this.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
});
}
await this.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
return Array.from(removedAgents);
return handleWorktreeListRequest(
{
emit: (message) => this.emit(message),
paseoHome: this.paseoHome,
},
msg,
);
}
private async handlePaseoWorktreeArchiveRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
const { requestId } = msg;
let targetPath = msg.worktreePath;
let repoRoot = msg.repoRoot ?? null;
try {
if (!targetPath) {
if (!repoRoot || !msg.branchName) {
throw new Error("worktreePath or repoRoot+branchName is required");
}
const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome });
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
if (!match) {
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
}
targetPath = match.path;
}
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
return handleWorktreeArchiveRequest(
{
paseoHome: this.paseoHome,
});
if (!ownership.allowed) {
this.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
error: {
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
},
requestId,
},
});
return;
}
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
if (!repoRoot) {
throw new Error("Unable to resolve repo root for worktree");
}
const removedAgents = await this.archivePaseoWorktree({
targetPath,
repoRoot,
requestId,
});
this.emit({
type: "paseo_worktree_archive_response",
payload: {
success: true,
removedAgents,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
error: toCheckoutError(error),
requestId,
},
});
}
agentManager: this.agentManager,
agentStorage: this.agentStorage,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds),
isPathWithinRoot: (rootPath, candidatePath) =>
this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) => this.killTerminalsUnderPath(rootPath),
},
msg,
);
}
/**
@@ -5543,50 +5244,20 @@ export class Session {
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
return registerPendingWorktreeWorkspaceSession(
{
buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input),
buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
projectRegistry: this.projectRegistry,
syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
workspaceRegistry: this.workspaceRegistry,
archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
this.archiveProjectRecordIfEmpty(projectId, archivedAt),
},
};
const now = new Date().toISOString();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
options,
);
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
@@ -5849,64 +5520,18 @@ export class Session {
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome });
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4();
const validation = validateBranchSlug(normalizedSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, this.paseoHome);
const workspace = await this.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath,
branchName: normalizedSlug,
});
const descriptor = await this.describeWorkspaceRecord(workspace);
this.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: descriptor,
error: null,
setupTerminalId: null,
requestId: request.requestId,
},
});
void this.createPaseoWorktreeInBackground({
requestCwd: request.cwd,
repoRoot,
baseBranch,
slug: normalizedSlug,
worktreePath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree";
this.sessionLogger.error(
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
"Failed to create worktree",
);
this.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: null,
error: message,
setupTerminalId: null,
requestId: request.requestId,
},
});
}
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
this.registerPendingWorktreeWorkspace(options),
sessionLogger: this.sessionLogger,
createPaseoWorktreeInBackground: (options) => this.createPaseoWorktreeInBackground(options),
},
request,
);
}
private async createPaseoWorktreeInBackground(options: {
@@ -5916,57 +5541,16 @@ export class Session {
slug: string;
worktreePath: string;
}): Promise<void> {
let setupTerminalId: string | null = null;
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
return createWorktreeInBackgroundSession(
{
paseoHome: this.paseoHome,
});
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length > 0 && this.terminalManager) {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.slug,
repoRootPath: options.repoRoot,
});
this.terminalManager.registerCwdEnv({
cwd: options.worktreePath,
env: runtimeEnv,
});
const terminal = await this.terminalManager.createTerminal({
cwd: options.worktreePath,
name: `setup-${options.slug}`,
env: runtimeEnv,
});
setupTerminalId = terminal.id;
for (const command of setupCommands) {
terminal.send({
type: "input",
data: `${command}\r`,
});
}
}
} catch (error) {
this.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: options.slug,
worktreePath: options.worktreePath,
setupTerminalId,
},
"Background worktree creation failed",
);
} finally {
await this.emitWorkspaceUpdateForCwd(options.worktreePath);
}
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
},
options,
);
}
private async handleArchiveWorkspaceRequest(
@@ -7868,36 +7452,16 @@ export class Session {
}
private async killTerminalsUnderPath(rootPath: string): Promise<void> {
if (!this.terminalManager) {
return;
}
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
const terminalDirectories = [...this.terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) {
if (!this.isPathWithinRoot(rootPath, terminalCwd)) {
continue;
}
try {
const terminals = await this.terminalManager.getTerminals(terminalCwd);
for (const terminal of [...terminals]) {
this.killTrackedTerminal(terminal.id, { emitExit: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupErrors.push({ cwd: terminalCwd, message });
this.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to clean up worktree terminals during archive",
);
}
}
if (cleanupErrors.length > 0) {
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; ");
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
}
return killWorktreeTerminalsUnderPath(
{
isPathWithinRoot: (pathRoot, candidatePath) =>
this.isPathWithinRoot(pathRoot, candidatePath),
killTrackedTerminal: (terminalId, options) => this.killTrackedTerminal(terminalId, options),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
},
rootPath,
);
}
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
@@ -0,0 +1,721 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
type GitSetupOptions,
type ProjectPlacementPayload,
type SessionInboundMessage,
type SessionOutboundMessage,
type WorkspaceDescriptorPayload,
} from "./messages.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import { createAgentWorktree } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import {
getCheckoutStatusLite,
resolveRepositoryDefaultBranch,
} from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
computeWorktreePath,
deletePaseoWorktree,
getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
resolvePaseoWorktreeRootForCwd,
resolveWorktreeRuntimeEnv,
slugify,
validateBranchSlug,
type WorktreeConfig,
} from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
const execAsync = promisify(exec);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
export type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type BuildAgentSessionConfigDependencies = {
paseoHome?: string;
sessionLogger: Logger;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
createBranchFromBase: (params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}) => Promise<void>;
};
type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
};
type RegisterPendingWorktreeWorkspaceDependencies = {
buildPersistedProjectRecord: (input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedProjectRecord;
buildPersistedWorkspaceRecord: (input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (
cwd: string,
options?: { dedupeGitState?: boolean },
) => Promise<void>;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage;
registerPendingWorktreeWorkspace: (options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
sessionLogger: Logger;
createPaseoWorktreeInBackground: (options: {
requestCwd: string;
repoRoot: string;
baseBranch: string;
slug: string;
worktreePath: string;
}) => Promise<void>;
};
type KillTerminalsUnderPathDependencies = {
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
export async function buildAgentSessionConfig(
dependencies: BuildAgentSessionConfigDependencies,
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
_labels?: Record<string, string>,
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
let cwd = expandTilde(config.cwd);
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
let worktreeConfig: WorktreeConfig | undefined;
if (!normalized) {
return {
sessionConfig: {
...config,
cwd,
},
};
}
if (normalized.createWorktree) {
let targetBranch: string;
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
env: READ_ONLY_GIT_ENV,
});
targetBranch = stdout.trim();
}
if (!targetBranch) {
throw new Error("A branch name is required when creating a worktree.");
}
dependencies.sessionLogger.info(
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`,
);
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
paseoHome: dependencies.paseoHome,
});
cwd = createdWorktree.worktreePath;
worktreeConfig = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
await dependencies.createBranchFromBase({
cwd,
baseBranch,
newBranchName: normalized.newBranchName!,
});
} else if (normalized.baseBranch) {
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
}
return {
sessionConfig: {
...config,
cwd,
},
worktreeConfig,
};
}
export function normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
): NormalizedGitOptions | null {
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
? {
createWorktree: true,
createNewBranch: true,
newBranchName: legacyWorktreeName,
worktreeSlug: legacyWorktreeName,
}
: undefined;
const merged = gitOptions ?? fallbackOptions;
if (!merged) {
return null;
}
const baseBranch = merged.baseBranch?.trim() || undefined;
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
const normalizedWorktreeSlug = merged.worktreeSlug
? slugify(merged.worktreeSlug)
: normalizedBranchName;
if (!createWorktree && !createNewBranch && !baseBranch) {
return null;
}
if (baseBranch) {
assertSafeGitRef(baseBranch, "base branch");
}
if (createNewBranch) {
if (!normalizedBranchName) {
throw new Error("New branch name is required");
}
const validation = validateBranchSlug(normalizedBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
if (normalizedWorktreeSlug) {
const validation = validateBranchSlug(normalizedWorktreeSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
}
return {
baseBranch,
createNewBranch,
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
};
}
export function assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
throw new Error(`Invalid ${label}: ${ref}`);
}
}
export async function resolveGitCreateBaseBranch(
cwd: string,
paseoHome?: string,
): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
if (!checkout.isGit) {
throw new Error("Cannot create a worktree outside a git repository");
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
return baseBranch;
}
export async function handlePaseoWorktreeListRequest(
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
const { requestId } = msg;
const cwd = msg.repoRoot ?? msg.cwd;
if (!cwd) {
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: [],
error: { code: "UNKNOWN", message: "cwd or repoRoot is required" },
requestId,
},
});
return;
}
try {
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome });
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: worktrees.map((entry) => ({
worktreePath: entry.path,
createdAt: entry.createdAt,
branchName: entry.branchName ?? null,
head: entry.head ?? null,
})),
error: null,
requestId,
},
});
} catch (error) {
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: [],
error: toCheckoutError(error),
requestId,
},
});
}
}
export async function archivePaseoWorktree(
dependencies: ArchivePaseoWorktreeDependencies,
options: {
targetPath: string;
repoRoot: string;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const agents = dependencies.agentManager.listAgents();
for (const agent of agents) {
if (!dependencies.isPathWithinRoot(targetPath, agent.cwd)) {
continue;
}
removedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
try {
await dependencies.agentManager.closeAgent(agent.id);
} catch {
// ignore cleanup errors
}
try {
await dependencies.agentStorage.remove(agent.id);
} catch {
// ignore cleanup errors
}
}
const registryRecords = await dependencies.agentStorage.list();
for (const record of registryRecords) {
if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) {
continue;
}
removedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
try {
await dependencies.agentStorage.remove(record.id);
} catch {
// ignore cleanup errors
}
}
await dependencies.killTerminalsUnderPath(targetPath);
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
paseoHome: dependencies.paseoHome,
});
for (const workspaceId of affectedWorkspaceIds) {
await dependencies.archiveWorkspaceRecord(workspaceId);
}
for (const agentId of removedAgents) {
dependencies.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
});
}
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
return Array.from(removedAgents);
}
export async function handlePaseoWorktreeArchiveRequest(
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
const { requestId } = msg;
let targetPath = msg.worktreePath;
let repoRoot = msg.repoRoot ?? null;
try {
if (!targetPath) {
if (!repoRoot || !msg.branchName) {
throw new Error("worktreePath or repoRoot+branchName is required");
}
const worktrees = await listPaseoWorktrees({
cwd: repoRoot,
paseoHome: dependencies.paseoHome,
});
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
if (!match) {
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
}
targetPath = match.path;
}
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (!ownership.allowed) {
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
error: {
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
},
requestId,
},
});
return;
}
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
if (!repoRoot) {
throw new Error("Unable to resolve repo root for worktree");
}
const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath,
repoRoot,
requestId,
});
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: true,
removedAgents,
error: null,
requestId,
},
});
} catch (error) {
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
error: toCheckoutError(error),
requestId,
},
});
}
}
export async function registerPendingWorktreeWorkspace(
dependencies: RegisterPendingWorktreeWorkspaceDependencies,
options: {
repoRoot: string;
worktreePath: string;
branchName: string;
},
): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = new Date().toISOString();
const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId);
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await dependencies.projectRegistry.upsert(nextProjectRecord);
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
}
export async function handleCreatePaseoWorktreeRequest(
dependencies: HandleCreatePaseoWorktreeRequestDependencies,
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const checkout = await getCheckoutStatusLite(request.cwd, {
paseoHome: dependencies.paseoHome,
});
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4();
const validation = validateBranchSlug(normalizedSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
const workspace = await dependencies.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath,
branchName: normalizedSlug,
});
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: descriptor,
error: null,
setupTerminalId: null,
requestId: request.requestId,
},
});
void dependencies.createPaseoWorktreeInBackground({
requestCwd: request.cwd,
repoRoot,
baseBranch,
slug: normalizedSlug,
worktreePath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree";
dependencies.sessionLogger.error(
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
"Failed to create worktree",
);
dependencies.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: null,
error: message,
setupTerminalId: null,
requestId: request.requestId,
},
});
}
}
export async function createPaseoWorktreeInBackground(
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
options: {
requestCwd: string;
repoRoot: string;
baseBranch: string;
slug: string;
worktreePath: string;
},
): Promise<void> {
let setupTerminalId: string | null = null;
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: dependencies.paseoHome,
});
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length > 0 && dependencies.terminalManager) {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.slug,
repoRootPath: options.repoRoot,
});
dependencies.terminalManager.registerCwdEnv({
cwd: options.worktreePath,
env: runtimeEnv,
});
const terminal = await dependencies.terminalManager.createTerminal({
cwd: options.worktreePath,
name: `setup-${options.slug}`,
env: runtimeEnv,
});
setupTerminalId = terminal.id;
for (const command of setupCommands) {
terminal.send({
type: "input",
data: `${command}\r`,
});
}
}
} catch (error) {
dependencies.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: options.slug,
worktreePath: options.worktreePath,
setupTerminalId,
},
"Background worktree creation failed",
);
} finally {
await dependencies.emitWorkspaceUpdateForCwd(options.worktreePath);
}
}
export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,
): Promise<void> {
if (!dependencies.terminalManager) {
return;
}
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
const terminalDirectories = [...dependencies.terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) {
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
continue;
}
try {
const terminals = await dependencies.terminalManager.getTerminals(terminalCwd);
for (const terminal of [...terminals]) {
dependencies.killTrackedTerminal(terminal.id, { emitExit: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupErrors.push({ cwd: terminalCwd, message });
dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to clean up worktree terminals during archive",
);
}
}
if (cleanupErrors.length > 0) {
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; ");
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
}
}
+64 -25
View File
@@ -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);
});
});
+25 -23
View File
@@ -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,
});
+51 -4
View File
@@ -96,7 +96,7 @@ function Worktrees() {
{/* paseo.json */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Setup with paseo.json</h2>
<h2 className="text-xl font-medium">Lifecycle hooks with paseo.json</h2>
<p className="text-white/60 leading-relaxed">
When Paseo creates a worktree, it's a fresh checkout. Dependencies aren't installed,
config files aren't copied. You can automate setup by creating a{" "}
@@ -117,6 +117,20 @@ function Worktrees() {
the worktree is created. Use it to install dependencies, copy local config files, or run
any other initialization.
</p>
<p className="text-white/60 leading-relaxed">
You can also add a <code className="font-mono">teardown</code> array for cleanup commands
that run before Paseo removes the worktree directory during archive:
</p>
<Code>
<pre className="text-white/80">{`{
"worktree": {
"teardown": [
"pkill -f \\"vite --port $PASEO_WORKTREE_PORT\\" || true",
"rm -rf \\"$PASEO_WORKTREE_PATH/.cache\\""
]
}
}`}</pre>
</Code>
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 text-white/80">
<strong>Important:</strong> Setup commands come from{" "}
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{" "}
@@ -130,7 +144,7 @@ function Worktrees() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Environment variables</h2>
<p className="text-white/60 leading-relaxed">
Setup commands have access to these environment variables:
Setup and teardown commands have access to these environment variables:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
@@ -148,8 +162,8 @@ function Worktrees() {
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
</li>
<li>
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for
setup scripts
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — the worktree port, when
runtime metadata exists
</li>
</ul>
<p className="text-white/60 leading-relaxed">
@@ -157,6 +171,39 @@ function Worktrees() {
shouldn't be in git (like <code className="font-mono">.env</code>) from your source
checkout to the worktree.
</p>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">$PASEO_WORKTREE_PORT</code> is available when the worktree
was bootstrapped with a port. That makes it useful for both starting services in setup
and stopping them again in teardown.
</p>
</section>
{/* Teardown */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Teardown</h2>
<p className="text-white/60 leading-relaxed">
Teardown runs during archive, before Paseo removes the worktree directory. Use it for
cleanup that needs access to the worktree path or its assigned port.
</p>
<p className="text-white/60 leading-relaxed">
Common uses include stopping dev servers on{" "}
<code className="font-mono">$PASEO_WORKTREE_PORT</code>, deleting generated files, or
deregistering services tied to that worktree.
</p>
<Code>
<pre className="text-white/80">{`{
"worktree": {
"setup": [
"npm ci",
"nohup npm run dev -- --port $PASEO_WORKTREE_PORT > \\"$PASEO_WORKTREE_PATH/dev.log\\" 2>&1 &"
],
"teardown": [
"pkill -f \\"npm run dev -- --port $PASEO_WORKTREE_PORT\\" || true",
"rm -f \\"$PASEO_WORKTREE_PATH/dev.log\\""
]
}
}`}</pre>
</Code>
</section>
{/* Common patterns */}