feat: support creating worktrees from GitHub pull requests
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -2,13 +2,19 @@ import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, stat as statFile } from "fs/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import { z } from "zod";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import type { GitHubSearchKind } from "../shared/messages.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { findExecutable } from "./executable.js";
|
||||
import {
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
createGitHubService,
|
||||
resolveGitHubRepo,
|
||||
type GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
export { parseStatusCheckRollup } from "../services/github-service.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { execCommand } from "./spawn.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
@@ -24,7 +30,6 @@ const SHORTSTAT_CACHE_MAX = 1_000;
|
||||
let pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS;
|
||||
let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
|
||||
const pullRequestStatusInFlight = new Map<string, Promise<PullRequestStatusResult>>();
|
||||
let cachedGhPath: string | null | undefined = undefined;
|
||||
let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS;
|
||||
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
|
||||
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
|
||||
@@ -69,14 +74,6 @@ export function __setPullRequestStatusCacheTtlForTests(ttlMs: number): void {
|
||||
pullRequestStatusInFlight.clear();
|
||||
}
|
||||
|
||||
export function __resetGhPathCacheForTests(): void {
|
||||
cachedGhPath = undefined;
|
||||
}
|
||||
|
||||
export function __setGhPathForTests(path: string | null): void {
|
||||
cachedGhPath = path;
|
||||
}
|
||||
|
||||
export function __resetCheckoutShortstatCacheForTests(): void {
|
||||
shortstatCache.clear();
|
||||
shortstatCache.cancelTimer();
|
||||
@@ -1837,279 +1834,12 @@ export type ChecksStatus = "none" | "pending" | "success" | "failure";
|
||||
|
||||
export type ReviewDecision = "approved" | "changes_requested" | "pending" | null;
|
||||
|
||||
const CheckRunNodeSchema = z.object({
|
||||
__typename: z.literal("CheckRun"),
|
||||
name: z.string(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
status: z.string().nullable().optional(),
|
||||
detailsUrl: z.string().nullable().optional(),
|
||||
startedAt: z.string().nullable().optional(),
|
||||
completedAt: z.string().nullable().optional(),
|
||||
checkSuite: z
|
||||
.object({
|
||||
workflowRun: z
|
||||
.object({
|
||||
databaseId: z.number().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const StatusContextNodeSchema = z.object({
|
||||
__typename: z.literal("StatusContext"),
|
||||
context: z.string(),
|
||||
state: z.string().nullable().optional(),
|
||||
targetUrl: z.string().nullable().optional(),
|
||||
createdAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const StatusCheckRollupNodeSchema = z.discriminatedUnion("__typename", [
|
||||
CheckRunNodeSchema,
|
||||
StatusContextNodeSchema,
|
||||
]);
|
||||
|
||||
const StatusCheckRollupArraySchema = z.array(z.unknown());
|
||||
const LegacyStatusCheckRollupSchema = z.object({
|
||||
contexts: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
const ReviewDecisionSchema = z
|
||||
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
|
||||
.nullable()
|
||||
.catch(null);
|
||||
|
||||
type CheckRunNode = z.infer<typeof CheckRunNodeSchema>;
|
||||
type StatusContextNode = z.infer<typeof StatusContextNodeSchema>;
|
||||
|
||||
export async function resolveGhPath(): Promise<string> {
|
||||
if (cachedGhPath === undefined) {
|
||||
cachedGhPath = await findExecutable("gh");
|
||||
}
|
||||
if (cachedGhPath === null) {
|
||||
throw new Error("GitHub CLI (gh) is not installed or not in PATH");
|
||||
}
|
||||
return cachedGhPath;
|
||||
}
|
||||
|
||||
function getCommandErrorText(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return String(error);
|
||||
}
|
||||
const stderr = typeof (error as any)?.stderr === "string" ? (error as any).stderr : "";
|
||||
const stdout = typeof (error as any)?.stdout === "string" ? (error as any).stdout : "";
|
||||
return `${error.message}\n${stderr}\n${stdout}`.toLowerCase();
|
||||
}
|
||||
|
||||
function isGhAuthError(error: unknown): boolean {
|
||||
const text = getCommandErrorText(error);
|
||||
return (
|
||||
text.includes("gh auth login") ||
|
||||
text.includes("not logged into any github hosts") ||
|
||||
text.includes("authentication failed") ||
|
||||
text.includes("authentication required") ||
|
||||
text.includes("bad credentials") ||
|
||||
text.includes("http 401")
|
||||
);
|
||||
}
|
||||
|
||||
function mapCheckRunStatus(status: unknown, conclusion: unknown): PullRequestCheck["status"] {
|
||||
if (status !== "COMPLETED") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
switch (conclusion) {
|
||||
case "SUCCESS":
|
||||
return "success";
|
||||
case "FAILURE":
|
||||
case "TIMED_OUT":
|
||||
case "ACTION_REQUIRED":
|
||||
return "failure";
|
||||
case "CANCELLED":
|
||||
return "cancelled";
|
||||
case "SKIPPED":
|
||||
case "NEUTRAL":
|
||||
return "skipped";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatusContextState(state: unknown): PullRequestCheck["status"] {
|
||||
switch (state) {
|
||||
case "SUCCESS":
|
||||
return "success";
|
||||
case "FAILURE":
|
||||
case "ERROR":
|
||||
return "failure";
|
||||
case "EXPECTED":
|
||||
case "PENDING":
|
||||
return "pending";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckRunRecency(context: CheckRunNode): number {
|
||||
const workflowRunId = context.checkSuite?.workflowRun?.databaseId;
|
||||
if (typeof workflowRunId === "number") {
|
||||
return workflowRunId;
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
typeof context.completedAt === "string"
|
||||
? context.completedAt
|
||||
: typeof context.startedAt === "string"
|
||||
? context.startedAt
|
||||
: null;
|
||||
if (!timestamp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const time = Date.parse(timestamp);
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
function getStatusContextRecency(context: StatusContextNode): number {
|
||||
if (typeof context.createdAt !== "string" || context.createdAt.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const time = Date.parse(context.createdAt);
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
|
||||
const directContexts = StatusCheckRollupArraySchema.safeParse(value);
|
||||
if (!directContexts.success) {
|
||||
const legacyContexts = LegacyStatusCheckRollupSchema.safeParse(value);
|
||||
if (!legacyContexts.success) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parseStatusCheckRollup(legacyContexts.data.contexts);
|
||||
}
|
||||
const contexts = directContexts.data;
|
||||
|
||||
const dedupedChecks = new Map<
|
||||
string,
|
||||
PullRequestCheck & {
|
||||
recency: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const entry of contexts) {
|
||||
const parsed = StatusCheckRollupNodeSchema.safeParse(entry);
|
||||
if (!parsed.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const context = parsed.data;
|
||||
let check: (PullRequestCheck & { recency: number }) | null = null;
|
||||
|
||||
if (context.__typename === "CheckRun") {
|
||||
check = {
|
||||
name: context.name,
|
||||
status: mapCheckRunStatus(context.status, context.conclusion),
|
||||
url: typeof context.detailsUrl === "string" ? context.detailsUrl : null,
|
||||
recency: getCheckRunRecency(context),
|
||||
};
|
||||
} else if (context.__typename === "StatusContext") {
|
||||
check = {
|
||||
name: context.context,
|
||||
status: mapStatusContextState(context.state),
|
||||
url: typeof context.targetUrl === "string" ? context.targetUrl : null,
|
||||
recency: getStatusContextRecency(context),
|
||||
};
|
||||
}
|
||||
|
||||
if (!check) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dedupedChecks.get(check.name);
|
||||
if (!existing || check.recency > existing.recency) {
|
||||
dedupedChecks.set(check.name, check);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dedupedChecks.values(), ({ recency: _recency, ...check }) => check);
|
||||
}
|
||||
|
||||
function computeChecksStatus(checks: PullRequestCheck[]): ChecksStatus {
|
||||
if (checks.length === 0) {
|
||||
return "none";
|
||||
}
|
||||
if (checks.some((check) => check.status === "failure")) {
|
||||
return "failure";
|
||||
}
|
||||
if (checks.some((check) => check.status === "pending")) {
|
||||
return "pending";
|
||||
}
|
||||
return "success";
|
||||
}
|
||||
|
||||
function mapReviewDecision(value: unknown): ReviewDecision {
|
||||
const reviewDecision = ReviewDecisionSchema.parse(value);
|
||||
if (reviewDecision === "APPROVED") {
|
||||
return "approved";
|
||||
}
|
||||
if (reviewDecision === "CHANGES_REQUESTED") {
|
||||
return "changes_requested";
|
||||
}
|
||||
if (reviewDecision === "REVIEW_REQUIRED") {
|
||||
return "pending";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const url = stdout.trim();
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
let cleaned = url;
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else if (cleaned.startsWith("https://github.com/")) {
|
||||
cleaned = cleaned.slice("https://github.com/".length);
|
||||
} else if (cleaned.startsWith("http://github.com/")) {
|
||||
cleaned = cleaned.slice("http://github.com/".length);
|
||||
} else {
|
||||
const marker = "github.com/";
|
||||
const index = cleaned.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
cleaned = cleaned.slice(index + marker.length);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
return cleaned;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions,
|
||||
github: GitHubService = createGitHubService(),
|
||||
): Promise<{ url: string; number: number }> {
|
||||
await requireGitRepo(cwd);
|
||||
const ghPath = await resolveGhPath();
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
if (!repo) {
|
||||
throw new Error("Unable to determine GitHub repo from git remote");
|
||||
@@ -2131,22 +1861,20 @@ export async function createPullRequest(
|
||||
|
||||
await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 });
|
||||
|
||||
const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
||||
args.push("-f", `head=${head}`);
|
||||
args.push("-f", `base=${normalizedBase}`);
|
||||
if (options.body) {
|
||||
args.push("-f", `body=${options.body}`);
|
||||
}
|
||||
const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv });
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
if (!parsed?.url || !parsed?.number) {
|
||||
throw new Error("GitHub CLI did not return PR url/number");
|
||||
}
|
||||
return { url: parsed.url, number: parsed.number };
|
||||
return github.createPullRequest({
|
||||
cwd,
|
||||
repo,
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
head,
|
||||
base: normalizedBase,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
|
||||
export async function getPullRequestStatus(
|
||||
cwd: string,
|
||||
github: GitHubService = createGitHubService(),
|
||||
): Promise<PullRequestStatusResult> {
|
||||
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
||||
const cached = pullRequestStatusCache.get(cacheKey);
|
||||
if (cached) {
|
||||
@@ -2158,7 +1886,7 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
return existing;
|
||||
}
|
||||
|
||||
const lookup = getPullRequestStatusUncached(cwd)
|
||||
const lookup = getPullRequestStatusUncached(cwd, github)
|
||||
.then((status) => {
|
||||
pullRequestStatusCache.set(cacheKey, status);
|
||||
return status;
|
||||
@@ -2171,7 +1899,10 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
return lookup;
|
||||
}
|
||||
|
||||
async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestStatusResult> {
|
||||
async function getPullRequestStatusUncached(
|
||||
cwd: string,
|
||||
github: GitHubService,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
if (!head) {
|
||||
@@ -2180,63 +1911,16 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
let ghPath: string;
|
||||
try {
|
||||
ghPath = await resolveGhPath();
|
||||
} catch {
|
||||
const status = await github.getCurrentPullRequestStatus({ cwd, headRef: head });
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execCommand(
|
||||
ghPath,
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
"--json",
|
||||
"url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision",
|
||||
],
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
);
|
||||
const pr = JSON.parse(stdout.trim());
|
||||
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
|
||||
return { status: null, githubFeaturesEnabled: true };
|
||||
}
|
||||
const mergedAt =
|
||||
typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0 ? pr.mergedAt : null;
|
||||
const state =
|
||||
mergedAt !== null
|
||||
? "merged"
|
||||
: typeof pr.state === "string" && pr.state.trim().length > 0
|
||||
? pr.state.toLowerCase()
|
||||
: "";
|
||||
const checks = parseStatusCheckRollup(pr.statusCheckRollup);
|
||||
const reviewDecision = mapReviewDecision(pr.reviewDecision);
|
||||
return {
|
||||
status: {
|
||||
url: pr.url,
|
||||
title: pr.title,
|
||||
state,
|
||||
baseRefName: pr.baseRefName ?? "",
|
||||
headRefName: pr.headRefName ?? head,
|
||||
isMerged: mergedAt !== null,
|
||||
checks,
|
||||
checksStatus: computeChecksStatus(checks),
|
||||
reviewDecision,
|
||||
},
|
||||
status,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
|
||||
return { status: null, githubFeaturesEnabled: false };
|
||||
}
|
||||
// gh pr view exits non-zero when no PR exists for the branch
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("no pull requests found") || message.includes("Could not resolve")) {
|
||||
return { status: null, githubFeaturesEnabled: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2256,96 +1940,86 @@ export interface GitHubSearchResult {
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
interface SearchGitHubIssuesAndPrsOptions {
|
||||
kinds?: GitHubSearchKind[];
|
||||
}
|
||||
|
||||
export async function searchGitHubIssuesAndPrs(
|
||||
cwd: string,
|
||||
query: string,
|
||||
limit = 20,
|
||||
github: GitHubService = createGitHubService(),
|
||||
options: SearchGitHubIssuesAndPrsOptions = {},
|
||||
): Promise<GitHubSearchResult> {
|
||||
await requireGitRepo(cwd);
|
||||
let ghPath: string;
|
||||
try {
|
||||
ghPath = await resolveGhPath();
|
||||
} catch {
|
||||
return { items: [], githubFeaturesEnabled: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const [issuesResult, prsResult] = await Promise.allSettled([
|
||||
execCommand(
|
||||
ghPath,
|
||||
[
|
||||
"issue",
|
||||
"list",
|
||||
"--search",
|
||||
query,
|
||||
"--json",
|
||||
"number,title,url,state,body,labels",
|
||||
"--limit",
|
||||
String(limit),
|
||||
],
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
),
|
||||
execCommand(
|
||||
ghPath,
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--search",
|
||||
query,
|
||||
"--json",
|
||||
"number,title,url,state,body,labels,baseRefName,headRefName",
|
||||
"--limit",
|
||||
String(limit),
|
||||
],
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
),
|
||||
]);
|
||||
const kinds = options.kinds ?? ["github-issue", "github-pr"];
|
||||
const shouldFetchIssues = kinds.includes("github-issue");
|
||||
const shouldFetchPullRequests = kinds.includes("github-pr");
|
||||
const issuesResult = shouldFetchIssues
|
||||
? await github.listIssues({ cwd, query, limit }).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
: null;
|
||||
const prsResult = shouldFetchPullRequests
|
||||
? await github.listPullRequests({ cwd, query, limit }).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
: null;
|
||||
|
||||
const items: GitHubSearchResult["items"] = [];
|
||||
const requestedResults = [issuesResult, prsResult].filter((result) => result !== null);
|
||||
const failures = requestedResults.filter((result) => result.status === "rejected");
|
||||
if (
|
||||
requestedResults.length > 0 &&
|
||||
failures.length === requestedResults.length &&
|
||||
failures.every(
|
||||
(result) =>
|
||||
result.status === "rejected" &&
|
||||
(result.reason instanceof GitHubCliMissingError ||
|
||||
result.reason instanceof GitHubAuthenticationError),
|
||||
)
|
||||
) {
|
||||
return { items: [], githubFeaturesEnabled: false };
|
||||
}
|
||||
|
||||
if (issuesResult.status === "fulfilled") {
|
||||
const parsed = JSON.parse(issuesResult.value.stdout.trim() || "[]");
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
items.push({
|
||||
kind: "issue",
|
||||
number: item.number,
|
||||
title: item.title ?? "",
|
||||
url: item.url ?? "",
|
||||
state: item.state ?? "",
|
||||
body: item.body ?? null,
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean)
|
||||
: [],
|
||||
baseRefName: item.baseRefName ?? null,
|
||||
headRefName: item.headRefName ?? null,
|
||||
});
|
||||
}
|
||||
if (issuesResult?.status === "fulfilled") {
|
||||
for (const item of issuesResult.value) {
|
||||
items.push({
|
||||
kind: "issue",
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
state: item.state,
|
||||
body: item.body,
|
||||
labels: item.labels,
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (prsResult.status === "fulfilled") {
|
||||
const parsed = JSON.parse(prsResult.value.stdout.trim() || "[]");
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
items.push({
|
||||
kind: "pr",
|
||||
number: item.number,
|
||||
title: item.title ?? "",
|
||||
url: item.url ?? "",
|
||||
state: item.state ?? "",
|
||||
body: item.body ?? null,
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels.map((l: { name?: string }) => l.name ?? "").filter(Boolean)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (prsResult?.status === "fulfilled") {
|
||||
for (const item of prsResult.value) {
|
||||
items.push({
|
||||
kind: "pr",
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
state: item.state,
|
||||
body: item.body,
|
||||
labels: item.labels,
|
||||
baseRefName: item.baseRefName,
|
||||
headRefName: item.headRefName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items, githubFeaturesEnabled: true };
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
|
||||
return { items: [], githubFeaturesEnabled: false };
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { join, basename, dirname, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import {
|
||||
@@ -153,15 +152,52 @@ export type PaseoWorktreeOwnership = {
|
||||
worktreePath?: string;
|
||||
};
|
||||
|
||||
interface CreateWorktreeOptions {
|
||||
branchName: string;
|
||||
export type WorktreeSource =
|
||||
| { kind: "branch-off"; baseBranch: string; newBranchName: string }
|
||||
| { kind: "checkout-branch"; branchName: string }
|
||||
| {
|
||||
kind: "checkout-github-pr";
|
||||
githubPrNumber: number;
|
||||
headRef: string;
|
||||
baseRefName: string;
|
||||
};
|
||||
|
||||
export interface CreateWorktreeOptions {
|
||||
cwd: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug?: string;
|
||||
runSetup?: boolean;
|
||||
worktreeSlug: string;
|
||||
source: WorktreeSource;
|
||||
runSetup: boolean;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
interface ResolveExistingWorktreeForSlugOptions {
|
||||
slug: string;
|
||||
repoRoot: string;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
export class BranchAlreadyCheckedOutError extends Error {
|
||||
readonly branchName: string;
|
||||
|
||||
constructor(branchName: string) {
|
||||
super(`Branch already checked out: ${branchName}`);
|
||||
this.name = "BranchAlreadyCheckedOutError";
|
||||
this.branchName = branchName;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownBranchError extends Error {
|
||||
readonly branchName: string;
|
||||
readonly cwd: string;
|
||||
|
||||
constructor(params: { branchName: string; cwd: string }) {
|
||||
super(`Unknown branch: ${params.branchName}`);
|
||||
this.name = "UnknownBranchError";
|
||||
this.branchName = params.branchName;
|
||||
this.cwd = params.cwd;
|
||||
}
|
||||
}
|
||||
|
||||
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
||||
const paseoConfigPath = join(repoRoot, "paseo.json");
|
||||
if (!existsSync(paseoConfigPath)) {
|
||||
@@ -792,10 +828,6 @@ export function slugify(input: string): string {
|
||||
return truncated.replace(/-+$/, "");
|
||||
}
|
||||
|
||||
function generateWorktreeSlug(): string {
|
||||
return createNameId();
|
||||
}
|
||||
|
||||
const WORKTREE_PROJECT_HASH_LENGTH = 8;
|
||||
|
||||
function deriveShortAlphanumericHash(value: string): string {
|
||||
@@ -974,6 +1006,36 @@ export async function listPaseoWorktrees({
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveExistingWorktreeForSlug({
|
||||
slug,
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
}: ResolveExistingWorktreeForSlugOptions): Promise<WorktreeConfig | null> {
|
||||
const worktrees = await listPaseoWorktrees({
|
||||
cwd: repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
const slugSuffix = `${sep}${slug}`;
|
||||
const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix));
|
||||
if (!existingWorktree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { stdout } = await runGitCommand(["branch", "--show-current"], {
|
||||
cwd: existingWorktree.path,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const branchName = stdout.trim();
|
||||
if (!branchName) {
|
||||
throw new Error(`Unable to resolve branch for existing worktree: ${existingWorktree.path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName,
|
||||
worktreePath: existingWorktree.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePaseoWorktreeRootForCwd(
|
||||
cwd: string,
|
||||
options?: { paseoHome?: string },
|
||||
@@ -1099,8 +1161,11 @@ async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,80 +1201,17 @@ async function removeDirectoryWithRetries(path: string): Promise<void> {
|
||||
/**
|
||||
* Create a git worktree with proper naming conventions
|
||||
*/
|
||||
export async function createWorktree({
|
||||
branchName,
|
||||
export const createWorktree = async ({
|
||||
cwd,
|
||||
baseBranch,
|
||||
source,
|
||||
worktreeSlug,
|
||||
runSetup = true,
|
||||
runSetup,
|
||||
paseoHome,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
|
||||
// Validate branch name
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid branch name: ${validation.error}`);
|
||||
}
|
||||
|
||||
const normalizedBaseBranch = baseBranch ? normalizeBaseRefName(baseBranch) : "";
|
||||
if (!normalizedBaseBranch) {
|
||||
throw new Error("Base branch is required when creating a Paseo worktree");
|
||||
}
|
||||
if (normalizedBaseBranch === "HEAD") {
|
||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||
}
|
||||
|
||||
// Resolve the base branch - prefer origin/{branch}, then fall back to local
|
||||
let resolvedBaseBranch = normalizedBaseBranch;
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let worktreePath: string;
|
||||
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
||||
|
||||
worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), desiredSlug);
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
|
||||
// Check if branch already exists
|
||||
let branchExists = false;
|
||||
try {
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd,
|
||||
});
|
||||
branchExists = true;
|
||||
} catch {
|
||||
branchExists = false;
|
||||
}
|
||||
|
||||
// Always create a new branch for the worktree
|
||||
// If branchName already exists, use it as base and create worktree-slug as branch name
|
||||
// If branchName doesn't exist, create it from baseBranch (resolved to remote if needed)
|
||||
const base = branchExists ? branchName : resolvedBaseBranch;
|
||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||
|
||||
// Find unique branch name if collision
|
||||
let newBranchName = candidateBranch;
|
||||
let suffix = 1;
|
||||
while (true) {
|
||||
try {
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${newBranchName}`], {
|
||||
cwd,
|
||||
});
|
||||
// Branch exists, try with suffix
|
||||
newBranchName = `${candidateBranch}-${suffix}`;
|
||||
suffix++;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
@@ -1218,24 +1220,170 @@ export async function createWorktree({
|
||||
pathSuffix++;
|
||||
}
|
||||
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], {
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
branchName: newBranchName,
|
||||
branchName: sourcePlan.branchName,
|
||||
cleanupOnFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreePath,
|
||||
};
|
||||
};
|
||||
|
||||
interface ResolveWorktreeSourcePlanOptions {
|
||||
cwd: string;
|
||||
source: WorktreeSource;
|
||||
desiredSlug: string;
|
||||
}
|
||||
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
metadataBaseRefName: string;
|
||||
addArguments: string[];
|
||||
}
|
||||
|
||||
async function resolveWorktreeSourcePlan({
|
||||
cwd,
|
||||
source,
|
||||
desiredSlug,
|
||||
}: ResolveWorktreeSourcePlanOptions): Promise<WorktreeSourcePlan> {
|
||||
switch (source.kind) {
|
||||
case "branch-off": {
|
||||
const branchName = source.newBranchName;
|
||||
validateWorktreeBranchName(branchName);
|
||||
const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch);
|
||||
const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, normalizedBaseBranch);
|
||||
const branchExists = await localBranchExists(cwd, branchName);
|
||||
const base = branchExists ? branchName : resolvedBaseBranch;
|
||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||
const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch);
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
addArguments: ["-b", newBranchName, base],
|
||||
};
|
||||
}
|
||||
case "checkout-branch": {
|
||||
validateWorktreeBranchName(source.branchName);
|
||||
if (!(await localBranchExists(cwd, source.branchName))) {
|
||||
try {
|
||||
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: source.branchName, cwd });
|
||||
}
|
||||
}
|
||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||
throw new BranchAlreadyCheckedOutError(source.branchName);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
case "checkout-github-pr": {
|
||||
validateWorktreeBranchName(source.headRef);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
await runGitCommand(
|
||||
[
|
||||
"fetch",
|
||||
"origin",
|
||||
`refs/pull/${source.githubPrNumber}/head:refs/heads/${source.headRef}`,
|
||||
"--force",
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
branchName: source.headRef,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
addArguments: [source.headRef],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorktreeBranchName(branchName: string): void {
|
||||
const validation = validateBranchSlug(branchName);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid branch name: ${validation.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRequiredBaseBranch(baseBranch: string): string {
|
||||
const normalizedBaseBranch = normalizeBaseRefName(baseBranch);
|
||||
if (!normalizedBaseBranch) {
|
||||
throw new Error("Base branch is required when creating a Paseo worktree");
|
||||
}
|
||||
if (normalizedBaseBranch === "HEAD") {
|
||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||
}
|
||||
return normalizedBaseBranch;
|
||||
}
|
||||
|
||||
async function resolveBaseBranchForWorktree(
|
||||
cwd: string,
|
||||
normalizedBaseBranch: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
|
||||
return `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
|
||||
return normalizedBaseBranch;
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
|
||||
let newBranchName = candidateBranch;
|
||||
let suffix = 1;
|
||||
while (await localBranchExists(cwd, newBranchName)) {
|
||||
newBranchName = `${candidateBranch}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
return newBranchName;
|
||||
}
|
||||
|
||||
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {
|
||||
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return parseWorktreeList(stdout).some((entry) => entry.branchName === branchName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user