fix worktree archive terminal cleanup and worktree setup
This commit is contained in:
@@ -665,5 +665,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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4484,11 +4484,13 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await deletePaseoWorktree({
|
await this.killTerminalsUnderPath(targetPath);
|
||||||
cwd: repoRoot,
|
|
||||||
worktreePath: targetPath,
|
await deletePaseoWorktree({
|
||||||
paseoHome: this.paseoHome,
|
cwd: repoRoot,
|
||||||
});
|
worktreePath: targetPath,
|
||||||
|
paseoHome: this.paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
for (const agentId of removedAgents) {
|
for (const agentId of removedAgents) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -6428,6 +6430,56 @@ export class Session {
|
|||||||
session.send(msg.message);
|
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> {
|
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
|
||||||
if (!this.terminalManager) {
|
if (!this.terminalManager) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -6441,19 +6493,7 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unsubscribe first
|
this.killTrackedTerminal(msg.terminalId, { emitExit: true });
|
||||||
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.emit({
|
this.emit({
|
||||||
type: "kill_terminal_response",
|
type: "kill_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ describe("createWorktree", () => {
|
|||||||
rmSync(varTempDir, { recursive: true, force: true });
|
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 () => {
|
it("creates a worktree with a new branch", async () => {
|
||||||
const result = await createWorktree({
|
const result = await createWorktree({
|
||||||
branchName: "feature-branch",
|
branchName: "feature-branch",
|
||||||
|
|||||||
@@ -667,11 +667,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(
|
export async function isPaseoOwnedWorktreeCwd(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
options?: { paseoHome?: string }
|
options?: { paseoHome?: string }
|
||||||
): Promise<PaseoWorktreeOwnership> {
|
): Promise<PaseoWorktreeOwnership> {
|
||||||
const gitCommonDir = await getGitCommonDir(cwd);
|
const gitCommonDir = await getGitCommonDir(cwd);
|
||||||
|
const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
|
||||||
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
|
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
|
||||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||||
const resolvedCwd = normalizePathForOwnership(cwd);
|
const resolvedCwd = normalizePathForOwnership(cwd);
|
||||||
@@ -679,7 +687,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
|||||||
if (!resolvedCwd.startsWith(resolvedRoot)) {
|
if (!resolvedCwd.startsWith(resolvedRoot)) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
repoRoot: gitCommonDir,
|
repoRoot,
|
||||||
worktreeRoot: worktreesRoot,
|
worktreeRoot: worktreesRoot,
|
||||||
worktreePath: resolvedCwd,
|
worktreePath: resolvedCwd,
|
||||||
};
|
};
|
||||||
@@ -692,7 +700,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
allowed,
|
allowed,
|
||||||
repoRoot: gitCommonDir,
|
repoRoot,
|
||||||
worktreeRoot: worktreesRoot,
|
worktreeRoot: worktreesRoot,
|
||||||
worktreePath: resolvedCwd,
|
worktreePath: resolvedCwd,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-2
@@ -2,12 +2,13 @@
|
|||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"npm ci",
|
||||||
|
"npm run build --workspace=@getpaseo/relay",
|
||||||
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
||||||
],
|
],
|
||||||
"terminals": [
|
"terminals": [
|
||||||
{
|
{
|
||||||
"name": "anti-andran dev",
|
"name": "dev",
|
||||||
"command": "npm run 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