diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index e4af15564..2f5381c86 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -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); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 61d593c92..8b5ecbbd8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, ): 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 { - 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, ): Promise { - 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 { - let targetPath = options.targetPath; - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { - paseoHome: this.paseoHome, - }); - if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; - } - - const removedAgents = new Set(); - const affectedWorkspaceCwds = new Set([targetPath]); - const affectedWorkspaceIds = new Set([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, ): Promise { - 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 { - 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 { @@ -5849,64 +5520,18 @@ export class Session { private async handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { - 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 { - 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 { - 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 { diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts new file mode 100644 index 000000000..2076c53e9 --- /dev/null +++ b/packages/server/src/server/worktree-session.ts @@ -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; + createBranchFromBase: (params: { + cwd: string; + baseBranch: string; + newBranchName: string; + }) => Promise; +}; + +type ArchivePaseoWorktreeDependencies = { + paseoHome?: string; + agentManager: Pick; + agentStorage: Pick; + archiveWorkspaceRecord: (workspaceId: string) => Promise; + emit: EmitSessionMessage; + emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; + killTerminalsUnderPath: (rootPath: string) => Promise; +}; + +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; + projectRegistry: Pick; + syncWorkspaceGitWatchTarget: ( + cwd: string, + options: { isGit: boolean }, + ) => Promise; + workspaceRegistry: Pick; + archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise; +}; + +type CreatePaseoWorktreeInBackgroundDependencies = { + paseoHome?: string; + emitWorkspaceUpdateForCwd: ( + cwd: string, + options?: { dedupeGitState?: boolean }, + ) => Promise; + sessionLogger: Logger; + terminalManager: TerminalManager | null; +}; + +type HandleCreatePaseoWorktreeRequestDependencies = { + paseoHome?: string; + describeWorkspaceRecord: ( + workspace: PersistedWorkspaceRecord, + ) => Promise; + emit: EmitSessionMessage; + registerPendingWorktreeWorkspace: (options: { + repoRoot: string; + worktreePath: string; + branchName: string; + }) => Promise; + sessionLogger: Logger; + createPaseoWorktreeInBackground: (options: { + requestCwd: string; + repoRoot: string; + baseBranch: string; + slug: string; + worktreePath: string; + }) => Promise; +}; + +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, +): 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 { + 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, +): Promise { + 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 { + let targetPath = options.targetPath; + const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { + paseoHome: dependencies.paseoHome, + }); + if (resolvedWorktree) { + targetPath = resolvedWorktree.worktreePath; + } + + const removedAgents = new Set(); + const affectedWorkspaceCwds = new Set([targetPath]); + const affectedWorkspaceIds = new Set([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 & { + emit: EmitSessionMessage; + emitWorkspaceUpdatesForCwds: (cwds: Iterable) => Promise; + }, + msg: Extract, +): Promise { + 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 { + 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, +): Promise { + 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 { + 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 { + 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})`); + } +} diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 6ccf35980..de6df46b1 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -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); }); }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 0103befc6..193901553 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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 { +}): 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) { + 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, }); diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 18b02ed4a..17e959d95 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -96,7 +96,7 @@ function Worktrees() { {/* paseo.json */}
-

Setup with paseo.json

+

Lifecycle hooks with paseo.json

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.

+

+ You can also add a teardown array for cleanup commands + that run before Paseo removes the worktree directory during archive: +

+ +
{`{
+  "worktree": {
+    "teardown": [
+      "pkill -f \\"vite --port $PASEO_WORKTREE_PORT\\" || true",
+      "rm -rf \\"$PASEO_WORKTREE_PATH/.cache\\""
+    ]
+  }
+}`}
+
Important: Setup commands come from{" "} paseo.json in the selected base branch. If you pick{" "} @@ -130,7 +144,7 @@ function Worktrees() {

Environment variables

- Setup commands have access to these environment variables: + Setup and teardown commands have access to these environment variables:

  • @@ -148,8 +162,8 @@ function Worktrees() { $PASEO_BRANCH_NAME — the branch name created
  • - $PASEO_WORKTREE_PORT — an available local port for - setup scripts + $PASEO_WORKTREE_PORT — the worktree port, when + runtime metadata exists

@@ -157,6 +171,39 @@ function Worktrees() { shouldn't be in git (like .env) from your source checkout to the worktree.

+

+ $PASEO_WORKTREE_PORT 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. +

+
+ + {/* Teardown */} +
+

Teardown

+

+ 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. +

+

+ Common uses include stopping dev servers on{" "} + $PASEO_WORKTREE_PORT, deleting generated files, or + deregistering services tied to that worktree. +

+ +
{`{
+  "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\\""
+    ]
+  }
+}`}
+
{/* Common patterns */}