From 88490a9af103a4a6a6e16a98f0bbe2d526c2edb0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Jan 2026 10:43:18 +0700 Subject: [PATCH] Add .paseo worktree manager and e2e coverage --- .../daemon-e2e/git-operations.e2e.test.ts | 49 +++++- packages/server/src/utils/worktree.test.ts | 82 +++++++++- packages/server/src/utils/worktree.ts | 153 ++++++++++++++++-- 3 files changed, 267 insertions(+), 17 deletions(-) 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 6d9e3e4d2..d8f5db12e 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 @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync } from "fs"; +import { mkdtempSync, writeFileSync, existsSync, rmSync, mkdirSync, readFileSync, readdirSync, realpathSync } from "fs"; import { tmpdir } from "os"; import path from "path"; import { @@ -277,5 +277,52 @@ describe("daemon E2E", () => { ); }); + describe("createAgent with worktree", () => { + test( + "creates agent in .paseo/worktrees when worktree is requested", + async () => { + const cwd = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init", { 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, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd, + title: "Worktree Agent Test", + git: { + createWorktree: true, + createNewBranch: true, + newBranchName: "worktree-test", + worktreeSlug: "worktree-test", + }, + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + expect(realpathSync(agent.cwd)).toBe( + realpathSync(path.join(cwd, ".paseo", "worktrees", "worktree-test")) + ); + expect(existsSync(agent.cwd)).toBe(true); + + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, + 60000 + ); + }); + }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index aa91c647c..770eb2500 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createWorktree, slugify } from "./worktree"; +import { + createWorktree, + deletePaseoWorktree, + ensurePaseoIgnored, + listPaseoWorktrees, + slugify, +} from "./worktree"; import { execSync } from "child_process"; import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; import { join } from "path"; @@ -35,7 +41,9 @@ describe("createWorktree", () => { worktreeSlug: "hello-world", }); - expect(result.worktreePath).toBe(join(tempDir, "test-repo-hello-world")); + expect(result.worktreePath).toBe( + join(repoDir, ".paseo", "worktrees", "hello-world") + ); expect(existsSync(result.worktreePath)).toBe(true); expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true); }); @@ -48,7 +56,9 @@ describe("createWorktree", () => { worktreeSlug: "my-feature", }); - expect(result.worktreePath).toBe(join(tempDir, "test-repo-my-feature")); + expect(result.worktreePath).toBe( + join(repoDir, ".paseo", "worktrees", "my-feature") + ); expect(existsSync(result.worktreePath)).toBe(true); // Verify branch was created @@ -77,7 +87,9 @@ describe("createWorktree", () => { }); // Should create branch "hello-1" since "hello" exists - expect(result.worktreePath).toBe(join(tempDir, "test-repo-hello")); + expect(result.worktreePath).toBe( + join(repoDir, ".paseo", "worktrees", "hello") + ); expect(existsSync(result.worktreePath)).toBe(true); const branches = execSync("git branch", { cwd: repoDir }).toString(); @@ -140,7 +152,12 @@ describe("createWorktree", () => { writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir }); - const expectedWorktreePath = join(tempDir, "test-repo-fail-test"); + const expectedWorktreePath = join( + repoDir, + ".paseo", + "worktrees", + "fail-test" + ); await expect( createWorktree({ @@ -155,6 +172,61 @@ describe("createWorktree", () => { }); }); +describe("paseo worktree manager", () => { + let tempDir: string; + let repoDir: string; + + beforeEach(() => { + tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-manager-test-"))); + repoDir = join(tempDir, "test-repo"); + + execSync(`mkdir -p ${repoDir}`); + execSync("git init", { cwd: repoDir }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir }); + execSync("git config user.name 'Test'", { cwd: repoDir }); + execSync("echo 'hello' > file.txt", { cwd: repoDir }); + execSync("git add .", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("lists and deletes paseo worktrees under .paseo/worktrees", async () => { + const first = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "alpha", + }); + const second = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "beta", + }); + + const worktrees = await listPaseoWorktrees({ cwd: repoDir }); + const paths = worktrees.map((worktree) => worktree.path).sort(); + expect(paths).toEqual([first.worktreePath, second.worktreePath].sort()); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath }); + expect(existsSync(first.worktreePath)).toBe(false); + + const remaining = await listPaseoWorktrees({ cwd: repoDir }); + expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]); + }); + + it("ensures .paseo is ignored in .gitignore", async () => { + await ensurePaseoIgnored(repoDir); + await ensurePaseoIgnored(repoDir); + + const gitignorePath = join(repoDir, ".gitignore"); + const gitignore = readFileSync(gitignorePath, "utf8"); + const matches = gitignore.match(/^\.paseo\/?$/gm) ?? []; + expect(matches.length).toBe(1); + }); +}); + describe("slugify", () => { it("converts to lowercase kebab-case", () => { expect(slugify("Hello World")).toBe("hello-world"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 91eeb34cb..1ffa5d377 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,7 +1,7 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { existsSync, readFileSync, rmSync } from "fs"; -import { join, basename, dirname } from "path"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { join, basename, dirname, resolve, sep } from "path"; import { createNameId } from "mnemonic-id"; interface PaseoConfig { @@ -29,6 +29,12 @@ interface WorktreeConfig { repoPath: string; } +export interface PaseoWorktreeInfo { + path: string; + branchName?: string; + head?: string; +} + interface CreateWorktreeOptions { branchName: string; cwd: string; @@ -185,6 +191,133 @@ function generateWorktreeSlug(): string { return createNameId(); } +function getPaseoWorktreesRoot(repoRoot: string): string { + return join(repoRoot, ".paseo", "worktrees"); +} + +function ensurePaseoIgnoredForRepo(repoInfo: RepoInfo): { + updated: boolean; + skipped: boolean; + path?: string; +} { + if (repoInfo.type === "bare") { + return { updated: false, skipped: true }; + } + + const gitignorePath = join(repoInfo.path, ".gitignore"); + const existing = existsSync(gitignorePath) + ? readFileSync(gitignorePath, "utf8") + : ""; + const hasEntry = /^\.paseo\/?$/m.test(existing); + + if (hasEntry) { + return { updated: false, skipped: false, path: gitignorePath }; + } + + const needsNewline = existing.length > 0 && !existing.endsWith("\n"); + const nextContents = `${existing}${needsNewline ? "\n" : ""}.paseo/\n`; + writeFileSync(gitignorePath, nextContents); + return { updated: true, skipped: false, path: gitignorePath }; +} + +export async function ensurePaseoIgnored(cwd: string): Promise<{ + updated: boolean; + skipped: boolean; + path?: string; +}> { + const repoInfo = await detectRepoInfo(cwd); + return ensurePaseoIgnoredForRepo(repoInfo); +} + +function parseWorktreeList(output: string): PaseoWorktreeInfo[] { + const entries: PaseoWorktreeInfo[] = []; + let current: PaseoWorktreeInfo | null = null; + + for (const line of output.split("\n")) { + if (line.startsWith("worktree ")) { + if (current?.path) { + entries.push(current); + } + current = { path: line.slice("worktree ".length).trim() }; + continue; + } + + if (!current) { + continue; + } + + if (line.startsWith("branch ")) { + const ref = line.slice("branch ".length).trim(); + current.branchName = ref.startsWith("refs/heads/") + ? ref.slice("refs/heads/".length) + : ref; + } else if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length).trim(); + } else if (line.trim().length === 0) { + if (current.path) { + entries.push(current); + } + current = null; + } + } + + if (current?.path) { + entries.push(current); + } + + return entries; +} + +export async function listPaseoWorktrees({ + cwd, +}: { + cwd: string; +}): Promise { + const repoInfo = await detectRepoInfo(cwd); + const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path); + const { stdout } = await execAsync("git worktree list --porcelain", { + cwd: repoInfo.path, + env: READ_ONLY_GIT_ENV, + }); + + const rootPrefix = resolve(worktreesRoot) + sep; + return parseWorktreeList(stdout).filter((entry) => + resolve(entry.path).startsWith(rootPrefix) + ); +} + +export async function deletePaseoWorktree({ + cwd, + worktreePath, + worktreeSlug, +}: { + cwd: string; + worktreePath?: string; + worktreeSlug?: string; +}): Promise { + if (!worktreePath && !worktreeSlug) { + throw new Error("worktreePath or worktreeSlug is required"); + } + + const repoInfo = await detectRepoInfo(cwd); + const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path); + const targetPath = worktreePath ?? join(worktreesRoot, worktreeSlug!); + const resolvedRoot = resolve(worktreesRoot) + sep; + const resolvedTarget = resolve(targetPath); + + if (!resolvedTarget.startsWith(resolvedRoot)) { + throw new Error("Refusing to delete non-Paseo worktree"); + } + + await execAsync(`git worktree remove "${targetPath}" --force`, { + cwd: repoInfo.path, + }); + + if (existsSync(targetPath)) { + rmSync(targetPath, { recursive: true, force: true }); + } +} + /** * Create a git worktree with proper naming conventions @@ -204,17 +337,15 @@ export async function createWorktree({ // Detect repository info const repoInfo = await detectRepoInfo(cwd); + // Ensure .paseo exists and is ignored + ensurePaseoIgnoredForRepo(repoInfo); + // Determine worktree directory based on repo type let worktreePath: string; const desiredSlug = worktreeSlug || generateWorktreeSlug(); - if (repoInfo.type === "bare") { - worktreePath = join(repoInfo.path, desiredSlug); - } else { - const parentDir = dirname(repoInfo.path); - const worktreeName = `${repoInfo.name}-${desiredSlug}`; - worktreePath = join(parentDir, worktreeName); - } + worktreePath = join(getPaseoWorktreesRoot(repoInfo.path), desiredSlug); + mkdirSync(dirname(worktreePath), { recursive: true }); // Check if branch already exists let branchExists = false; @@ -264,7 +395,7 @@ export async function createWorktree({ worktreePath = finalWorktreePath; // Run setup commands from paseo.json if present (look in source worktree, not bare repo) - const paseoConfigPath = join(cwd, "paseo.json"); + const paseoConfigPath = join(repoInfo.path, "paseo.json"); if (existsSync(paseoConfigPath)) { let config: PaseoConfig; try { @@ -277,7 +408,7 @@ export async function createWorktree({ if (setupCommands && setupCommands.length > 0) { const setupEnv = { ...process.env, - PASEO_ROOT_PATH: cwd, + PASEO_ROOT_PATH: repoInfo.path, PASEO_WORKTREE_PATH: worktreePath, PASEO_BRANCH_NAME: newBranchName, };