Files
paseo/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts
T
paseo-bot[bot]and见安 9b92b2e25c Preserve base worktree paths when merging (#6668)
* fix(git): preserve the base worktree path when merging

* test(git): cover merging into whitespace worktree paths

* test(git): follow the server object parameter convention

* test(git): keep merge fixture line endings deterministic

Disable automatic CRLF checkout in the fixture repository so the exact-content assertion works with Windows Git defaults.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 06:00:59 +02:00

872 lines
29 KiB
TypeScript

import { test, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
readFileSync,
realpathSync,
mkdirSync,
} from "fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
import { isPlatform } from "../../test-utils/platform.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
function findTimelineToolCall(
messages: SessionOutboundMessage[],
agentId: string,
predicate: (item: AgentTimelineItem) => boolean,
): AgentTimelineItem | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
if (msg?.type !== "agent_stream") {
continue;
}
if (msg.payload.agentId !== agentId) {
continue;
}
const event = msg.payload.event as { type?: string; item?: AgentTimelineItem };
if (event?.type !== "timeline") {
continue;
}
const item = event.item as AgentTimelineItem;
if (item?.type === "tool_call" && predicate(item)) {
return item;
}
}
return null;
}
async function waitForTimelineToolCall(
messages: SessionOutboundMessage[],
agentId: string,
predicate: (item: AgentTimelineItem) => boolean,
timeoutMs = 10000,
): Promise<Extract<AgentTimelineItem, { type: "tool_call" }>> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const existing = findTimelineToolCall(messages, agentId, predicate);
if (existing && existing.type === "tool_call") {
return existing;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const recentToolCalls: Array<{ name: string; status?: string; callId?: string }> = [];
for (let i = messages.length - 1; i >= 0 && recentToolCalls.length < 10; i -= 1) {
const msg = messages[i];
if (msg?.type !== "agent_stream") continue;
if (msg.payload.agentId !== agentId) continue;
const event = msg.payload.event as { type?: string; item?: AgentTimelineItem };
if (event?.type !== "timeline") continue;
const item = event.item as AgentTimelineItem;
if (item?.type !== "tool_call") continue;
recentToolCalls.push({ name: item.name, status: item.status, callId: item.callId });
}
throw new Error(
`Timed out waiting for timeline tool_call (${agentId}). Recent tool_calls: ${JSON.stringify(
recentToolCalls,
)}`,
);
}
async function waitForPathExists(options: {
targetPath: string;
timeoutMs: number;
label: string;
}): Promise<void> {
await waitForCondition({
timeoutMs: options.timeoutMs,
label: `${options.label}: ${options.targetPath}`,
predicate: () => existsSync(options.targetPath),
});
}
async function waitForCondition(options: {
timeoutMs: number;
label: string;
predicate: () => boolean | Promise<boolean>;
}): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (await options.predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`);
}
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
const originalShell = process.env.SHELL;
process.env.SHELL = shell;
try {
return await run();
} finally {
if (originalShell === undefined) {
delete process.env.SHELL;
} else {
process.env.SHELL = originalShell;
}
}
}
interface WorktreeTerminalBootstrapEntry {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
function getWorktreeTerminalBootstrapEntries(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
): WorktreeTerminalBootstrapEntry[] | null {
const detail = item.detail;
if (!detail || detail.type !== "unknown" || !detail.output) {
return null;
}
const output = detail.output as Record<string, unknown>;
const terminals = output.terminals;
if (!Array.isArray(terminals)) {
return null;
}
return terminals as WorktreeTerminalBootstrapEntry[];
}
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
let ctx: DaemonTestContext;
let collector: MessageCollector;
beforeEach(async () => {
ctx = await createDaemonTestContext();
collector = createMessageCollector(ctx.client);
});
afterEach(async () => {
collector.unsubscribe();
await ctx.cleanup();
}, 60000);
interface GitCommandOptions {
cwd: string;
args: string[];
}
async function mergeFeatureIntoBaseWorktree(baseName: string): Promise<void> {
const directory = realpathSync(tmpCwd());
const base = path.join(directory, baseName);
const feature = path.join(directory, "feature");
function git({ cwd, args }: GitCommandOptions): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
mkdirSync(base);
git({ cwd: base, args: ["init", "-b", "main"] });
git({ cwd: base, args: ["config", "user.name", "Paseo Test"] });
git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] });
git({ cwd: base, args: ["config", "commit.gpgsign", "false"] });
git({ cwd: base, args: ["config", "core.autocrlf", "false"] });
git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] });
git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] });
writeFileSync(path.join(feature, "feature.txt"), "feature payload\n");
git({ cwd: feature, args: ["add", "feature.txt"] });
git({ cwd: feature, args: ["commit", "-m", "feature"] });
const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim();
const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" });
expect(merged).toMatchObject({ success: true, error: null });
expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit);
expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n");
expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature");
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])(
"merges into a base worktree whose path preserves %j",
mergeFeatureIntoBaseWorktree,
);
test.each(["base checkout", "中文 checkout"])(
"merges into a base worktree named %j",
mergeFeatureIntoBaseWorktree,
);
test("merges into a local base branch without another worktree", async () => {
const cwd = realpathSync(tmpCwd());
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
git(["init", "-b", "main"]);
git(["config", "user.name", "Paseo Test"]);
git(["config", "user.email", "test@paseo.local"]);
git(["config", "commit.gpgsign", "false"]);
git(["commit", "--allow-empty", "-m", "base"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n");
git(["add", "feature.txt"]);
git(["commit", "-m", "feature"]);
const featureCommit = git(["rev-parse", "HEAD"]).trim();
const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" });
expect(merged).toMatchObject({ success: true, error: null });
expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit);
expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("returns diff for modified file in git repo", async () => {
const cwd = tmpCwd();
// Initialize git repo
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (creates unstaged changes)
writeFileSync(testFile, "modified content\n");
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.error).toBeNull();
expect(result.files.length).toBeGreaterThan(0);
const file = result.files.find((entry) => entry.path === "test.txt");
expect(file).toBeTruthy();
expect(file?.hunks.length).toBeGreaterThan(0);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("returns empty diff when no changes", async () => {
const cwd = tmpCwd();
// Initialize git repo with clean state
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.error).toBeNull();
expect(result.files).toEqual([]);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("returns error for non-git directory", async () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(result.files).toEqual([]);
expect(result.error).toBeTruthy();
expect(result.error?.code).toBe("NOT_GIT_REPO");
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
// POSIX-only: asserts repo-root containment across macOS /var symlink normalization.
test.skipIf(isPlatform("win32"))(
"returns repo info for git repo with branch and dirty state",
async () => {
const cwd = tmpCwd();
// Initialize git repo
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "original content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Modify the file (makes repo dirty)
writeFileSync(testFile, "modified content\n");
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Test",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get checkout status
const result = await ctx.client.getCheckoutStatus(cwd);
// Verify repo info returned without error
expect(result.error).toBeNull();
expect(result.isGit).toBe(true);
// macOS symlinks /var to /private/var, so we check containment
expect(result.repoRoot).toContain("daemon-e2e-");
expect(result.currentBranch).toBeTruthy();
expect(result.isDirty).toBe(true);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
60000,
); // 1 minute timeout
test("returns clean state when no uncommitted changes", async () => {
const cwd = tmpCwd();
// Initialize git repo with clean state
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
// Create and commit a file (no uncommitted changes)
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Clean Test",
});
expect(agent.id).toBeTruthy();
// Get checkout status
const result = await ctx.client.getCheckoutStatus(cwd);
expect(result.error).toBeNull();
expect(result.isGit).toBe(true);
expect(result.isDirty).toBe(false);
expect(result.currentBranch).toBeTruthy();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("returns isGit false for non-git directory", async () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Non-Git Test",
});
expect(agent.id).toBeTruthy();
// Get checkout status - should return isGit: false
const result = await ctx.client.getCheckoutStatus(cwd);
expect(result.isGit).toBe(false);
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
test("runs paseo.json setup asynchronously and reports status via timeline tool_call", 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 setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: [setupCommand] } }),
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add paseo.json'", {
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: "Async Worktree Setup Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-test",
worktreeSlug: "async-setup-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
const completed = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000,
);
expect(completed.callId).toBeTruthy();
expect(completed.detail.type).toBe("worktree_setup");
if (completed.detail.type === "worktree_setup") {
expect(completed.detail.commands.length).toBeGreaterThan(0);
expect(completed.detail.log.length).toBeGreaterThan(0);
}
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
}, 60000);
test("bootstraps configured worktree terminals after setup succeeds", async () => {
await withShell("/bin/sh", 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 setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: "tail -f /dev/null",
},
{
command: "tail -f /dev/null",
},
],
},
}),
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
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: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000,
);
const terminalsBootstrapToolCall = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_terminals" && item.status === "completed",
30000,
);
const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(terminalsBootstrapToolCall);
expect(bootstrappedTerminals).toBeTruthy();
expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2);
const failedBootstraps =
bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? [];
expect(failedBootstraps).toEqual([]);
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
await waitForPathExists({
targetPath: path.join(agent.cwd, "setup-port.txt"),
timeoutMs: 30000,
label: "setup runtime port marker",
});
const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
const openedWorktree = await ctx.client.openProject(agent.cwd);
if (!openedWorktree.workspace) {
throw new Error(openedWorktree.error ?? `Failed to open worktree workspace for ${agent.cwd}`);
}
const createdTerminal = await ctx.client.createTerminal(
agent.cwd,
"Manual Port Check",
undefined,
{
workspaceId: openedWorktree.workspace.id,
},
);
expect(createdTerminal.error).toBeNull();
expect(createdTerminal.terminal).toBeTruthy();
const manualTerminalId = createdTerminal.terminal?.id;
expect(manualTerminalId).toBeTruthy();
if (!manualTerminalId) {
throw new Error("Expected manual terminal id");
}
ctx.client.sendTerminalInput(manualTerminalId, {
type: "input",
data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r',
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "manual-terminal-port.txt"),
timeoutMs: 30000,
label: "manual terminal runtime port marker",
});
const manualTerminalPort = readFileSync(
path.join(agent.cwd, "manual-terminal-port.txt"),
"utf8",
).trim();
expect(manualTerminalPort).toBe(setupPort);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
});
}, 60000);
test("reports failures via timeline tool_call without deleting the created worktree", 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 setupCommand =
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Should Not Start",
command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null',
},
],
},
}),
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
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: "Async Worktree Setup Failure Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-failure-test",
worktreeSlug: "async-setup-failure-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on failing setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(agent.cwd)).toBe(true);
const started = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "running",
10000,
);
const failed = await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) =>
item.name === "paseo_worktree_setup" &&
item.callId === started.callId &&
item.status === "failed",
20000,
);
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
expect(failed.detail.type).toBe("worktree_setup");
if (failed.detail.type === "worktree_setup") {
expect(Array.isArray(failed.detail.commands)).toBe(true);
expect(failed.detail.commands[0]?.exitCode).toBe(7);
expect(failed.detail.log).toContain("Exit 7");
}
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
}, 60000);
test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", async () => {
const cwd = tmpCwd();
const projectHash = await deriveWorktreeProjectHash(cwd);
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd, stdio: "pipe" });
const testFile = path.join(cwd, "test.txt");
writeFileSync(testFile, "content\n");
execSync("git add test.txt", { cwd, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
cwd,
stdio: "pipe",
});
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Worktree Agent Test",
git: {
createWorktree: true,
createNewBranch: true,
newBranchName: "worktree-test",
worktreeSlug: "worktree-test",
baseBranch: "main",
},
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(realpathSync(agent.cwd)).toBe(
realpathSync(path.join(ctx.daemon.paseoHome, "worktrees", projectHash, "worktree-test")),
);
expect(existsSync(agent.cwd)).toBe(true);
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}, 60000);
test("archiving a worktree shuts down its terminals but leaves the worktree on disk", 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 teardownMarkerPath = path.join(repoRoot, "teardown-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',
},
],
teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`],
},
}),
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", {
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 waitForCondition({
timeoutMs: 30000,
label: `worktree terminal bootstrap for ${agent.cwd}`,
predicate: async () => {
const directories = ctx.daemon.daemon.terminalManager.listDirectories();
if (!directories.includes(agent.cwd)) {
return false;
}
const terminals = await ctx.client.listTerminals(agent.cwd);
return terminals.terminals.some((terminal) => terminal.name === "Dev Server");
},
});
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);
// Archiving tears down the workspace's terminals but never deletes the
// worktree from disk and never runs teardown commands — on-disk removal is a
// separate, explicit step.
expect(existsSync(agent.cwd)).toBe(true);
expect(existsSync(teardownMarkerPath)).toBe(false);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd);
rmSync(repoRoot, { recursive: true, force: true });
}, 60000);