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.
This commit is contained in:
Alexander Adam
2026-10-13 22:23:24 +02:00
committed by paseo-bot[bot]
parent 1dbb838af1
commit 1be237d353
4 changed files with 192 additions and 0 deletions
@@ -333,6 +333,106 @@ 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,
hasOriginHeadBranch: false,
hasHeadBranch: false,
}),
},
});
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,
hasHeadBranch: false,
}),
},
});
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,
hasHeadBranch: false,
}),
},
});
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",
@@ -41,6 +41,8 @@ export interface PullRequestCheckoutTarget {
isCrossRepository: boolean;
/** false when origin has no head branch to track */
hasOriginHeadBranch?: boolean;
/** false when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches) */
hasHeadBranch?: boolean;
}
export interface PullRequestCheckoutRef {
@@ -2304,6 +2304,7 @@ describe("createGiteaService", () => {
headRepositoryUrl: null,
isCrossRepository: false,
hasOriginHeadBranch: true,
hasHeadBranch: true,
});
});
@@ -2341,6 +2342,7 @@ describe("createGiteaService", () => {
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
isCrossRepository: true,
hasOriginHeadBranch: false,
hasHeadBranch: true,
});
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
@@ -2368,9 +2370,34 @@ describe("createGiteaService", () => {
],
isCrossRepository: false,
hasOriginHeadBranch: false,
hasHeadBranch: false,
});
});
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"
@@ -2392,6 +2419,7 @@ describe("createGiteaService", () => {
],
isCrossRepository: false,
hasOriginHeadBranch: false,
hasHeadBranch: false,
});
});
@@ -2412,6 +2440,7 @@ describe("createGiteaService", () => {
headOwnerLogin: null,
isCrossRepository: true,
hasOriginHeadBranch: false,
hasHeadBranch: true,
});
});
@@ -2450,6 +2479,66 @@ describe("createGiteaService", () => {
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,
hasHeadBranch: false,
},
});
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,
hasHeadBranch: false,
},
});
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,
hasHeadBranch: false,
},
});
expect(localBranchName).toBe("pr-5");
});
it("returns the pull head ref for default checkout refs", () => {
const { service } = makeService(() => ok(""));
@@ -2049,6 +2049,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
headRepositoryUrl: fork.headRepositoryUrl,
isCrossRepository: fork.isCrossRepository,
hasOriginHeadBranch: headBranchOnOrigin,
hasHeadBranch: fork.hasHeadBranch,
};
},