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
+286 -236
View File
@@ -1,22 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import {
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
realpathSync,
mkdirSync,
symlinkSync,
} from "fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, realpathSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
__resetGhPathCacheForTests,
__resetCheckoutShortstatCacheForTests,
__resetPullRequestStatusCacheForTests,
__setGhPathForTests,
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCachedCheckoutShortstat,
@@ -38,9 +27,48 @@ import {
parseStatusCheckRollup,
isPaseoWorktreePath,
isDescendantPath,
searchGitHubIssuesAndPrs,
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import { createWorktree } from "./worktree.js";
import {
GitHubCliMissingError,
type GitHubCurrentPullRequestStatus,
type GitHubService,
} from "../services/github-service.js";
import {
createWorktree as createWorktreePrimitive,
type CreateWorktreeOptions,
type WorktreeConfig,
} from "./worktree.js";
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,
});
}
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
function initRepo(): { tempDir: string; repoDir: string } {
@@ -60,6 +88,52 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createGitHubServiceForStatus(
status: GitHubCurrentPullRequestStatus | null,
options?: { onStatus?: () => void },
): GitHubService {
return {
listPullRequests: async () => [],
listIssues: async () => [],
getPullRequest: async () => ({
number: 1,
title: "PR",
url: "https://github.com/getpaseo/paseo/pull/1",
state: "OPEN",
body: null,
baseRefName: "main",
headRefName: "feature",
labels: [],
}),
getPullRequestHeadRef: async () => "feature",
getCurrentPullRequestStatus: async () => {
options?.onStatus?.();
return status;
},
createPullRequest: async () => ({
url: "https://github.com/getpaseo/paseo/pull/1",
number: 1,
}),
isAuthenticated: async () => true,
invalidate: () => {},
};
}
function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestStatus>) {
return {
url: "https://github.com/getpaseo/paseo/pull/123",
title: "Ship feature",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
checks: [],
checksStatus: "none" as const,
reviewDecision: null,
...overrides,
};
}
describe("checkout git utilities", () => {
let tempDir: string;
let repoDir: string;
@@ -70,13 +144,11 @@ describe("checkout git utilities", () => {
tempDir = setup.tempDir;
repoDir = setup.repoDir;
paseoHome = join(tempDir, "paseo-home");
__resetGhPathCacheForTests();
__resetCheckoutShortstatCacheForTests();
__resetPullRequestStatusCacheForTests();
});
afterEach(() => {
__resetGhPathCacheForTests();
__resetCheckoutShortstatCacheForTests();
__resetPullRequestStatusCacheForTests();
rmSync(tempDir, { recursive: true, force: true });
@@ -383,7 +455,7 @@ const x = 1;
});
it("handles status/diff/commit in a .paseo worktree", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -417,7 +489,7 @@ const x = 1;
});
it("returns checkout root metadata for .paseo worktrees", async () => {
const result = await createWorktree({
const result = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -444,7 +516,7 @@ const x = 1;
execSync("git config user.email 'test@test.com'", { cwd: mainCheckoutDir });
execSync("git config user.name 'Test'", { cwd: mainCheckoutDir });
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: mainCheckoutDir,
baseBranch: "main",
@@ -459,7 +531,7 @@ const x = 1;
});
it("merges the current branch into base from a worktree checkout", async () => {
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",
@@ -720,20 +792,130 @@ const x = 1;
it("disables GitHub features when gh is unavailable", async () => {
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async () => {
throw new GitHubCliMissingError();
};
const status = await getPullRequestStatus(repoDir, github);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
});
try {
__setGhPathForTests(null);
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(false);
expect(status.status).toBeNull();
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
it("searches GitHub issues and PRs through the GitHub service", async () => {
let issueCalls = 0;
let pullRequestCalls = 0;
const github = createGitHubServiceForStatus(null);
github.listIssues = async (options) => {
issueCalls += 1;
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
return [
{
number: 55,
title: "Issue title",
url: "https://github.com/getpaseo/paseo/issues/55",
state: "OPEN",
body: "issue body",
labels: ["bug"],
},
];
};
github.listPullRequests = async (options) => {
pullRequestCalls += 1;
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
return [
{
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
baseRefName: "main",
headRefName: "feature",
labels: ["enhancement"],
},
];
};
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github);
expect(issueCalls).toBe(1);
expect(pullRequestCalls).toBe(1);
expect(result).toEqual({
githubFeaturesEnabled: true,
items: [
{
kind: "issue",
number: 55,
title: "Issue title",
url: "https://github.com/getpaseo/paseo/issues/55",
state: "OPEN",
body: "issue body",
labels: ["bug"],
baseRefName: null,
headRefName: null,
},
{
kind: "pr",
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
},
],
});
});
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
let issueCalls = 0;
let pullRequestCalls = 0;
const github = createGitHubServiceForStatus(null);
github.listIssues = async () => {
issueCalls += 1;
return [];
};
github.listPullRequests = async (options) => {
pullRequestCalls += 1;
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
return [
{
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
baseRefName: "main",
headRefName: "feature",
labels: ["enhancement"],
},
];
};
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github, {
kinds: ["github-pr"],
});
expect(issueCalls).toBe(0);
expect(pullRequestCalls).toBe(1);
expect(result).toEqual({
githubFeaturesEnabled: true,
items: [
{
kind: "pr",
number: 123,
title: "PR title",
url: "https://github.com/getpaseo/paseo/pull/123",
state: "OPEN",
body: "pr body",
labels: ["enhancement"],
baseRefName: "main",
headRefName: "feature",
},
],
});
});
it("parses real gh status check rollup output and dedupes by latest check run", () => {
@@ -835,189 +1017,89 @@ const x = 1;
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-merged");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z","statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
const status = await getPullRequestStatus(
repoDir,
createGitHubServiceForStatus(
createPullRequestStatus({
state: "merged",
isMerged: true,
}),
),
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/123");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(true);
expect(status.status?.state).toBe("merged");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/123");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(true);
expect(status.status?.state).toBe("merged");
});
it("returns closed-unmerged PR status without marking it as merged", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-closed");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
const status = await getPullRequestStatus(
repoDir,
createGitHubServiceForStatus(
createPullRequestStatus({
url: "https://github.com/getpaseo/paseo/pull/999",
title: "Closed without merge",
state: "closed",
}),
),
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const status = await getPullRequestStatus(repoDir);
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/999");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(false);
expect(status.status?.state).toBe("closed");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
expect(status.githubFeaturesEnabled).toBe(true);
expect(status.status).not.toBeNull();
expect(status.status?.url).toContain("/pull/999");
expect(status.status?.baseRefName).toBe("main");
expect(status.status?.headRefName).toBe("feature");
expect(status.status?.isMerged).toBe(false);
expect(status.status?.state).toBe("closed");
});
it("caches PR status results for duplicate lookups", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-cache-hit");
const callCountPath = join(tempDir, "gh-call-count.txt");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(callCountPath, "0\n");
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`count_file=${JSON.stringify(callCountPath)}`,
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' printf "%s\\n" "$((count + 1))" > "$count_file"',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const first = await getPullRequestStatus(repoDir);
const second = await getPullRequestStatus(repoDir);
expect(first).toEqual(second);
expect(first.status?.url).toContain("/pull/123");
expect(readFileSync(callCountPath, "utf8").trim()).toBe("1");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
let callCount = 0;
const github = createGitHubServiceForStatus(createPullRequestStatus(), {
onStatus: () => {
callCount += 1;
},
});
const first = await getPullRequestStatus(repoDir, github);
const second = await getPullRequestStatus(repoDir, github);
expect(first).toEqual(second);
expect(first.status?.url).toContain("/pull/123");
expect(callCount).toBe(1);
});
it("expires cached PR status after the TTL", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-cache-expiry");
const callCountPath = join(tempDir, "gh-call-count-expiry.txt");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(callCountPath, "0\n");
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`count_file=${JSON.stringify(callCountPath)}`,
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' next="$((count + 1))"',
' printf "%s\\n" "$next" > "$count_file"',
' printf \'{"url":"https://github.com/getpaseo/paseo/pull/%s","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\\n\' "$next"',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
__setPullRequestStatusCacheTtlForTests(50);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const first = await getPullRequestStatus(repoDir);
let callCount = 0;
const github = createGitHubServiceForStatus(null, {
onStatus: () => {
callCount += 1;
},
});
github.getCurrentPullRequestStatus = async () => {
callCount += 1;
return createPullRequestStatus({
url: `https://github.com/getpaseo/paseo/pull/${callCount}`,
});
};
const first = await getPullRequestStatus(repoDir, github);
await sleep(80);
const second = await getPullRequestStatus(repoDir);
const second = await getPullRequestStatus(repoDir, github);
expect(first.status?.url).toContain("/pull/1");
expect(second.status?.url).toContain("/pull/2");
expect(readFileSync(callCountPath, "utf8").trim()).toBe("2");
expect(callCount).toBe(2);
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
});
@@ -1026,50 +1108,18 @@ const x = 1;
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
const fakeBinDir = join(tempDir, "fake-bin-gh-cache-concurrent");
const callCountPath = join(tempDir, "gh-call-count-concurrent.txt");
mkdirSync(fakeBinDir);
const gitPath = execSync("command -v git", { stdio: "pipe" }).toString().trim();
symlinkSync(gitPath, join(fakeBinDir, "git"));
writeFileSync(callCountPath, "0\n");
writeFileSync(
join(fakeBinDir, "gh"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
`count_file=${JSON.stringify(callCountPath)}`,
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' printf "%s\\n" "$((count + 1))" > "$count_file"',
" sleep 0.2",
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
try {
__setGhPathForTests(join(fakeBinDir, "gh"));
const [first, second] = await Promise.all([
getPullRequestStatus(repoDir),
getPullRequestStatus(repoDir),
]);
expect(first).toEqual(second);
expect(readFileSync(callCountPath, "utf8").trim()).toBe("1");
} finally {
__resetGhPathCacheForTests();
__resetPullRequestStatusCacheForTests();
}
let callCount = 0;
const github = createGitHubServiceForStatus(createPullRequestStatus(), {
onStatus: () => {
callCount += 1;
},
});
const [first, second] = await Promise.all([
getPullRequestStatus(repoDir, github),
getPullRequestStatus(repoDir, github),
]);
expect(first).toEqual(second);
expect(callCount).toBe(1);
});
it("returns typed MergeConflictError on merge conflicts", async () => {
@@ -1110,7 +1160,7 @@ const x = 1;
execSync("git checkout main", { cwd: repoDir });
// Create a worktree/branch based on develop, but keep main as the repo default.
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: repoDir,
baseBranch: "develop",
@@ -1156,7 +1206,7 @@ const x = 1;
execSync("git checkout main", { cwd: repoDir });
// Create a Paseo worktree configured to use develop as base.
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "feature",
cwd: repoDir,
baseBranch: "develop",
@@ -1189,7 +1239,7 @@ const x = 1;
});
it("throws if Paseo worktree base metadata is missing", async () => {
const worktree = await createWorktree({
const worktree = await createLegacyWorktreeForTest({
branchName: "main",
cwd: repoDir,
baseBranch: "main",