Add .paseo worktree manager and e2e coverage
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
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 { tmpdir } from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
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 { execSync } from "child_process";
|
||||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
@@ -35,7 +41,9 @@ describe("createWorktree", () => {
|
|||||||
worktreeSlug: "hello-world",
|
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(result.worktreePath)).toBe(true);
|
||||||
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -48,7 +56,9 @@ describe("createWorktree", () => {
|
|||||||
worktreeSlug: "my-feature",
|
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);
|
expect(existsSync(result.worktreePath)).toBe(true);
|
||||||
|
|
||||||
// Verify branch was created
|
// Verify branch was created
|
||||||
@@ -77,7 +87,9 @@ describe("createWorktree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should create branch "hello-1" since "hello" exists
|
// 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);
|
expect(existsSync(result.worktreePath)).toBe(true);
|
||||||
|
|
||||||
const branches = execSync("git branch", { cwd: repoDir }).toString();
|
const branches = execSync("git branch", { cwd: repoDir }).toString();
|
||||||
@@ -140,7 +152,12 @@ describe("createWorktree", () => {
|
|||||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
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 });
|
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(
|
await expect(
|
||||||
createWorktree({
|
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", () => {
|
describe("slugify", () => {
|
||||||
it("converts to lowercase kebab-case", () => {
|
it("converts to lowercase kebab-case", () => {
|
||||||
expect(slugify("Hello World")).toBe("hello-world");
|
expect(slugify("Hello World")).toBe("hello-world");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { existsSync, readFileSync, rmSync } from "fs";
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||||
import { join, basename, dirname } from "path";
|
import { join, basename, dirname, resolve, sep } from "path";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
|
|
||||||
interface PaseoConfig {
|
interface PaseoConfig {
|
||||||
@@ -29,6 +29,12 @@ interface WorktreeConfig {
|
|||||||
repoPath: string;
|
repoPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaseoWorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branchName?: string;
|
||||||
|
head?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateWorktreeOptions {
|
interface CreateWorktreeOptions {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -185,6 +191,133 @@ function generateWorktreeSlug(): string {
|
|||||||
return createNameId();
|
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<PaseoWorktreeInfo[]> {
|
||||||
|
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<void> {
|
||||||
|
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
|
* Create a git worktree with proper naming conventions
|
||||||
@@ -204,17 +337,15 @@ export async function createWorktree({
|
|||||||
// Detect repository info
|
// Detect repository info
|
||||||
const repoInfo = await detectRepoInfo(cwd);
|
const repoInfo = await detectRepoInfo(cwd);
|
||||||
|
|
||||||
|
// Ensure .paseo exists and is ignored
|
||||||
|
ensurePaseoIgnoredForRepo(repoInfo);
|
||||||
|
|
||||||
// Determine worktree directory based on repo type
|
// Determine worktree directory based on repo type
|
||||||
let worktreePath: string;
|
let worktreePath: string;
|
||||||
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
||||||
|
|
||||||
if (repoInfo.type === "bare") {
|
worktreePath = join(getPaseoWorktreesRoot(repoInfo.path), desiredSlug);
|
||||||
worktreePath = join(repoInfo.path, desiredSlug);
|
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||||
} else {
|
|
||||||
const parentDir = dirname(repoInfo.path);
|
|
||||||
const worktreeName = `${repoInfo.name}-${desiredSlug}`;
|
|
||||||
worktreePath = join(parentDir, worktreeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
@@ -264,7 +395,7 @@ export async function createWorktree({
|
|||||||
worktreePath = finalWorktreePath;
|
worktreePath = finalWorktreePath;
|
||||||
|
|
||||||
// Run setup commands from paseo.json if present (look in source worktree, not bare repo)
|
// 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)) {
|
if (existsSync(paseoConfigPath)) {
|
||||||
let config: PaseoConfig;
|
let config: PaseoConfig;
|
||||||
try {
|
try {
|
||||||
@@ -277,7 +408,7 @@ export async function createWorktree({
|
|||||||
if (setupCommands && setupCommands.length > 0) {
|
if (setupCommands && setupCommands.length > 0) {
|
||||||
const setupEnv = {
|
const setupEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PASEO_ROOT_PATH: cwd,
|
PASEO_ROOT_PATH: repoInfo.path,
|
||||||
PASEO_WORKTREE_PATH: worktreePath,
|
PASEO_WORKTREE_PATH: worktreePath,
|
||||||
PASEO_BRANCH_NAME: newBranchName,
|
PASEO_BRANCH_NAME: newBranchName,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user