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