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", () => { 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 repoRoot = tmpCwd();
const { execSync } = await import("child_process"); const { execSync } = await import("child_process");
@@ -749,7 +749,7 @@ describe("daemon E2E", () => {
}); });
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); 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( writeFileSync(
path.join(repoRoot, "paseo.json"), path.join(repoRoot, "paseo.json"),
JSON.stringify({ JSON.stringify({
@@ -760,12 +760,12 @@ describe("daemon E2E", () => {
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', 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 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, cwd: repoRoot,
stdio: "pipe", stdio: "pipe",
}); });
@@ -813,8 +813,8 @@ describe("daemon E2E", () => {
expect(archive.removedAgents).toContain(agent.id); expect(archive.removedAgents).toContain(agent.id);
expect(existsSync(agent.cwd)).toBe(false); expect(existsSync(agent.cwd)).toBe(false);
expect(existsSync(destroyMarkerPath)).toBe(true); expect(existsSync(teardownMarkerPath)).toBe(true);
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd); expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd); 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 { PushTokenStore } from "./push/token-store.js";
import { import {
type WorktreeConfig, type WorktreeConfig,
computeWorktreePath,
getWorktreeSetupCommands,
resolveWorktreeRuntimeEnv,
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js"; } from "../utils/worktree.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import { import {
getCheckoutDiff, getCheckoutDiff,
getCheckoutShortstat, getCheckoutShortstat,
getCheckoutStatus, getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions, listBranchSuggestions,
commitChanges, commitChanges,
mergeToBase, mergeToBase,
@@ -159,7 +149,6 @@ import {
pushCurrentBranch, pushCurrentBranch,
createPullRequest, createPullRequest,
getPullRequestStatus, getPullRequestStatus,
resolveRepositoryDefaultBranch,
} from "../utils/checkout-git.js"; } from "../utils/checkout-git.js";
import { getProjectIcon } from "../utils/project-icon.js"; import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js"; import { expandTilde } from "../utils/path.js";
@@ -182,6 +171,16 @@ import {
import { notifyChatMentions } from "./chat/chat-mentions.js"; import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js"; import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/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 execAsync = promisify(exec);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
@@ -238,14 +237,6 @@ type WorkspaceGitWatchTarget = {
latestFingerprint: string | null; latestFingerprint: string | null;
}; };
type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
type ActiveTerminalStream = { type ActiveTerminalStream = {
terminalId: string; terminalId: string;
slot: number; slot: number;
@@ -330,7 +321,6 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round( const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
); );
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
const AgentIdSchema = z.string().uuid(); const AgentIdSchema = z.string().uuid();
const VOICE_MCP_SERVER_NAME = "paseo_voice"; const VOICE_MCP_SERVER_NAME = "paseo_voice";
const VOICE_INTERRUPT_CONFIRMATION_MS = 500; const VOICE_INTERRUPT_CONFIRMATION_MS = 500;
@@ -2904,70 +2894,18 @@ export class Session {
legacyWorktreeName?: string, legacyWorktreeName?: string,
_labels?: Record<string, string>, _labels?: Record<string, string>,
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> { ): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
let cwd = expandTilde(config.cwd); return buildWorktreeAgentSessionConfig(
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,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
}); sessionLogger: this.sessionLogger,
cwd = createdWorktree.worktreePath; checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
worktreeConfig = createdWorktree; createBranchFromBase: (params) => this.createBranchFromBase(params),
} 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,
}, },
worktreeConfig, config,
}; gitOptions,
legacyWorktreeName,
_labels,
);
} }
private async handleListProviderModelsRequest( 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 { private assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) { assertWorktreeSafeGitRef(ref, label);
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;
} }
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean { private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
@@ -4346,193 +4208,32 @@ export class Session {
private async handlePaseoWorktreeListRequest( private async handlePaseoWorktreeListRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>, msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> { ): Promise<void> {
const { requestId } = msg; return handleWorktreeListRequest(
const cwd = msg.repoRoot ?? msg.cwd; {
if (!cwd) { emit: (message) => this.emit(message),
this.emit({ paseoHome: this.paseoHome,
type: "paseo_worktree_list_response", },
payload: { msg,
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);
} }
private async handlePaseoWorktreeArchiveRequest( private async handlePaseoWorktreeArchiveRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>, msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> { ): Promise<void> {
const { requestId } = msg; return handleWorktreeArchiveRequest(
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, {
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
}); agentManager: this.agentManager,
if (!ownership.allowed) { agentStorage: this.agentStorage,
this.emit({ archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
type: "paseo_worktree_archive_response", emit: (message) => this.emit(message),
payload: { emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds),
success: false, isPathWithinRoot: (rootPath, candidatePath) =>
removedAgents: [], this.isPathWithinRoot(rootPath, candidatePath),
error: { killTerminalsUnderPath: (rootPath) => this.killTerminalsUnderPath(rootPath),
code: "NOT_ALLOWED", },
message: "Worktree is not a Paseo-owned worktree", msg,
}, );
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,
},
});
}
} }
/** /**
@@ -5543,50 +5244,20 @@ export class Session {
worktreePath: string; worktreePath: string;
branchName: string; branchName: string;
}): Promise<PersistedWorkspaceRecord> { }): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath); return registerPendingWorktreeWorkspaceSession(
const basePlacement = await this.buildProjectPlacement(options.repoRoot); {
const placement: ProjectPlacementPayload = { buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input),
...basePlacement, buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input),
checkout: { buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd),
cwd: workspaceId, projectRegistry: this.projectRegistry,
isGit: true, syncWorkspaceGitWatchTarget: (cwd, syncOptions) =>
currentBranch: options.branchName, this.syncWorkspaceGitWatchTarget(cwd, syncOptions),
remoteUrl: basePlacement.checkout.remoteUrl, workspaceRegistry: this.workspaceRegistry,
isPaseoOwnedWorktree: true, archiveProjectRecordIfEmpty: (projectId, archivedAt) =>
mainRepoRoot: options.repoRoot, this.archiveProjectRecordIfEmpty(projectId, archivedAt),
}, },
}; options,
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;
} }
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> { private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
@@ -5849,64 +5520,18 @@ export class Session {
private async handleCreatePaseoWorktreeRequest( private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>, request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> { ): Promise<void> {
try { return handleCreateWorktreeRequest(
const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome }); {
if (!checkout.isGit) { paseoHome: this.paseoHome,
throw new Error("Create worktree requires a git repository"); describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
} emit: (message) => this.emit(message),
registerPendingWorktreeWorkspace: (options) =>
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd; this.registerPendingWorktreeWorkspace(options),
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); sessionLogger: this.sessionLogger,
if (!baseBranch) { createPaseoWorktreeInBackground: (options) => this.createPaseoWorktreeInBackground(options),
throw new Error("Unable to resolve repository default branch"); },
} request,
);
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,
},
});
}
} }
private async createPaseoWorktreeInBackground(options: { private async createPaseoWorktreeInBackground(options: {
@@ -5916,57 +5541,16 @@ export class Session {
slug: string; slug: string;
worktreePath: string; worktreePath: string;
}): Promise<void> { }): Promise<void> {
let setupTerminalId: string | null = null; return createWorktreeInBackgroundSession(
{
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
}); emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
const setupCommands = getWorktreeSetupCommands(options.worktreePath); sessionLogger: this.sessionLogger,
if (setupCommands.length > 0 && this.terminalManager) { terminalManager: this.terminalManager,
const runtimeEnv = await resolveWorktreeRuntimeEnv({ },
worktreePath: options.worktreePath, options,
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);
}
} }
private async handleArchiveWorkspaceRequest( private async handleArchiveWorkspaceRequest(
@@ -7868,36 +7452,16 @@ export class Session {
} }
private async killTerminalsUnderPath(rootPath: string): Promise<void> { private async killTerminalsUnderPath(rootPath: string): Promise<void> {
if (!this.terminalManager) { return killWorktreeTerminalsUnderPath(
return; {
} isPathWithinRoot: (pathRoot, candidatePath) =>
this.isPathWithinRoot(pathRoot, candidatePath),
const cleanupErrors: Array<{ cwd: string; message: string }> = []; killTrackedTerminal: (terminalId, options) => this.killTrackedTerminal(terminalId, options),
const terminalDirectories = [...this.terminalManager.listDirectories()]; sessionLogger: this.sessionLogger,
for (const terminalCwd of terminalDirectories) { terminalManager: this.terminalManager,
if (!this.isPathWithinRoot(rootPath, terminalCwd)) { },
continue; rootPath,
} );
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})`);
}
} }
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> { 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, runWorktreeSetupCommands,
slugify, slugify,
} from "./worktree"; } from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import {
getPaseoWorktreeMetadataPath,
} from "./worktree-metadata.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
@@ -527,69 +529,106 @@ describe("paseo worktree manager", () => {
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false); expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
}); });
it("runs destroy commands from paseo.json before deleting a worktree", async () => { it("runs teardown commands from paseo.json before deleting a worktree", async () => {
const paseoConfig = { const paseoConfig = {
worktree: { worktree: {
destroy: [ teardown: [
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', 'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', 'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"', 'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.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)); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", { execSync(
cwd: repoDir, "git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
}); {
cwd: repoDir,
},
);
const created = await createWorktree({ const created = await createWorktree({
branchName: "destroy-branch", branchName: "teardown-branch",
cwd: repoDir, cwd: repoDir,
baseBranch: "main", baseBranch: "main",
worktreeSlug: "destroy-test", worktreeSlug: "teardown-test",
paseoHome, paseoHome,
}); });
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: created.worktreePath,
branchName: created.branchName,
});
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
expect(existsSync(created.worktreePath)).toBe(false); expect(existsSync(created.worktreePath)).toBe(false);
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8"); const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
expect(destroyLog).toContain(`source=${repoDir}`); expect(teardownLog).toContain(`source=${repoDir}`);
expect(destroyLog).toContain(`root_alias=${repoDir}`); expect(teardownLog).toContain(`root_alias=${repoDir}`);
expect(destroyLog).toContain(`worktree=${created.worktreePath}`); expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
expect(destroyLog).toContain("branch=destroy-branch"); 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 = { const paseoConfig = {
worktree: { worktree: {
destroy: [ teardown: [
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"', '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", "echo boom 1>&2; exit 9",
], ],
}, },
}; };
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync( 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 }, { cwd: repoDir },
); );
const created = await createWorktree({ const created = await createWorktree({
branchName: "destroy-failure-branch", branchName: "teardown-failure-branch",
cwd: repoDir, cwd: repoDir,
baseBranch: "main", baseBranch: "main",
worktreeSlug: "destroy-failure-test", worktreeSlug: "teardown-failure-test",
paseoHome, paseoHome,
}); });
await expect( await expect(
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }), 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(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 { interface PaseoConfig {
worktree?: { worktree?: {
setup?: string[]; setup?: string[];
destroy?: string[]; teardown?: string[];
terminals?: WorktreeTerminalConfig[]; terminals?: WorktreeTerminalConfig[];
}; };
} }
@@ -94,14 +94,14 @@ export class WorktreeSetupError extends Error {
} }
} }
export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult; export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
export class WorktreeDestroyError extends Error { export class WorktreeTeardownError extends Error {
readonly results: WorktreeDestroyCommandResult[]; readonly results: WorktreeTeardownCommandResult[];
constructor(message: string, results: WorktreeDestroyCommandResult[]) { constructor(message: string, results: WorktreeTeardownCommandResult[]) {
super(message); super(message);
this.name = "WorktreeDestroyError"; this.name = "WorktreeTeardownError";
this.results = results; this.results = results;
} }
} }
@@ -150,13 +150,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
} }
export function getWorktreeDestroyCommands(repoRoot: string): string[] { export function getWorktreeTeardownCommands(repoRoot: string): string[] {
const config = readPaseoConfig(repoRoot); const config = readPaseoConfig(repoRoot);
const destroyCommands = config?.worktree?.destroy; const teardownCommands = config?.worktree?.teardown;
if (!destroyCommands || destroyCommands.length === 0) { if (!teardownCommands || teardownCommands.length === 0) {
return []; 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[] { 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; worktreePath: string;
branchName?: string; branchName?: string;
repoRootPath?: string; repoRootPath?: string;
}): Promise<WorktreeDestroyCommandResult[]> { }): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo) // Read paseo.json from the worktree (it will have the same content as the source repo)
const destroyCommands = getWorktreeDestroyCommands(options.worktreePath); const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
if (destroyCommands.length === 0) { if (teardownCommands.length === 0) {
return []; return [];
} }
@@ -521,30 +521,32 @@ export async function runWorktreeDestroyCommands(options: {
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath)); options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName = const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath)); options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const destroyEnv = { const teardownEnv: NodeJS.ProcessEnv = {
...process.env, ...process.env,
// Source checkout path is the original git repo root (shared across worktrees), not the // 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 // worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// source checkout. // from the source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath, PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias. // Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath, PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath, PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName, PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
}; };
const results: WorktreeDestroyCommandResult[] = []; const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of destroyCommands) { for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, { const result = await execSetupCommand(cmd, {
cwd: options.worktreePath, cwd: options.worktreePath,
env: destroyEnv, env: teardownEnv,
}); });
results.push(result); results.push(result);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new WorktreeDestroyError( throw new WorktreeTeardownError(
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(), `Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
results, results,
); );
} }
@@ -884,7 +886,7 @@ export async function deletePaseoWorktree({
throw new Error("Refusing to delete non-Paseo worktree"); throw new Error("Refusing to delete non-Paseo worktree");
} }
await runWorktreeDestroyCommands({ await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree, worktreePath: resolvedWorktree,
}); });
+51 -4
View File
@@ -96,7 +96,7 @@ function Worktrees() {
{/* paseo.json */} {/* paseo.json */}
<section className="space-y-4"> <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"> <p className="text-white/60 leading-relaxed">
When Paseo creates a worktree, it's a fresh checkout. Dependencies aren't installed, 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{" "} 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 the worktree is created. Use it to install dependencies, copy local config files, or run
any other initialization. any other initialization.
</p> </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"> <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{" "} <strong>Important:</strong> Setup commands come from{" "}
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{" "} <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"> <section className="space-y-4">
<h2 className="text-xl font-medium">Environment variables</h2> <h2 className="text-xl font-medium">Environment variables</h2>
<p className="text-white/60 leading-relaxed"> <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> </p>
<ul className="text-white/60 space-y-2 list-disc list-inside"> <ul className="text-white/60 space-y-2 list-disc list-inside">
<li> <li>
@@ -148,8 +162,8 @@ function Worktrees() {
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created <code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
</li> </li>
<li> <li>
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for <code className="font-mono">$PASEO_WORKTREE_PORT</code> — the worktree port, when
setup scripts runtime metadata exists
</li> </li>
</ul> </ul>
<p className="text-white/60 leading-relaxed"> <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 shouldn't be in git (like <code className="font-mono">.env</code>) from your source
checkout to the worktree. checkout to the worktree.
</p> </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> </section>
{/* Common patterns */} {/* Common patterns */}