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:
nllptrx
2026-07-17 15:03:26 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 04d1ebdce0
commit a8ebd390fa
187 changed files with 20988 additions and 2907 deletions
+111 -11
View File
@@ -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"], {
+116 -59
View File
@@ -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);
});
});
@@ -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;
}
+37 -10
View File
@@ -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);
}
+83 -16
View File
@@ -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;