Merge pull request #32 from getpaseo/shaky-penguin
Fix worktree archive terminal cleanup and tighten worktree setup
This commit is contained in:
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -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<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})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
|
||||
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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PaseoWorktreeOwnership> {
|
||||
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,
|
||||
};
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user