diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 268d7290f..4253f0b79 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -739,5 +739,98 @@ describe("daemon E2E", () => { ); }); + describe("archivePaseoWorktree", () => { + test( + "archives worktree by running destroy commands and shutting down worktree terminals", + async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt"); + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + terminals: [ + { + name: "Dev Server", + command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', + }, + ], + destroy: [ + `echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`, + ], + }, + }) + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Worktree Archive Cleanup Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "archive-cleanup-test", + worktreeSlug: "archive-cleanup-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + await waitForPathExists({ + targetPath: path.join(agent.cwd, "dev-terminal.txt"), + timeoutMs: 15000, + label: "worktree terminal marker", + }); + + const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(beforeArchiveDirectories).toContain(agent.cwd); + + const archive = await ctx.client.archivePaseoWorktree({ + worktreePath: agent.cwd, + }); + expect(archive.error).toBeNull(); + expect(archive.success).toBe(true); + 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); + + const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(afterArchiveDirectories).not.toContain(agent.cwd); + + rmSync(repoRoot, { recursive: true, force: true }); + }, + 60000 + ); + }); + }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6234e71bc..295acd47d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4620,11 +4620,13 @@ export class Session { } } - await deletePaseoWorktree({ - cwd: repoRoot, - worktreePath: targetPath, - paseoHome: this.paseoHome, - }); + await this.killTerminalsUnderPath(targetPath); + + await deletePaseoWorktree({ + cwd: repoRoot, + worktreePath: targetPath, + paseoHome: this.paseoHome, + }); for (const agentId of removedAgents) { this.emit({ @@ -6588,6 +6590,56 @@ export class Session { session.send(msg.message); } + private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void { + const unsubscribe = this.terminalSubscriptions.get(terminalId); + if (unsubscribe) { + unsubscribe(); + this.terminalSubscriptions.delete(terminalId); + } + + const streamId = this.terminalStreamByTerminalId.get(terminalId); + if (typeof streamId === "number") { + this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true }); + } + + this.terminalManager?.killTerminal(terminalId); + } + + private async killTerminalsUnderPath(rootPath: string): Promise { + if (!this.terminalManager) { + return; + } + + const cleanupErrors: Array<{ cwd: string; message: string }> = []; + const terminalDirectories = [...this.terminalManager.listDirectories()]; + for (const terminalCwd of terminalDirectories) { + if (!this.isPathWithinRoot(rootPath, terminalCwd)) { + continue; + } + + try { + const terminals = await this.terminalManager.getTerminals(terminalCwd); + for (const terminal of [...terminals]) { + this.killTrackedTerminal(terminal.id, { emitExit: true }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + cleanupErrors.push({ cwd: terminalCwd, message }); + this.sessionLogger.warn( + { err: error, cwd: terminalCwd }, + "Failed to clean up worktree terminals during archive" + ); + } + } + + if (cleanupErrors.length > 0) { + const details = cleanupErrors + .map((entry) => `${entry.cwd}: ${entry.message}`) + .join("; "); + throw new Error(`Failed to clean up worktree terminals during archive (${details})`); + } + } + private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -6601,19 +6653,7 @@ export class Session { return; } - // Unsubscribe first - const unsubscribe = this.terminalSubscriptions.get(msg.terminalId); - if (unsubscribe) { - unsubscribe(); - this.terminalSubscriptions.delete(msg.terminalId); - } - - const streamId = this.terminalStreamByTerminalId.get(msg.terminalId); - if (typeof streamId === "number") { - this.detachTerminalStream(streamId, { emitExit: true }); - } - - this.terminalManager.killTerminal(msg.terminalId); + this.killTrackedTerminal(msg.terminalId, { emitExit: true }); this.emit({ type: "kill_terminal_response", payload: { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index d37b48ff8..3a17e483e 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -104,6 +104,20 @@ describe("createWorktree", () => { rmSync(varTempDir, { recursive: true, force: true }); }); + it("reports repoRoot as the repository root for paseo-owned worktrees", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "repo-root-check", + paseoHome, + }); + + const ownership = await isPaseoOwnedWorktreeCwd(result.worktreePath, { paseoHome }); + expect(ownership.allowed).toBe(true); + expect(ownership.repoRoot).toBe(repoDir); + }); + it("creates a worktree with a new branch", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const result = await createWorktree({ diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index a9e61f34e..ed195aeb5 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -681,11 +681,19 @@ function normalizePathForOwnership(input: string): string { } } +function resolveRepoRootFromGitCommonDir(commonDir: string): string { + const normalizedCommonDir = normalizePathForOwnership(commonDir); + return basename(normalizedCommonDir) === ".git" + ? dirname(normalizedCommonDir) + : normalizedCommonDir; +} + export async function isPaseoOwnedWorktreeCwd( cwd: string, options?: { paseoHome?: string } ): Promise { const gitCommonDir = await getGitCommonDir(cwd); + const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; const resolvedCwd = normalizePathForOwnership(cwd); @@ -693,7 +701,7 @@ export async function isPaseoOwnedWorktreeCwd( if (!resolvedCwd.startsWith(resolvedRoot)) { return { allowed: false, - repoRoot: gitCommonDir, + repoRoot, worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; @@ -706,7 +714,7 @@ export async function isPaseoOwnedWorktreeCwd( }); return { allowed, - repoRoot: gitCommonDir, + repoRoot, worktreeRoot: worktreesRoot, worktreePath: resolvedCwd, }; diff --git a/paseo.json b/paseo.json index 9d33a7dca..2e68019e0 100644 --- a/paseo.json +++ b/paseo.json @@ -2,12 +2,13 @@ "worktree": { "setup": [ "npm ci", + "npm run build --workspace=@getpaseo/relay", "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" ], "terminals": [ { - "name": "anti-andran dev", - "command": "npm run dev" + "name": "dev", + "command": "SESSION_NAME=\"${PWD##*/}\"; if ! tmux has-session -t \"$SESSION_NAME\" 2>/dev/null; then tmux new-session -d -s \"$SESSION_NAME\" -c \"$PWD\"; tmux send-keys -t \"$SESSION_NAME\":0.0 \"npm run dev\" Enter; fi" } ] }