Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 4d91066ad7 test(worktrees): make fork fixture line endings deterministic
Windows Git converts the fixture checkout to CRLF when core.autocrlf is enabled. Set the repository fixture to preserve its written LF bytes so the content assertion checks the fetched patch on every platform. Reproduced the failure locally with inherited core.autocrlf=true and verified the corrected setup against that same configuration.
2026-10-13 23:00:07 +02:00
paseo-bot[bot] 6136647791 fix(worktrees): avoid branch namespace conflicts in fork checkouts
Fork owner prefixes can name an existing branch as a parent, which Git rejects even when the full proposed name is unused. Allocate names against parent and child refs, suffixing the conflicting segment while preserving existing branches and fetched commits.
2026-10-13 22:37:37 +02:00
Alexander Adam 1947acf276 refactor(forge): one head ref kind on the checkout target
hasOriginHeadBranch and hasHeadBranch could describe contradictory
states (cross-repo checkout claiming an origin head branch). collapse
both into headRefKind: "branch" | "pull-ref" and derive origin
tracking in the resolver instead of storing it separately.

Co-authored-by: Alexander Adam <alexander.adam@vade.io>
2026-10-13 22:26:58 +02:00
Alexander Adam 1be237d353 fix(gitea): surface hasHeadBranch on the pr checkout target
resolveForkCheckoutFacts already knew whether a pr had a real head
branch (agit flow, or head.ref == the pull ref both mean no). now that
signal reaches buildPrLocalBranchName so worktree names and local
branches stop being named after the raw pull ref.

covers same-repo, fork with owner known, and deleted fork all with no
real head branch. added an end to end gitea-service test plus resolver
cases since the worktree slug follows localBranchName too.
2026-10-13 22:23:24 +02:00
Alexander Adam 1dbb838af1 fix(gitea): collapse pull-ref-only heads to pr-N in local branch names
buildForkLocalBranchName still used the raw headRef even when it was
just the pull ref (refs/pull/N/head), which agit prs and deleted head
branches report as their headRefName. that gave local branches literal
names like refs/heads/refs/pull/3/head.
2026-10-13 22:23:24 +02:00
Alexander Adam 52bf490fbf test: resolver harness uses the real branch helper
plus deleted forks and same repo PRs without an origin branch
2026-10-13 22:23:24 +02:00
Alexander Adam c77b046337 fix(gitea): handle AGit PRs and deleted head branches
neither has a branch on origin. forgejo reports refs/pull/N/head as
head.ref for both, so skip the refs/heads fallback and dont track
origin.

also try refs/pull/N/head on upstream for clones where origin is your
fork, like github does. that list lives in the shared util now.
2026-10-13 22:23:24 +02:00
Alexander Adam c958166402 fix(gitea): PRs from a deleted fork are still cross repo
the API drops head.repo once the fork is gone, we read that as a same
repo PR and fetched refs/heads/<name> from origin
2026-10-13 22:23:24 +02:00
Alexander Adam ef6a024513 refactor(forge): share fork branch naming with github
moved the owner normalizer and the branch name builder to
utils/change-request-checkout.ts, gitea and github both use it.

github changes a bit: a fork PR with an unknown or unusable owner (eg
deleted fork) now gets pr-N/<branch> instead of the bare branch name,
which could collide with a local branch the same way #3515 did.
2026-10-13 22:23:24 +02:00
Alexander Adam 3534c50da4 fix(gitea): prefix fork PR branches with the owner
Two forks with the same branch name (say patch-1) ended up on the same
local branch. The gitea service had no buildPrLocalBranchName or
defaultCheckoutRefs, github has both.

getPullRequestCheckoutTarget also fell back to refs/heads/<name> on
origin for fork PRs. Thats the base repo's branch, not the fork's, so
a failed refs/pull fetch checked out the wrong branch without an error.

fixes #3515
2026-10-13 22:23:24 +02:00
13 changed files with 633 additions and 43 deletions
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import type { ForgeService } from "../services/forge-service.js";
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
import {
CheckoutSourceForgeMismatchError,
MissingCheckoutTargetError,
@@ -32,14 +33,8 @@ function createResolverHarness(overrides?: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized)
? normalized
: null;
return owner ? `${owner}/${headRef}` : headRef;
},
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
}
: {};
@@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("does not track origin for a same-repo head with no origin branch", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "ford/towel",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("configures the contributor remote for fork PR targets", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
@@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("prefixes the pr number when the fork was deleted", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "towel",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" });
expect(intent).not.toHaveProperty("pushRemoteUrl");
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("uses an explicit PR head ref without calling GitHub", async () => {
const deps = createResolverHarness();
@@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("names the local branch pr-<number> for a gitea pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "pr-3" });
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("names the local branch owner/pr-<number> for a gitea fork pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: "contributor",
headRepositorySshUrl: "git@gitea.test:contributor/repo.git",
headRepositoryUrl: "https://gitea.test/contributor/repo",
isCrossRepository: true,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" });
});
test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "pr-3" });
});
test("reports unsupported cross-repository checkout targets without a checkout ref", async () => {
const deps = createResolverHarness({
forge: "gitea",
@@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent(
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
const trackOriginHead = !checkoutTarget.isCrossRepository;
const trackOriginHead =
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
return {
kind: "checkout-change-request",
@@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget {
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
/** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
headRefKind?: "branch" | "pull-ref";
}
export interface PullRequestCheckoutRef {
@@ -441,9 +443,9 @@ export interface ForgeService {
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
/**
* Refs to fetch for a change-request checkout when the resolved checkout
* target carries none. Adapters that expose a universal change-request head
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
* back to the head branch.
* target carries none. Adapters with a universal change-request head ref
* (refs/pull/N/head) return it here; others let the shell fall back to the
* head branch.
*/
defaultCheckoutRefs?(params: {
changeRequestNumber: number;
@@ -451,7 +453,7 @@ export interface ForgeService {
}): PullRequestCheckoutRef[];
/**
* Local branch name for a checked-out change request when the adapter
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
* disambiguates cross-repository heads (prefixes the fork owner).
* Returns undefined to keep the head ref name as-is.
*/
buildPrLocalBranchName?(params: {
@@ -2297,11 +2297,13 @@ describe("createGiteaService", () => {
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "branch",
});
});
@@ -2332,17 +2334,215 @@ describe("createGiteaService", () => {
headRefName: "feat/sample-change",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
headOwnerLogin: "contributor",
headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git",
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
isCrossRepository: true,
headRefKind: "branch",
});
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
});
it("skips the origin branch fallback and tracking for an AGit pull request", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
flow: 1,
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target).toMatchObject({
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
isCrossRepository: false,
headRefKind: "pull-ref",
});
});
it("names the local branch pr-<number> end to end for an agit pull request", async () => {
// the tea CLI reports head as the raw pull ref too when there's no real
// branch, same as the Forgejo API's head.ref
const { service } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
flow: 1,
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify({ ...STATUS_PR_VIEW, head: "refs/pull/5/head" })),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
const localBranchName = service.buildPrLocalBranchName?.({
headRef: target.headRefName,
checkoutTarget: target,
});
expect(localBranchName).toBe("pr-5");
});
it("skips the origin branch fallback when the head branch was deleted", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target).toMatchObject({
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
isCrossRepository: false,
headRefKind: "pull-ref",
});
});
it("keeps a pull request from a deleted fork cross-repository", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(JSON.stringify({ head: { repo: null }, base: { repo: { id: 1 } } }))
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target).toMatchObject({
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
headOwnerLogin: null,
isCrossRepository: true,
headRefKind: "branch",
});
});
it("keeps the origin branch fallback when the server sends no head ref", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } }))
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target.checkoutRefs).toContainEqual({
remoteName: "origin",
remoteRef: "refs/heads/feat/sample-change",
});
expect(target.headRefKind).toBe("branch");
});
it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "patch-1",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "patch-1",
headOwnerLogin: "contributor",
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
},
});
expect(localBranchName).toBe("contributor/patch-1");
});
it("names the local branch pr-<number> for a same-repo pull-ref-only head", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "refs/pull/5/head",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "refs/pull/5/head",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
},
});
expect(localBranchName).toBe("pr-5");
});
it("prefixes pr-<number> with the fork owner for a pull-ref-only head", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "refs/pull/5/head",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "refs/pull/5/head",
headOwnerLogin: "contributor",
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
},
});
expect(localBranchName).toBe("contributor/pr-5");
});
it("does not double the pr number for a deleted fork's pull-ref-only head", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "refs/pull/5/head",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "refs/pull/5/head",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
},
});
expect(localBranchName).toBe("pr-5");
});
it("returns the pull head ref for default checkout refs", () => {
const { service } = makeService(() => ok(""));
expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
]);
});
it("creates a pull request and parses the resulting URL and index", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "create") {
+50 -11
View File
@@ -5,6 +5,10 @@ import pLimit from "p-limit";
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { runGitCommand } from "../utils/run-git-command.js";
import {
buildForkLocalBranchName,
buildPullHeadCheckoutRefs,
} from "../utils/change-request-checkout.js";
import { execCommand } from "../utils/spawn.js";
import {
createCachedCliPathResolver,
@@ -75,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000;
// the sweep to the 250 most recent Actions tasks.
const GITEA_ACTION_PAGE_LIMIT = 50;
const GITEA_ACTION_MAX_PAGES = 5;
// PullRequestFlowAGit in forgejo models/issues/pull.go
const GITEA_PULL_REQUEST_FLOW_AGIT = 1;
/**
* Fields requested from `tea pr list -o json`. tea's default field set omits the
@@ -225,8 +231,12 @@ const GiteaPullRequestRepoSchema = z
const GiteaPullRequestApiSchema = z
.object({
flow: z.number().optional(),
head: z
.object({ repo: GiteaPullRequestRepoSchema.nullable().optional() })
.object({
ref: z.string().optional(),
repo: GiteaPullRequestRepoSchema.nullable().optional(),
})
.passthrough()
.optional(),
base: z
@@ -1435,17 +1445,30 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
return runJsonParse(args, runOptions, stdout, schema);
}
function resolveHeadRefKind(
pr: z.infer<typeof GiteaPullRequestApiSchema>,
number: number,
): "branch" | "pull-ref" {
// head.ref only becomes the branch name when the branch still exists,
// agit PRs and deleted branches keep the pull ref
const isPullRefOnly =
pr.flow === GITEA_PULL_REQUEST_FLOW_AGIT || pr.head?.ref === `refs/pull/${number}/head`;
return isPullRefOnly ? "pull-ref" : "branch";
}
async function resolveForkCheckoutFacts(
cwd: string,
summary: PullRequestSummary,
): Promise<{
isCrossRepository: boolean;
headRefKind: "branch" | "pull-ref";
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
}> {
const sameRepo = {
isCrossRepository: false,
headRefKind: "branch" as const,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
@@ -1464,14 +1487,17 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
);
const headRepo = pr.head?.repo ?? null;
const baseRepo = pr.base?.repo ?? null;
if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) {
return sameRepo;
const headRefKind = resolveHeadRefKind(pr, summary.number);
if (baseRepo?.id == null || headRepo?.id === baseRepo.id) {
return { ...sameRepo, headRefKind };
}
// headRepo is null when the fork was deleted
return {
isCrossRepository: true,
headOwnerLogin: headRepo.owner?.login ?? null,
headRepositorySshUrl: headRepo.ssh_url ?? null,
headRepositoryUrl: headRepo.html_url ?? null,
headRefKind,
headOwnerLogin: headRepo?.owner?.login ?? null,
headRepositorySshUrl: headRepo?.ssh_url ?? null,
headRepositoryUrl: headRepo?.html_url ?? null,
};
}
@@ -1997,17 +2023,29 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
return summary.headRefName;
},
defaultCheckoutRefs({ changeRequestNumber }) {
return buildPullHeadCheckoutRefs(changeRequestNumber);
},
buildPrLocalBranchName({ headRef, checkoutTarget }) {
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
},
async getPullRequestCheckoutTarget(
input: GetPullRequestOptions,
): Promise<PullRequestCheckoutTarget> {
const summary = await this.getPullRequest(input);
const checkoutRefs = [
{ remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` },
];
if (summary.headRefName) {
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number);
const checkoutRefs = [originPullRef];
// for a fork PR a branch with that name on origin belongs to the base repo
const headBranchOnOrigin =
!fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName);
if (headBranchOnOrigin) {
checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` });
}
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
// upstream can hold a different PR with the same number, so it goes last
checkoutRefs.push(upstreamPullRef);
return {
number: summary.number,
baseRefName: summary.baseRefName,
@@ -2017,6 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
headRepositorySshUrl: fork.headRepositorySshUrl,
headRepositoryUrl: fork.headRepositoryUrl,
isCrossRepository: fork.isCrossRepository,
headRefKind: fork.headRefKind,
};
},
@@ -797,6 +797,7 @@ describe("ForgeService", () => {
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
headRefKind: "branch",
});
expect(runner.calls).toHaveLength(2);
+7 -13
View File
@@ -6,6 +6,10 @@ import {
} from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { runGitCommand } from "../utils/run-git-command.js";
import {
buildForkLocalBranchName,
buildPullHeadCheckoutRefs,
} from "../utils/change-request-checkout.js";
import { execCommand } from "../utils/spawn.js";
import { resolveSshHostname } from "../utils/ssh-hostname.js";
import {
@@ -2104,17 +2108,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
},
defaultCheckoutRefs({ changeRequestNumber }) {
return [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
];
return buildPullHeadCheckoutRefs(changeRequestNumber);
},
buildPrLocalBranchName({ headRef, checkoutTarget }) {
const owner = checkoutTarget.isCrossRepository
? normalizeGitHubOwnerForBranch(checkoutTarget.headOwnerLogin)
: null;
return owner ? `${owner}/${headRef}` : headRef;
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
},
supportsCrossRepoCheckoutWithoutRefs: true,
@@ -2663,11 +2661,6 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return api;
}
function normalizeGitHubOwnerForBranch(owner: string | null): string | null {
const normalized = owner?.trim().toLowerCase() ?? "";
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
}
function getGithubStatusFacts(
status: PullRequestCommandStatus | null | undefined,
): GitHubPullRequestStatusFacts | null {
@@ -3529,6 +3522,7 @@ function toPullRequestCheckoutTarget(
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
headRepositoryUrl: pullRequest.headRepository?.url || null,
isCrossRepository: pullRequest.isCrossRepository,
headRefKind: "branch",
};
}
@@ -477,6 +477,7 @@ describe("createGitLabService", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "branch",
});
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
});
@@ -1204,6 +1204,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id,
headRefKind: "branch",
};
},
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
buildForkLocalBranchName,
normalizeForgeOwnerForBranch,
} from "./change-request-checkout.js";
describe("normalizeForgeOwnerForBranch", () => {
it("lowercases a valid login", () => {
expect(normalizeForgeOwnerForBranch("Contributor")).toBe("contributor");
});
it("allows dots and underscores", () => {
expect(normalizeForgeOwnerForBranch("a.b_c")).toBe("a.b_c");
});
it("rejects a login starting with a dash", () => {
expect(normalizeForgeOwnerForBranch("-abc")).toBeNull();
});
it("rejects a login starting with a dot", () => {
expect(normalizeForgeOwnerForBranch(".hidden")).toBeNull();
});
it("rejects a login containing '..'", () => {
expect(normalizeForgeOwnerForBranch("a..b")).toBeNull();
});
it("rejects a login ending in .lock", () => {
expect(normalizeForgeOwnerForBranch("contributor.lock")).toBeNull();
});
it("rejects a login with a path traversal segment", () => {
expect(normalizeForgeOwnerForBranch("../evil")).toBeNull();
});
it("rejects an empty or null login", () => {
expect(normalizeForgeOwnerForBranch(null)).toBeNull();
expect(normalizeForgeOwnerForBranch("")).toBeNull();
expect(normalizeForgeOwnerForBranch(" ")).toBeNull();
});
});
describe("buildForkLocalBranchName", () => {
const fork = { headRef: "patch-1", number: 42, isCrossRepository: true };
it("keeps the head ref for a same-repository head", () => {
expect(
buildForkLocalBranchName({ ...fork, isCrossRepository: false, headOwnerLogin: "arthur" }),
).toBe("patch-1");
});
it("prefixes a fork head with the lowercased owner", () => {
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur" })).toBe("arthur/patch-1");
});
it("falls back to the pr number when the owner is unknown", () => {
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: null })).toBe("pr-42/patch-1");
});
it("falls back to the pr number when the owner is not a safe ref segment", () => {
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "-zaphod" })).toBe("pr-42/patch-1");
});
// pull-ref-only heads (agit PRs, deleted branches) report headRef as the
// literal pull ref, e.g. refs/pull/42/head, not a real branch name
it("collapses a same-repository pull-ref-only head to pr-<number>", () => {
expect(
buildForkLocalBranchName({
...fork,
isCrossRepository: false,
headOwnerLogin: "arthur",
headRefKind: "pull-ref",
}),
).toBe("pr-42");
});
it("prefixes pr-<number> with the owner for a fork pull-ref-only head", () => {
expect(
buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur", headRefKind: "pull-ref" }),
).toBe("arthur/pr-42");
});
it("does not double the pr number when the fork owner is unknown and there is no head branch", () => {
expect(
buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }),
).toBe("pr-42");
});
});
@@ -0,0 +1,47 @@
import type { PullRequestCheckoutRef } from "../services/forge-service.js";
// upstream for clones where origin is your fork
export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] {
return [
{ remoteName: "origin", remoteRef: `refs/pull/${number}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${number}/head` },
];
}
// forgejo username pattern, covers github logins too
export function normalizeForgeOwnerForBranch(owner: string | null): string | null {
const normalized = owner?.trim().toLowerCase() ?? "";
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(normalized)) {
return null;
}
// git refuses .. and .lock, trailing dot too
if (normalized.includes("..") || normalized.endsWith(".") || normalized.endsWith(".lock")) {
return null;
}
return normalized;
}
export interface ForkLocalBranchNameInput {
headRef: string;
number: number;
isCrossRepository: boolean;
headOwnerLogin: string | null;
/** "pull-ref" when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
headRefKind?: "branch" | "pull-ref";
}
export function buildForkLocalBranchName(params: ForkLocalBranchNameInput): string {
const pullRefFallback = `pr-${params.number}`;
// headRef is the literal pull ref (e.g. refs/pull/42/head) when there is no
// real branch to name after, so use the pr number instead
const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef;
if (!params.isCrossRepository) {
return headSegment;
}
const owner = normalizeForgeOwnerForBranch(params.headOwnerLogin);
if (!owner) {
// don't double up pr-<n>/pr-<n> when the owner is also unknown
return headSegment === pullRefFallback ? headSegment : `${pullRefFallback}/${headSegment}`;
}
return `${owner}/${headSegment}`;
}
@@ -15,6 +15,7 @@ import {
mkdirSync,
rmSync,
existsSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
@@ -93,6 +94,64 @@ describe("paseo worktree manager", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it.each([
{ existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" },
{ existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" },
{
existing: ["main-1/patch-1"],
preferred: "main/patch-1",
expected: "main-1/patch-1-2",
},
{
existing: ["bob/patch-1/child"],
preferred: "bob/patch-1",
expected: "bob/patch-1-1",
},
])(
"checks out a fork when $preferred conflicts with existing branch paths",
async ({ existing, preferred, expected }) => {
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim();
git("config", "core.autocrlf", "false");
const baseHead = git("rev-parse", "main");
for (const branch of existing) git("branch", branch);
git("checkout", "-b", "fixture-pr");
writeFileSync(join(repoDir, "file.txt"), "from-pr\n");
git("add", "file.txt");
git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch");
const prHead = git("rev-parse", "HEAD");
git("checkout", "main");
const remote = join(tempDir, "origin.git");
git("clone", "--bare", repoDir, remote);
git("remote", "add", "origin", remote);
git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead);
git("pack-refs", "--all");
const result = await createWorktreePrimitive({
cwd: repoDir,
paseoHome,
worktreeSlug: "fork-review",
runSetup: false,
source: {
kind: "checkout-change-request",
forge: "gitea",
changeRequestNumber: 1,
headRef: "patch-1",
localBranchName: preferred,
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }],
},
});
expect(result.branchName).toBe(expected);
expect(git("rev-parse", result.branchName)).toBe(prHead);
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
for (const branch of ["main", ...existing]) {
expect(git("rev-parse", branch)).toBe(baseHead);
}
},
);
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "orphan-admin-branch",
+22 -5
View File
@@ -1778,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise<boole
}
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
let newBranchName = candidateBranch;
const { stdout } = await runGitCommand(
["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"],
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean));
const candidateSegments = candidateBranch.split("/");
const segments = [...candidateSegments];
let suffix = 1;
while (await localBranchExists(cwd, newBranchName)) {
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
while (true) {
const branchName = segments.join("/");
const parentConflict = segments.findIndex((_, index) =>
existingBranches.has(segments.slice(0, index + 1).join("/")),
);
const childConflict = [...existingBranches].some((branch) =>
branch.startsWith(`${branchName}/`),
);
if (parentConflict === -1 && !childConflict) {
return branchName;
}
// Git refs form a namespace: a branch cannot also be another branch's
// parent directory. Rename the conflicting segment, preserving the rest.
const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict;
segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`;
}
return newBranchName;
}
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {