feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -17,6 +17,7 @@ import {
|
||||
__resetPullRequestStatusCacheForTests,
|
||||
__setPullRequestStatusCacheTtlForTests,
|
||||
commitAll,
|
||||
createPullRequest,
|
||||
getCachedCheckoutShortstat,
|
||||
getCheckoutSnapshotFacts,
|
||||
getCurrentBranch,
|
||||
@@ -42,12 +43,10 @@ import {
|
||||
warmCheckoutShortstatInBackground,
|
||||
} from "./checkout-git.js";
|
||||
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
||||
import {
|
||||
GitHubCommandError,
|
||||
GitHubCliMissingError,
|
||||
type GitHubCurrentPullRequestStatus,
|
||||
type GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
import { createForgeResolver } from "../services/forge-resolver.js";
|
||||
import { GitHubCommandError, GitHubCliMissingError } from "../services/github-service.js";
|
||||
import type { CurrentPullRequestStatus, ForgeService } from "../services/forge-service.js";
|
||||
import { TeaAuthenticationError, TeaCliMissingError } from "../services/gitea-service.js";
|
||||
import {
|
||||
createWorktree as createWorktreePrimitive,
|
||||
type CreateWorktreeOptions,
|
||||
@@ -102,9 +101,9 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
function createGitHubServiceForStatus(
|
||||
status: GitHubCurrentPullRequestStatus | null,
|
||||
status: CurrentPullRequestStatus | null,
|
||||
options?: { onStatus?: () => void },
|
||||
): GitHubService {
|
||||
): ForgeService {
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
@@ -120,6 +119,15 @@ function createGitHubServiceForStatus(
|
||||
labels: [],
|
||||
}),
|
||||
getPullRequestHeadRef: async () => "feature",
|
||||
getPullRequestCheckoutTarget: async ({ number }) => ({
|
||||
number,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
headOwnerLogin: null,
|
||||
headRepositorySshUrl: null,
|
||||
headRepositoryUrl: null,
|
||||
isCrossRepository: false,
|
||||
}),
|
||||
getCurrentPullRequestStatus: async () => {
|
||||
options?.onStatus?.();
|
||||
return status;
|
||||
@@ -134,7 +142,7 @@ function createGitHubServiceForStatus(
|
||||
};
|
||||
}
|
||||
|
||||
function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestStatus>) {
|
||||
function createPullRequestStatus(overrides?: Partial<CurrentPullRequestStatus>) {
|
||||
return {
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
title: "Ship feature",
|
||||
@@ -156,12 +164,12 @@ interface RequestedPullRequestTarget {
|
||||
|
||||
interface RecordingPullRequestTargetsOptions {
|
||||
requestedTargets: RequestedPullRequestTarget[];
|
||||
statusOverrides?: Partial<GitHubCurrentPullRequestStatus>;
|
||||
statusOverrides?: Partial<CurrentPullRequestStatus>;
|
||||
}
|
||||
|
||||
function createGitHubServiceRecordingPullRequestTargets(
|
||||
options: RecordingPullRequestTargetsOptions,
|
||||
): GitHubService {
|
||||
): ForgeService {
|
||||
const github = createGitHubServiceForStatus(null);
|
||||
github.getCurrentPullRequestStatus = async (request) => {
|
||||
options.requestedTargets.push({
|
||||
@@ -245,6 +253,60 @@ describe("checkout git utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a pull request via the given adapter without resolving a repo slug in the shell", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
let adapterReached = false;
|
||||
const adapter = createGitHubServiceForStatus(null);
|
||||
adapter.createPullRequest = async () => {
|
||||
adapterReached = true;
|
||||
return { url: "https://gitlab.com/group/proj/-/merge_requests/7", number: 7 };
|
||||
};
|
||||
|
||||
const result = await createPullRequest(
|
||||
repoDir,
|
||||
{ title: "Add thing", body: "desc", base: "main" },
|
||||
adapter,
|
||||
);
|
||||
|
||||
expect(adapterReached).toBe(true);
|
||||
expect(result).toEqual({
|
||||
url: "https://gitlab.com/group/proj/-/merge_requests/7",
|
||||
number: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a gitlab remote through the resolver and creates via the gitlab adapter", async () => {
|
||||
// origin is a real local bare repo so the push succeeds; the resolver reads a
|
||||
// gitlab remote URL (injected) so it selects the gitlab adapter from the host.
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
let adapterReached = false;
|
||||
const adapter = createGitHubServiceForStatus(null);
|
||||
adapter.createPullRequest = async () => {
|
||||
adapterReached = true;
|
||||
return { url: "https://gitlab.com/group/proj/-/merge_requests/9", number: 9 };
|
||||
};
|
||||
|
||||
const resolver = createForgeResolver({
|
||||
resolveRemoteUrl: async () => "git@gitlab.com:group/proj.git",
|
||||
createService: (forge) => (forge === "gitlab" ? adapter : null),
|
||||
});
|
||||
const resolution = await resolver.resolve(repoDir);
|
||||
expect(resolution).toMatchObject({ forge: "gitlab", host: "gitlab.com" });
|
||||
expect(resolution?.service).toBe(adapter);
|
||||
|
||||
const result = await createPullRequest(
|
||||
repoDir,
|
||||
{ title: "Add thing", body: "desc", base: "main" },
|
||||
resolution?.service,
|
||||
);
|
||||
|
||||
expect(adapterReached).toBe(true);
|
||||
expect(result).toEqual({
|
||||
url: "https://gitlab.com/group/proj/-/merge_requests/9",
|
||||
number: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for getCurrentBranch in a repo with no commits", async () => {
|
||||
const emptyRepo = join(tempDir, "empty-repo");
|
||||
mkdirSync(emptyRepo, { recursive: true });
|
||||
@@ -2199,6 +2261,7 @@ const x = 1;
|
||||
|
||||
expect(status).toEqual({
|
||||
githubFeaturesEnabled: true,
|
||||
authState: "authenticated",
|
||||
status: {
|
||||
number: 123,
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
@@ -2593,6 +2656,7 @@ const x = 1;
|
||||
expect(fresh.status?.url).toContain("/pull/123");
|
||||
expect(cleared).toEqual({
|
||||
githubFeaturesEnabled: true,
|
||||
authState: "authenticated",
|
||||
status: null,
|
||||
});
|
||||
expect(callCount).toBe(2);
|
||||
@@ -2601,6 +2665,42 @@ const x = 1;
|
||||
}
|
||||
});
|
||||
|
||||
it("maps missing Gitea CLI PR status lookups to cli_missing auth state", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const service = createGitHubServiceForStatus(null);
|
||||
service.getCurrentPullRequestStatus = async () => {
|
||||
throw new TeaCliMissingError();
|
||||
};
|
||||
|
||||
await expect(getPullRequestStatus(repoDir, service)).resolves.toEqual({
|
||||
status: null,
|
||||
authState: "cli_missing",
|
||||
githubFeaturesEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Gitea authentication PR status failures to unauthenticated auth state", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://gitea.example.com/acme/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const service = createGitHubServiceForStatus(null);
|
||||
service.getCurrentPullRequestStatus = async () => {
|
||||
throw new TeaAuthenticationError({ stderr: "401 unauthorized" });
|
||||
};
|
||||
|
||||
await expect(getPullRequestStatus(repoDir, service)).resolves.toEqual({
|
||||
status: null,
|
||||
authState: "unauthenticated",
|
||||
githubFeaturesEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes concurrent PR status lookups for the same cwd", async () => {
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], {
|
||||
|
||||
@@ -7,21 +7,19 @@ import type { Logger } from "pino";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
|
||||
import {
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
GitHubCommandError,
|
||||
createGitHubService,
|
||||
resolveGitHubRepo,
|
||||
type GitHubCurrentPullRequestStatus,
|
||||
type GitHubPullRequestStatusFacts,
|
||||
type GitHubService,
|
||||
type PullRequestMergeable,
|
||||
} from "../services/github-service.js";
|
||||
import { GitHubCommandError, createGitHubService } from "../services/github-service.js";
|
||||
import type {
|
||||
CurrentPullRequestStatus,
|
||||
ForgeAuthState,
|
||||
ForgeService,
|
||||
ForgeSpecificStatusFacts,
|
||||
PullRequestMergeable,
|
||||
} from "../services/forge-service.js";
|
||||
import { ForgeAuthenticationError, ForgeCliMissingError } from "../services/forge-cli-command.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
} as const;
|
||||
@@ -1626,6 +1624,45 @@ function buildPullRequestLookupTargetFromPushConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPullRequestLookupTargetFromMetadata(
|
||||
metadata: PaseoWorktreeMetadata | null,
|
||||
): PullRequestStatusLookupTarget | null {
|
||||
const target = metadata?.changeRequestLookupTarget;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
headRef: target.headRef,
|
||||
...(target.headRepositoryOwner ? { headRepositoryOwner: target.headRepositoryOwner } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialPullRequestLookupTarget(input: {
|
||||
currentBranch: string | null;
|
||||
metadata: PaseoWorktreeMetadata | null;
|
||||
branchRemoteName: string | null;
|
||||
branchMergeRef: string | null;
|
||||
branchRemoteUrl: string | null;
|
||||
originRemoteUrl: string | null;
|
||||
resolvedBaseRef: string | null;
|
||||
}): PullRequestStatusLookupTarget | null {
|
||||
if (!input.currentBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
buildPullRequestLookupTargetFromMetadata(input.metadata) ??
|
||||
buildPullRequestLookupTargetFromBranchConfig({
|
||||
currentBranch: input.currentBranch,
|
||||
branchRemoteName: input.branchRemoteName,
|
||||
branchMergeRef: input.branchMergeRef,
|
||||
branchRemoteUrl: input.branchRemoteUrl,
|
||||
originRemoteUrl: input.originRemoteUrl,
|
||||
resolvedBaseRef: input.resolvedBaseRef,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvePullRequestLookupTargetFromPushConfig(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
@@ -1671,9 +1708,10 @@ export async function getCheckoutSnapshotFacts(
|
||||
return { isGit: false };
|
||||
}
|
||||
|
||||
const storedBaseRef = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
|
||||
const paseoWorktreeMetadata = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||
? readPaseoWorktreeMetadata(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const storedBaseRef = paseoWorktreeMetadata?.baseRefName ?? null;
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
|
||||
const mainRepoRoot = await getMainRepoRootFromCommonDir(
|
||||
cwd,
|
||||
@@ -1707,16 +1745,15 @@ export async function getCheckoutSnapshotFacts(
|
||||
]);
|
||||
}
|
||||
}
|
||||
let pullRequestLookupTarget = inspected.currentBranch
|
||||
? buildPullRequestLookupTargetFromBranchConfig({
|
||||
currentBranch: inspected.currentBranch,
|
||||
branchRemoteName,
|
||||
branchMergeRef,
|
||||
branchRemoteUrl,
|
||||
originRemoteUrl: inspected.remoteUrl,
|
||||
resolvedBaseRef,
|
||||
})
|
||||
: null;
|
||||
let pullRequestLookupTarget = buildInitialPullRequestLookupTarget({
|
||||
currentBranch: inspected.currentBranch,
|
||||
metadata: paseoWorktreeMetadata,
|
||||
branchRemoteName,
|
||||
branchMergeRef,
|
||||
branchRemoteUrl,
|
||||
originRemoteUrl: inspected.remoteUrl,
|
||||
resolvedBaseRef,
|
||||
});
|
||||
if (
|
||||
inspected.currentBranch &&
|
||||
pullRequestLookupTarget?.headRef === inspected.currentBranch &&
|
||||
@@ -3100,7 +3137,7 @@ async function detectAndThrowMergeFromBaseConflict(
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullCurrentBranch(cwd: string, github?: GitHubService): Promise<void> {
|
||||
export async function pullCurrentBranch(cwd: string, forgeService?: ForgeService): Promise<void> {
|
||||
await requireGitRepo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (!currentBranch || currentBranch === "HEAD") {
|
||||
@@ -3112,14 +3149,14 @@ export async function pullCurrentBranch(cwd: string, github?: GitHubService): Pr
|
||||
}
|
||||
try {
|
||||
await runGitCommand(["pull"], { cwd, timeout: 120_000 });
|
||||
github?.invalidate({ cwd });
|
||||
forgeService?.invalidate({ cwd });
|
||||
} catch (error) {
|
||||
await abortGitPullConflictState(cwd);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushCurrentBranch(cwd: string, github?: GitHubService): Promise<void> {
|
||||
export async function pushCurrentBranch(cwd: string, forgeService?: ForgeService): Promise<void> {
|
||||
await requireGitRepo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (!currentBranch || currentBranch === "HEAD") {
|
||||
@@ -3132,7 +3169,7 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
|
||||
{ cwd, timeout: 120_000 },
|
||||
);
|
||||
await refreshCurrentBranchTrackedRefAfterPush(cwd, currentBranch, configuredPushTarget);
|
||||
github?.invalidate({ cwd });
|
||||
forgeService?.invalidate({ cwd });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3142,7 +3179,7 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
|
||||
["push", "-u", upstreamTarget.remoteName, `HEAD:refs/heads/${upstreamTarget.headRef}`],
|
||||
{ cwd, timeout: 120_000 },
|
||||
);
|
||||
github?.invalidate({ cwd });
|
||||
forgeService?.invalidate({ cwd });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3151,7 +3188,7 @@ export async function pushCurrentBranch(cwd: string, github?: GitHubService): Pr
|
||||
throw new Error("Remote 'origin' is not configured.");
|
||||
}
|
||||
await runGitCommand(["push", "-u", "origin", currentBranch], { cwd, timeout: 120_000 });
|
||||
github?.invalidate({ cwd });
|
||||
forgeService?.invalidate({ cwd });
|
||||
}
|
||||
|
||||
async function getCurrentBranchConfiguredPushTarget(
|
||||
@@ -3266,6 +3303,7 @@ export interface PullRequestStatus {
|
||||
number?: number;
|
||||
repoOwner?: string;
|
||||
repoName?: string;
|
||||
projectPath?: string;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
@@ -3277,14 +3315,41 @@ export interface PullRequestStatus {
|
||||
checks?: PullRequestCheck[];
|
||||
checksStatus?: ChecksStatus;
|
||||
reviewDecision?: ReviewDecision;
|
||||
github?: GitHubPullRequestStatusFacts;
|
||||
forgeSpecific?: ForgeSpecificStatusFacts;
|
||||
}
|
||||
|
||||
export interface PullRequestStatusResult {
|
||||
status: PullRequestStatus | null;
|
||||
/** Why forge features are (un)available — drives the onboarding callout. */
|
||||
authState: ForgeAuthState;
|
||||
/** Kept in sync with {@link authState} for back-compat; true iff authenticated. */
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
function buildPullRequestStatusResult(
|
||||
status: PullRequestStatus | null,
|
||||
authState: ForgeAuthState,
|
||||
): PullRequestStatusResult {
|
||||
return { status, authState, githubFeaturesEnabled: authState === "authenticated" };
|
||||
}
|
||||
|
||||
/** True for the CLI-missing / authentication errors of any supported forge. */
|
||||
export function isForgeAuthError(error: unknown): boolean {
|
||||
return error instanceof ForgeCliMissingError || error instanceof ForgeAuthenticationError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a forge CLI failure to an auth state. A missing-CLI error means the user
|
||||
* must install the tool; anything else surfaced as an auth probe failure means
|
||||
* they must sign in.
|
||||
*/
|
||||
export function forgeAuthStateFromError(error: unknown): ForgeAuthState {
|
||||
if (error instanceof ForgeCliMissingError) {
|
||||
return "cli_missing";
|
||||
}
|
||||
return "unauthenticated";
|
||||
}
|
||||
|
||||
export interface PullRequestCheck {
|
||||
name: string;
|
||||
status: "success" | "failure" | "pending" | "skipped" | "cancelled";
|
||||
@@ -3300,14 +3365,10 @@ export type ReviewDecision = "approved" | "changes_requested" | "pending" | null
|
||||
export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions,
|
||||
github: GitHubService = createGitHubService(),
|
||||
forgeService: ForgeService = createGitHubService(),
|
||||
context?: CheckoutContext,
|
||||
): Promise<{ url: string; number: number }> {
|
||||
await requireGitRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
if (!repo) {
|
||||
throw new Error("Unable to determine GitHub repo from git remote");
|
||||
}
|
||||
|
||||
const head = options.head ?? (await getCurrentBranch(cwd));
|
||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
@@ -3323,23 +3384,28 @@ export async function createPullRequest(
|
||||
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
|
||||
}
|
||||
|
||||
// The push deliberately happens before the adapter resolves the target
|
||||
// repository: slug resolution is adapter-internal (e.g. `gh repo view`, which
|
||||
// handles GHES and renamed repos), and the RPC path only reaches here after
|
||||
// the forge resolver has already matched the origin remote to a forge. If the
|
||||
// adapter still fails after the push, retrying is safe — the non-force push
|
||||
// of the head branch is idempotent.
|
||||
await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 });
|
||||
|
||||
const result = await github.createPullRequest({
|
||||
const result = await forgeService.createPullRequest({
|
||||
cwd,
|
||||
repo,
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
head,
|
||||
base: normalizedBase,
|
||||
});
|
||||
github.invalidate({ cwd });
|
||||
forgeService.invalidate({ cwd });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getPullRequestStatus(
|
||||
cwd: string,
|
||||
github: GitHubService = createGitHubService(),
|
||||
forgeService: ForgeService = createGitHubService(),
|
||||
options?: CheckoutReadCacheOptions,
|
||||
context?: CheckoutContext,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
@@ -3356,7 +3422,7 @@ export async function getPullRequestStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const lookup = getPullRequestStatusUncached(cwd, github, options, context)
|
||||
const lookup = getPullRequestStatusUncached(cwd, forgeService, options, context)
|
||||
.then((status) => {
|
||||
pullRequestStatusCache.set(cacheKey, status);
|
||||
rememberPullRequestStatus(cacheKey, status);
|
||||
@@ -3381,54 +3447,45 @@ export async function getPullRequestStatus(
|
||||
|
||||
async function getPullRequestStatusUncached(
|
||||
cwd: string,
|
||||
github: GitHubService,
|
||||
forgeService: ForgeService,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
context?: CheckoutContext,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
if (context?.facts?.isGit === false) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
return buildPullRequestStatusResult(null, "no_remote");
|
||||
}
|
||||
if (!context?.facts?.isGit) {
|
||||
await requireGitRepo(cwd);
|
||||
}
|
||||
const head = context?.facts?.isGit ? context.facts.currentBranch : await getCurrentBranch(cwd);
|
||||
if (!head) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
return buildPullRequestStatusResult(null, "no_remote");
|
||||
}
|
||||
try {
|
||||
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
|
||||
let status: GitHubCurrentPullRequestStatus | null;
|
||||
let status: CurrentPullRequestStatus | null;
|
||||
if (options?.force) {
|
||||
const reason = options.reason;
|
||||
if (!reason) {
|
||||
throw new Error("Forced PR status read requires a reason");
|
||||
}
|
||||
status = await github.getCurrentPullRequestStatus({
|
||||
status = await forgeService.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
...lookupTarget,
|
||||
force: true,
|
||||
reason,
|
||||
});
|
||||
} else {
|
||||
status = await github.getCurrentPullRequestStatus({
|
||||
status = await forgeService.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
...lookupTarget,
|
||||
reason: options?.reason,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
return buildPullRequestStatusResult(status, "authenticated");
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
|
||||
return { status: null, githubFeaturesEnabled: false };
|
||||
if (isForgeAuthError(error)) {
|
||||
return buildPullRequestStatusResult(null, forgeAuthStateFromError(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseGitHubRemoteUrl, resolveGitHubRemote } from "./github-remote.js";
|
||||
|
||||
function createSshHostnameResolver(hostnameByAlias: Record<string, string | null>) {
|
||||
return async (host: string): Promise<string | null> => {
|
||||
return hostnameByAlias[host] ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseGitHubRemoteUrl", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["http://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["git@github.com:acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["ssh://git@github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
["ssh://git@ssh.github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }],
|
||||
])("parses direct GitHub remotes: %s", (remoteUrl, expected) => {
|
||||
expect(parseGitHubRemoteUrl(remoteUrl)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub remotes", () => {
|
||||
expect(parseGitHubRemoteUrl("git@gitlab.com:acme/repo.git")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for SSH aliases before hostname resolution", () => {
|
||||
expect(parseGitHubRemoteUrl("git@github-work:acme/repo.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitHubRemote", () => {
|
||||
it("resolves an SSH alias that maps to github.com", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "git@github-work:acme/repo.git",
|
||||
resolveSshHostname: createSshHostnameResolver({ "github-work": "github.com" }),
|
||||
}),
|
||||
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
|
||||
});
|
||||
|
||||
it("resolves a dotted SSH alias that maps to github.com", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "git@mindnexus.github.com:JakubMindNexus/postline.git",
|
||||
resolveSshHostname: createSshHostnameResolver({
|
||||
"mindnexus.github.com": "github.com",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
owner: "JakubMindNexus",
|
||||
name: "postline",
|
||||
repo: "JakubMindNexus/postline",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an SSH alias that maps to ssh.github.com", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "ssh://git@github-work/acme/repo.git",
|
||||
resolveSshHostname: createSshHostnameResolver({ "github-work": "ssh.github.com" }),
|
||||
}),
|
||||
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
|
||||
});
|
||||
|
||||
it("returns null when an SSH alias resolves to a non-GitHub host", async () => {
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "git@github-work:acme/repo.git",
|
||||
resolveSshHostname: createSshHostnameResolver({ "github-work": "gitlab.com" }),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not use SSH hostname resolution for HTTPS remotes", async () => {
|
||||
let resolverCalls = 0;
|
||||
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
resolveSshHostname: async () => {
|
||||
resolverCalls += 1;
|
||||
return "github.com";
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" });
|
||||
|
||||
expect(resolverCalls).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"git@-oProxyCommand=evil:acme/repo.git",
|
||||
"ssh://git@-oProxyCommand=evil/acme/repo.git",
|
||||
"git@host with space:acme/repo.git",
|
||||
])("rejects malformed hostnames without invoking the SSH resolver: %s", async (remoteUrl) => {
|
||||
let resolverCalls = 0;
|
||||
|
||||
await expect(
|
||||
resolveGitHubRemote({
|
||||
remoteUrl,
|
||||
resolveSshHostname: async (host) => {
|
||||
resolverCalls += 1;
|
||||
return host;
|
||||
},
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(resolverCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
+29
-32
@@ -1,41 +1,29 @@
|
||||
import {
|
||||
isGitHubHost,
|
||||
normalizeHost,
|
||||
parseGitHubRemoteIdentity,
|
||||
parseGitRemoteLocation,
|
||||
type GitHubRemoteIdentity as ResolvedGitHubRemoteIdentity,
|
||||
} from "@getpaseo/protocol/git-remote";
|
||||
import { normalizeHost } from "@getpaseo/protocol/git-remote";
|
||||
import { findExecutable } from "../executable-resolution/executable-resolution.js";
|
||||
import { execCommand } from "./spawn.js";
|
||||
|
||||
export type SshHostnameResolver = (host: string) => Promise<string | null>;
|
||||
|
||||
const SSH_HOSTNAME_RESOLVE_TIMEOUT_MS = 5_000;
|
||||
|
||||
let sshExecutableLookup: Promise<string | null> | null = null;
|
||||
const sshHostnameResolutionCache = new Map<string, Promise<string | null>>();
|
||||
|
||||
export type SshHostnameResolver = (host: string) => Promise<string | null>;
|
||||
|
||||
export { parseGitHubRemoteUrl, type GitHubRemoteIdentity } from "@getpaseo/protocol/git-remote";
|
||||
|
||||
export async function resolveGitHubRemote(input: {
|
||||
remoteUrl: string;
|
||||
resolveSshHostname?: SshHostnameResolver;
|
||||
}): Promise<ResolvedGitHubRemoteIdentity | null> {
|
||||
const location = parseGitRemoteLocation(input.remoteUrl);
|
||||
if (!location) return null;
|
||||
if (isGitHubHost(location.host)) return parseGitHubRemoteIdentity(location.path);
|
||||
if (location.transport !== "scp" && location.transport !== "ssh") return null;
|
||||
|
||||
const resolve = input.resolveSshHostname ?? resolveSshHostname;
|
||||
const resolvedHost = await resolve(location.host);
|
||||
if (!resolvedHost || !isGitHubHost(resolvedHost)) return null;
|
||||
return parseGitHubRemoteIdentity(location.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an SSH host alias (e.g. `github-work` from ~/.ssh/config) to its
|
||||
* configured HostName via `ssh -G`. Results are cached for the daemon's
|
||||
* lifetime; a host that isn't an alias resolves to itself.
|
||||
*/
|
||||
export async function resolveSshHostname(host: string): Promise<string | null> {
|
||||
const normalized = normalizeHost(host);
|
||||
if (!normalized) return null;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = sshHostnameResolutionCache.get(normalized);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const resolution = runSshHostnameLookup(normalized);
|
||||
sshHostnameResolutionCache.set(normalized, resolution);
|
||||
@@ -45,12 +33,15 @@ export async function resolveSshHostname(host: string): Promise<string | null> {
|
||||
async function runSshHostnameLookup(host: string): Promise<string | null> {
|
||||
sshExecutableLookup ??= findExecutable("ssh");
|
||||
const sshPath = await sshExecutableLookup;
|
||||
if (!sshPath) return null;
|
||||
if (!sshPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execCommand(sshPath, ["-G", host], {
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: SSH_HOSTNAME_RESOLVE_TIMEOUT_MS,
|
||||
});
|
||||
return parseSshHostname(stdout);
|
||||
} catch {
|
||||
@@ -61,11 +52,17 @@ async function runSshHostnameLookup(host: string): Promise<string | null> {
|
||||
function parseSshHostname(stdout: string): string | null {
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const [key, value] = trimmed.split(/\s+/u);
|
||||
if (key?.toLowerCase() !== "hostname") continue;
|
||||
if (key?.toLowerCase() !== "hostname") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeHost(value ?? "");
|
||||
if (normalized) return normalized;
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const ChangeRequestLookupTargetSchema = z.object({
|
||||
headRef: z.string().min(1),
|
||||
headRepositoryOwner: z.string().min(1).optional(),
|
||||
changeRequestNumber: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const PaseoWorktreeMetadataV1Schema = z.object({
|
||||
version: z.literal(1),
|
||||
baseRefName: z.string().min(1),
|
||||
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
|
||||
});
|
||||
|
||||
const PaseoWorktreeMetadataV2Schema = z.object({
|
||||
version: z.literal(2),
|
||||
baseRefName: z.string().min(1),
|
||||
changeRequestLookupTarget: ChangeRequestLookupTargetSchema.optional(),
|
||||
firstAgentBranchAutoName: z
|
||||
.discriminatedUnion("status", [
|
||||
z.object({
|
||||
@@ -36,6 +44,9 @@ const PaseoWorktreeMetadataSchema = z.union([
|
||||
]);
|
||||
|
||||
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
||||
export type PaseoWorktreeChangeRequestLookupTarget = z.infer<
|
||||
typeof ChangeRequestLookupTargetSchema
|
||||
>;
|
||||
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
const gitPath = join(worktreeRoot, ".git");
|
||||
@@ -77,7 +88,10 @@ export function normalizeBaseRefName(input: string): string {
|
||||
|
||||
export function writePaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { baseRefName: string },
|
||||
options: {
|
||||
baseRefName: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
||||
},
|
||||
): void {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
if (baseRefName === "HEAD") {
|
||||
@@ -90,10 +104,14 @@ export function writePaseoWorktreeMetadata(
|
||||
throw new Error(`Invalid base branch: ${baseRefName}`);
|
||||
}
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const metadata: PaseoWorktreeMetadata = { version: 1, baseRefName };
|
||||
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
const metadata: PaseoWorktreeMetadata = {
|
||||
version: 1,
|
||||
baseRefName,
|
||||
...(options.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: options.changeRequestLookupTarget }
|
||||
: {}),
|
||||
};
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, metadata);
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeRuntimeMetadata(
|
||||
@@ -109,11 +127,12 @@ export function writePaseoWorktreeRuntimeMetadata(
|
||||
throw new Error("Cannot persist worktree runtime metadata: missing base metadata");
|
||||
}
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
...(current.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
|
||||
: {}),
|
||||
...(current.version === 2 && current.firstAgentBranchAutoName
|
||||
? { firstAgentBranchAutoName: current.firstAgentBranchAutoName }
|
||||
: {}),
|
||||
@@ -121,7 +140,7 @@ export function writePaseoWorktreeRuntimeMetadata(
|
||||
worktreePort: options.worktreePort,
|
||||
},
|
||||
};
|
||||
writeFileSync(metadataPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, next);
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
@@ -141,6 +160,9 @@ export function writePaseoWorktreeFirstAgentBranchAutoNameMetadata(
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
...(current.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
|
||||
: {}),
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
placeholderBranchName,
|
||||
@@ -161,6 +183,9 @@ export function markPaseoWorktreeFirstAgentBranchAutoNameAttempted(
|
||||
const next: PaseoWorktreeMetadata = {
|
||||
version: 2,
|
||||
baseRefName: current.baseRefName,
|
||||
...(current.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: current.changeRequestLookupTarget }
|
||||
: {}),
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
placeholderBranchName: current.firstAgentBranchAutoName.placeholderBranchName,
|
||||
@@ -207,5 +232,7 @@ function writePaseoWorktreeMetadataFile(
|
||||
): void {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, metadataPath);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import {
|
||||
normalizeBaseRefName,
|
||||
type PaseoWorktreeChangeRequestLookupTarget,
|
||||
readPaseoWorktreeMetadata,
|
||||
readPaseoWorktreeRuntimePort,
|
||||
writePaseoWorktreeMetadata,
|
||||
@@ -159,14 +160,33 @@ export interface WorktreeRootOptions {
|
||||
worktreesRoot?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeCheckoutRef {
|
||||
remoteName?: string;
|
||||
remoteRef: string;
|
||||
}
|
||||
|
||||
export type WorktreeSource =
|
||||
| { kind: "branch-off"; baseBranch: string; branchName: string }
|
||||
| { kind: "checkout-branch"; branchName: string }
|
||||
| {
|
||||
kind: "checkout-change-request";
|
||||
forge: string;
|
||||
changeRequestNumber: number;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
baseRefName: string;
|
||||
checkoutRefs?: WorktreeCheckoutRef[];
|
||||
localBranchName?: string;
|
||||
pushRemoteUrl?: string;
|
||||
trackOriginHead?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "checkout-github-pr";
|
||||
githubPrNumber: number;
|
||||
headRef: string;
|
||||
headRepositoryOwner?: string;
|
||||
baseRefName: string;
|
||||
checkoutRefs?: WorktreeCheckoutRef[];
|
||||
localBranchName?: string;
|
||||
pushRemoteUrl?: string;
|
||||
trackOriginHead?: boolean;
|
||||
@@ -1216,7 +1236,12 @@ export const createWorktree = async ({
|
||||
});
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: sourcePlan.metadataBaseRefName });
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// If paseo.json exists in the main repo but wasn't checked into the worktree
|
||||
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
|
||||
@@ -1254,6 +1279,7 @@ interface ResolveWorktreeSourcePlanOptions {
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
metadataBaseRefName: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
||||
addArguments: string[];
|
||||
pushRemote?: {
|
||||
name: string;
|
||||
@@ -1311,24 +1337,23 @@ async function resolveWorktreeSourcePlan({
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
case "checkout-change-request":
|
||||
case "checkout-github-pr": {
|
||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
await runGitCommand(
|
||||
[
|
||||
"fetch",
|
||||
"origin",
|
||||
`refs/pull/${source.githubPrNumber}/head:refs/heads/${localBranchName}`,
|
||||
"--force",
|
||||
const changeRequestNumber =
|
||||
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
|
||||
await fetchWorktreeCheckoutRefs({
|
||||
cwd,
|
||||
localBranchName,
|
||||
checkoutRefs: source.checkoutRefs ?? [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
const trackingRemote = source.trackOriginHead
|
||||
});
|
||||
const shouldTrackOriginHead = source.trackOriginHead === true;
|
||||
const trackingRemote = shouldTrackOriginHead
|
||||
? await tryFetchWorktreeTrackingRemote({
|
||||
cwd,
|
||||
remoteName: "origin",
|
||||
@@ -1337,18 +1362,18 @@ async function resolveWorktreeSourcePlan({
|
||||
: undefined;
|
||||
const remotePlan: Pick<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
|
||||
if (source.pushRemoteUrl) {
|
||||
const remoteName = `paseo-pr-${source.githubPrNumber}`;
|
||||
const remoteName = `paseo-pr-${changeRequestNumber}`;
|
||||
remotePlan.pushRemote = {
|
||||
name: remoteName,
|
||||
url: source.pushRemoteUrl,
|
||||
headRef: source.headRef,
|
||||
track: true,
|
||||
};
|
||||
} else if (source.trackOriginHead && localBranchName !== source.headRef) {
|
||||
} else if (shouldTrackOriginHead && localBranchName !== source.headRef) {
|
||||
const originUrl = await getWorktreeRemotePushUrl(cwd, "origin");
|
||||
if (originUrl) {
|
||||
remotePlan.pushRemote = {
|
||||
name: `paseo-pr-${source.githubPrNumber}`,
|
||||
name: `paseo-pr-${changeRequestNumber}`,
|
||||
url: originUrl,
|
||||
headRef: source.headRef,
|
||||
track: false,
|
||||
@@ -1362,6 +1387,13 @@ async function resolveWorktreeSourcePlan({
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: {
|
||||
headRef: source.headRef,
|
||||
...(source.headRepositoryOwner
|
||||
? { headRepositoryOwner: source.headRepositoryOwner }
|
||||
: {}),
|
||||
changeRequestNumber,
|
||||
},
|
||||
addArguments: [localBranchName],
|
||||
...remotePlan,
|
||||
};
|
||||
@@ -1414,6 +1446,41 @@ async function configureWorktreePushRemote(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorktreeCheckoutRefs(options: {
|
||||
cwd: string;
|
||||
localBranchName: string;
|
||||
checkoutRefs: WorktreeCheckoutRef[];
|
||||
}): Promise<void> {
|
||||
let lastResult:
|
||||
| Awaited<ReturnType<typeof runGitCommand>>
|
||||
| { stderr: string; stdout: string; exitCode: number | null }
|
||||
| null = null;
|
||||
for (const checkoutRef of options.checkoutRefs) {
|
||||
lastResult = await runGitCommand(
|
||||
[
|
||||
"fetch",
|
||||
checkoutRef.remoteName ?? "origin",
|
||||
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
|
||||
"--force",
|
||||
],
|
||||
{
|
||||
cwd: options.cwd,
|
||||
timeout: 120_000,
|
||||
acceptExitCodes: [0, 1, 128],
|
||||
},
|
||||
);
|
||||
if (lastResult.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const attemptedRefs = options.checkoutRefs
|
||||
.map((checkoutRef) => `${checkoutRef.remoteName ?? "origin"} ${checkoutRef.remoteRef}`)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Unable to fetch change request refs for worktree branch ${options.localBranchName}: ${attemptedRefs}${lastResult?.stderr ? `\n${lastResult.stderr}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function tryFetchWorktreeTrackingRemote(options: {
|
||||
cwd: string;
|
||||
remoteName: string;
|
||||
|
||||
Reference in New Issue
Block a user