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