feat: support creating worktrees from GitHub pull requests

This commit is contained in:
Mohamed Boudra
2026-04-18 12:08:30 +07:00
parent 82f97e942d
commit 14cc1cc83c
48 changed files with 6448 additions and 1515 deletions
+160 -33
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
createWorktree,
BranchAlreadyCheckedOutError,
createWorktree as createWorktreePrimitive,
deriveWorktreeProjectHash,
deletePaseoWorktree,
getScriptConfigs,
@@ -12,6 +13,8 @@ import {
type WorktreeSetupCommandProgressEvent,
runWorktreeSetupCommands,
slugify,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import { execSync } from "child_process";
@@ -28,6 +31,35 @@ import { dirname, join } from "path";
import { tmpdir } from "os";
import net from "node:net";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
cwd: string;
baseBranch: string;
worktreeSlug: string;
runSetup?: boolean;
paseoHome?: string;
}
function createLegacyWorktreeForTest(
options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions,
): Promise<WorktreeConfig> {
if ("source" in options) {
return createWorktreePrimitive(options);
}
return createWorktreePrimitive({
cwd: options.cwd,
worktreeSlug: options.worktreeSlug,
source: {
kind: "branch-off",
baseBranch: options.baseBranch,
newBranchName: options.branchName,
},
runSetup: options.runSetup ?? true,
paseoHome: options.paseoHome,
});
}
describe.skipIf(process.platform === "win32")("createWorktree", () => {
let tempDir: string;
let repoDir: string;
@@ -55,7 +87,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("creates a worktree for the current branch (main)", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -86,7 +118,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git add .", { cwd: varRepoDir });
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: varRepoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: varRepoDir,
baseBranch: "main",
@@ -113,7 +145,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
});
it("reports repoRoot as the repository root for paseo-owned worktrees", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -138,25 +170,120 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("creates a worktree with a new branch", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({
branchName: "feature-branch",
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "my-feature",
source: { kind: "branch-off", baseBranch: "main", newBranchName: "feature/x" },
runSetup: true,
paseoHome,
});
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "my-feature"));
expect(existsSync(result.worktreePath)).toBe(true);
// Verify branch was created
const branches = execSync("git branch", { cwd: repoDir }).toString();
expect(branches).toContain("feature-branch");
const currentBranch = execSync("git branch --show-current", {
cwd: result.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("feature/x");
execSync("git merge-base --is-ancestor main HEAD", { cwd: result.worktreePath });
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
it("checks out an existing local branch that is not checked out elsewhere", async () => {
execSync("git branch dev", { cwd: repoDir });
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "dev" },
runSetup: true,
paseoHome,
});
expect(existsSync(result.worktreePath)).toBe(true);
const currentBranch = execSync("git branch --show-current", {
cwd: result.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("dev");
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
});
it("throws a typed error when checking out a branch already checked out in the main repo", async () => {
let caughtError: unknown;
try {
await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "dev-worktree",
source: { kind: "checkout-branch", branchName: "main" },
runSetup: true,
paseoHome,
});
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError);
expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main");
});
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
execSync(`git clone --bare ${repoDir} ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir });
execSync('git config user.name "Test"', { cwd: remoteCloneDir });
execSync("git checkout -b contributor/feature", { cwd: remoteCloneDir });
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
writeFileSync(
join(remoteCloneDir, "paseo.json"),
JSON.stringify({ worktree: { setup: ['echo "setup ran" > setup.log'] } }),
);
execSync("git add .", { cwd: remoteCloneDir });
execSync('git -c commit.gpgsign=false commit -m "pr branch"', { cwd: remoteCloneDir });
const prHead = execSync("git rev-parse HEAD", { cwd: remoteCloneDir }).toString().trim();
execSync("git push origin contributor/feature", { cwd: remoteCloneDir });
execSync(`git --git-dir=${remoteDir} update-ref refs/pull/42/head ${prHead}`);
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "pr-42",
source: {
kind: "checkout-github-pr",
githubPrNumber: 42,
headRef: "user/feature",
baseRefName: "main",
},
runSetup: true,
paseoHome,
});
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe("setup ran\n");
const currentBranch = execSync("git branch --show-current", {
cwd: result.worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("user/feature");
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ baseRefName: "main" });
});
it("prefers origin/{branch} over local {branch} when both exist", async () => {
const remoteDir = join(tempDir, "remote.git");
const remoteCloneDir = join(tempDir, "remote-clone");
@@ -181,7 +308,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git fetch origin", { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "prefer-origin-feature",
cwd: repoDir,
baseBranch: "main",
@@ -198,7 +325,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git add file.txt", { cwd: repoDir });
execSync('git -c commit.gpgsign=false commit -m "advance local main only"', { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "prefer-local-fallback-feature",
cwd: repoDir,
baseBranch: "main",
@@ -212,7 +339,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("throws when neither origin/{branch} nor local {branch} exists", async () => {
await expect(
createWorktree({
createLegacyWorktreeForTest({
branchName: "missing-base-feature",
cwd: repoDir,
baseBranch: "does-not-exist",
@@ -225,7 +352,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
it("fails with invalid branch name", async () => {
await expect(
createWorktree({
createLegacyWorktreeForTest({
branchName: "INVALID_UPPERCASE",
cwd: repoDir,
baseBranch: "main",
@@ -239,7 +366,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
// Create a branch named "hello" first
execSync("git branch hello", { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -260,7 +387,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
execSync("git branch hello", { cwd: repoDir });
execSync("git branch hello-1", { cwd: repoDir });
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -292,7 +419,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
cwd: repoDir,
});
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -326,7 +453,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
cwd: repoDir,
});
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -367,7 +494,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
});
it("reuses persisted worktree runtime port across resolutions", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -389,7 +516,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
});
it("fails runtime env resolution when persisted port is in use", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -443,7 +570,7 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test");
await expect(
createWorktree({
createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -616,14 +743,14 @@ describe("paseo worktree manager", () => {
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repo });
}
const fromRepoA = await createWorktree({
const fromRepoA = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoA,
baseBranch: "main",
worktreeSlug: "alpha",
paseoHome,
});
const fromRepoB = await createWorktree({
const fromRepoB = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoB,
baseBranch: "main",
@@ -643,14 +770,14 @@ describe("paseo worktree manager", () => {
});
it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{hash}", async () => {
const first = await createWorktree({
const first = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "alpha",
paseoHome,
});
const second = await createWorktree({
const second = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -670,7 +797,7 @@ describe("paseo worktree manager", () => {
});
it("deletes a paseo worktree even when given a subdirectory path", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -708,7 +835,7 @@ describe("paseo worktree manager", () => {
},
);
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "teardown-branch",
cwd: repoDir,
baseBranch: "main",
@@ -745,7 +872,7 @@ describe("paseo worktree manager", () => {
{ cwd: repoDir },
);
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "teardown-port-missing-branch",
cwd: repoDir,
baseBranch: "main",
@@ -774,7 +901,7 @@ describe("paseo worktree manager", () => {
{ cwd: repoDir },
);
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "teardown-failure-branch",
cwd: repoDir,
baseBranch: "main",
@@ -791,7 +918,7 @@ describe("paseo worktree manager", () => {
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "orphan-admin-branch",
cwd: repoDir,
baseBranch: "main",
@@ -835,7 +962,7 @@ describe("paseo worktree manager", () => {
});
it("deletes a worktree whose .git admin dir has already been removed", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "orphan-delete-branch",
cwd: repoDir,
baseBranch: "main",
@@ -859,7 +986,7 @@ describe("paseo worktree manager", () => {
});
it("is idempotent: deleting an already-absent worktree succeeds", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "idempotent-delete-branch",
cwd: repoDir,
baseBranch: "main",
@@ -881,7 +1008,7 @@ describe("paseo worktree manager", () => {
});
it("deletes a worktree when the parent repo root is not available", async () => {
const created = await createWorktree({
const created = await createLegacyWorktreeForTest({
branchName: "no-cwd-branch",
cwd: repoDir,
baseBranch: "main",