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", () => {
|
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);
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user