From 1be237d35354556784fd7bb13580478f6979f634 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Fri, 25 Sep 2026 10:41:05 +0200 Subject: [PATCH] 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. --- .../resolve-worktree-creation-intent.test.ts | 100 ++++++++++++++++++ packages/server/src/services/forge-service.ts | 2 + .../server/src/services/gitea-service.test.ts | 89 ++++++++++++++++ packages/server/src/services/gitea-service.ts | 1 + 4 files changed, 192 insertions(+) diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index eef623d223..ff0c9ddb0b 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -333,6 +333,106 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("names the local branch pr- 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- 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", diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index 6f48a7b767..be12acdef5 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -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 { diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 80cec4c663..94edc2f970 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -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- 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- 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- 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("")); diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 5c1a431603..80a9228ac4 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -2049,6 +2049,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For headRepositoryUrl: fork.headRepositoryUrl, isCrossRepository: fork.isCrossRepository, hasOriginHeadBranch: headBranchOnOrigin, + hasHeadBranch: fork.hasHeadBranch, }; },