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>
This commit is contained in:
Alexander Adam
2026-10-13 22:26:58 +02:00
committed by paseo-bot[bot]
parent 1be237d353
commit 1947acf276
11 changed files with 46 additions and 44 deletions
@@ -187,7 +187,7 @@ describe("resolveWorktreeCreationIntent", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
hasOriginHeadBranch: false,
headRefKind: "pull-ref",
});
const intent = await resolveWorktreeCreationIntent(
@@ -351,8 +351,7 @@ describe("resolveWorktreeCreationIntent", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
hasOriginHeadBranch: false,
hasHeadBranch: false,
headRefKind: "pull-ref",
}),
},
});
@@ -386,7 +385,7 @@ describe("resolveWorktreeCreationIntent", () => {
headRepositorySshUrl: "git@gitea.test:contributor/repo.git",
headRepositoryUrl: "https://gitea.test/contributor/repo",
isCrossRepository: true,
hasHeadBranch: false,
headRefKind: "pull-ref",
}),
},
});
@@ -419,7 +418,7 @@ describe("resolveWorktreeCreationIntent", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
hasHeadBranch: false,
headRefKind: "pull-ref",
}),
},
});
@@ -192,7 +192,7 @@ async function resolvePrCheckoutIntent(
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
const trackOriginHead =
!checkoutTarget.isCrossRepository && checkoutTarget.hasOriginHeadBranch !== false;
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
return {
kind: "checkout-change-request",
@@ -39,10 +39,8 @@ export interface PullRequestCheckoutTarget {
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
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;
/** "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 {
@@ -2303,8 +2303,7 @@ describe("createGiteaService", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
hasOriginHeadBranch: true,
hasHeadBranch: true,
headRefKind: "branch",
});
});
@@ -2341,8 +2340,7 @@ describe("createGiteaService", () => {
headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git",
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
isCrossRepository: true,
hasOriginHeadBranch: false,
hasHeadBranch: true,
headRefKind: "branch",
});
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
@@ -2369,8 +2367,7 @@ describe("createGiteaService", () => {
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
isCrossRepository: false,
hasOriginHeadBranch: false,
hasHeadBranch: false,
headRefKind: "pull-ref",
});
});
@@ -2418,8 +2415,7 @@ describe("createGiteaService", () => {
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
isCrossRepository: false,
hasOriginHeadBranch: false,
hasHeadBranch: false,
headRefKind: "pull-ref",
});
});
@@ -2439,8 +2435,7 @@ describe("createGiteaService", () => {
],
headOwnerLogin: null,
isCrossRepository: true,
hasOriginHeadBranch: false,
hasHeadBranch: true,
headRefKind: "branch",
});
});
@@ -2457,7 +2452,7 @@ describe("createGiteaService", () => {
remoteName: "origin",
remoteRef: "refs/heads/feat/sample-change",
});
expect(target.hasOriginHeadBranch).toBe(true);
expect(target.headRefKind).toBe("branch");
});
it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => {
@@ -2492,7 +2487,7 @@ describe("createGiteaService", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
hasHeadBranch: false,
headRefKind: "pull-ref",
},
});
@@ -2512,7 +2507,7 @@ describe("createGiteaService", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
hasHeadBranch: false,
headRefKind: "pull-ref",
},
});
@@ -2532,7 +2527,7 @@ describe("createGiteaService", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
hasHeadBranch: false,
headRefKind: "pull-ref",
},
});
+18 -12
View File
@@ -1445,19 +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;
hasHeadBranch: boolean;
headRefKind: "branch" | "pull-ref";
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
}> {
const sameRepo = {
isCrossRepository: false,
hasHeadBranch: true,
headRefKind: "branch" as const,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
@@ -1476,18 +1487,14 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
);
const headRepo = pr.head?.repo ?? null;
const baseRepo = pr.base?.repo ?? null;
// head.ref only becomes the branch name when the branch still exists,
// agit PRs and deleted branches keep the pull ref
const hasHeadBranch =
pr.flow !== GITEA_PULL_REQUEST_FLOW_AGIT &&
pr.head?.ref !== `refs/pull/${summary.number}/head`;
const headRefKind = resolveHeadRefKind(pr, summary.number);
if (baseRepo?.id == null || headRepo?.id === baseRepo.id) {
return { ...sameRepo, hasHeadBranch };
return { ...sameRepo, headRefKind };
}
// headRepo is null when the fork was deleted
return {
isCrossRepository: true,
hasHeadBranch,
headRefKind,
headOwnerLogin: headRepo?.owner?.login ?? null,
headRepositorySshUrl: headRepo?.ssh_url ?? null,
headRepositoryUrl: headRepo?.html_url ?? null,
@@ -2033,7 +2040,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
const checkoutRefs = [originPullRef];
// for a fork PR a branch with that name on origin belongs to the base repo
const headBranchOnOrigin =
!fork.isCrossRepository && fork.hasHeadBranch && Boolean(summary.headRefName);
!fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName);
if (headBranchOnOrigin) {
checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` });
}
@@ -2048,8 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
headRepositorySshUrl: fork.headRepositorySshUrl,
headRepositoryUrl: fork.headRepositoryUrl,
isCrossRepository: fork.isCrossRepository,
hasOriginHeadBranch: headBranchOnOrigin,
hasHeadBranch: fork.hasHeadBranch,
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);
@@ -3522,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",
};
},
@@ -70,20 +70,20 @@ describe("buildForkLocalBranchName", () => {
...fork,
isCrossRepository: false,
headOwnerLogin: "arthur",
hasHeadBranch: false,
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", hasHeadBranch: false }),
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, hasHeadBranch: false })).toBe(
"pr-42",
);
expect(
buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }),
).toBe("pr-42");
});
});
@@ -26,15 +26,15 @@ export interface ForkLocalBranchNameInput {
number: number;
isCrossRepository: boolean;
headOwnerLogin: string | null;
/** false when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches) */
hasHeadBranch?: boolean;
/** "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.hasHeadBranch === false ? pullRefFallback : params.headRef;
const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef;
if (!params.isCrossRepository) {
return headSegment;
}