Keep fork PR badges visible in worktrees

This commit is contained in:
Mohamed Boudra
2026-06-02 15:38:27 +07:00
parent f1a20a9fe9
commit 07ace19b69
4 changed files with 151 additions and 15 deletions
@@ -879,6 +879,53 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github.dispose?.();
});
test("GitHub self-heal polling uses the fork PR head branch instead of the owner-prefixed local branch", async () => {
const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() }));
const github = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll,
};
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
createCheckoutFacts(cwd, {
currentBranch: "fork-owner/open-button-targets-active-file",
branchRemoteName: "paseo-pr-1285",
branchMergeRef: "refs/heads/open-button-targets-active-file",
trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file",
pullRequestLookupTarget: {
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
},
}),
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, {
currentBranch: "fork-owner/open-button-targets-active-file",
remoteUrl: "git@github.com:getpaseo/paseo.git",
}),
);
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
github,
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledWith(
expect.objectContaining({
cwd: REPO_CWD,
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
}),
);
});
subscription.unsubscribe();
service.dispose();
});
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
@@ -270,7 +270,7 @@ interface WorkspaceGitTarget {
debounceTimer: NodeJS.Timeout | null;
selfHealTimer: NodeJS.Timeout | null;
githubPollSubscription: { unsubscribe: () => void } | null;
githubPollHeadRef: string | null;
githubPollKey: string | null;
refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null;
@@ -317,6 +317,11 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
inFlight: Promise<T> | null;
}
interface WorkspaceGitHubPollTarget {
headRef: string;
headRepositoryOwner?: string;
}
function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies {
return {
watch,
@@ -796,7 +801,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
debounceTimer: null,
selfHealTimer: null,
githubPollSubscription: null,
githubPollHeadRef: null,
githubPollKey: null,
refreshState: { status: "idle" },
latestGit: null,
latestGitLoadedAtMs: null,
@@ -1156,23 +1161,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
const headRef = git.currentBranch;
const pollTarget = this.resolveGitHubPollTarget(target);
const remoteUrl = git.remoteUrl;
const hasGitHubRemote =
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
target.cachedGitHubRemote?.remoteUrl === remoteUrl &&
target.cachedGitHubRemote.identity !== null;
if (!headRef || !hasGitHubRemote) {
if (!pollTarget || remoteUrl === null || !hasGitHubRemote) {
this.stopGitHubPollForTarget(target);
return;
}
if (target.githubPollHeadRef === headRef && target.githubPollSubscription) {
const pollKey = buildWorkspaceGitHubPollKey(remoteUrl, pollTarget);
if (target.githubPollKey === pollKey && target.githubPollSubscription) {
return;
}
this.stopGitHubPollForTarget(target);
target.githubPollHeadRef = headRef;
target.githubPollKey = pollKey;
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef,
headRef: pollTarget.headRef,
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
@@ -1183,17 +1193,40 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
},
onError: (error) => {
this.logger.warn(
{ err: error, cwd: target.cwd, headRef, reason: "self-heal-github" },
{
err: error,
cwd: target.cwd,
headRef: pollTarget.headRef,
headRepositoryOwner: pollTarget.headRepositoryOwner,
reason: "self-heal-github",
},
"Failed to run GitHub self-heal refresh",
);
},
});
}
private resolveGitHubPollTarget(target: WorkspaceGitTarget): WorkspaceGitHubPollTarget | null {
const git = target.latestGit;
if (!git?.currentBranch) {
return null;
}
const lookupTarget =
target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch
? target.latestFacts.pullRequestLookupTarget
: null;
if (lookupTarget) {
return lookupTarget;
}
return { headRef: git.currentBranch };
}
private stopGitHubPollForTarget(target: WorkspaceGitTarget): void {
target.githubPollSubscription?.unsubscribe();
target.githubPollSubscription = null;
target.githubPollHeadRef = null;
target.githubPollKey = null;
}
private addWorkingTreeWatcher(
@@ -1669,7 +1702,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return null;
}
return JSON.stringify([git.remoteUrl, git.currentBranch]);
const pollTarget = this.resolveGitHubPollTarget(target);
if (!pollTarget) {
return null;
}
return buildWorkspaceGitHubPollKey(git.remoteUrl, pollTarget);
}
private rememberGitHubSnapshot(
@@ -1976,6 +2014,10 @@ function buildGitHubSnapshotFromStatus(
};
}
function buildWorkspaceGitHubPollKey(remoteUrl: string, target: WorkspaceGitHubPollTarget): string {
return JSON.stringify([remoteUrl, target.headRef, target.headRepositoryOwner ?? null]);
}
async function runGitFetch(cwd: string): Promise<void> {
await runGitCommand(["fetch", "origin", "--prune"], {
cwd,
@@ -13,7 +13,6 @@ import {
type GitHubCommandRunner,
type GitHubCommandRunnerOptions,
type GitHubCurrentPullRequestStatus,
type GitHubReadOptions,
} from "./github-service.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
@@ -186,7 +185,7 @@ function githubStatusFacts(
}
function recordCurrentPullRequestStatusReads(service: ReturnType<typeof createGitHubService>) {
const reads: GitHubReadOptions[] = [];
const reads: Parameters<typeof service.getCurrentPullRequestStatus>[0][] = [];
const getCurrentPullRequestStatus = service.getCurrentPullRequestStatus.bind(service);
service.getCurrentPullRequestStatus = vi.fn(async (options) => {
reads.push(options);
@@ -662,6 +661,41 @@ describe("GitHubService", () => {
service.dispose?.();
});
it("retained fork PR status polls keep the head repository owner", async () => {
const runner = createRunner([
currentPullRequestJson({
headRefName: "open-button-targets-active-file",
headRepositoryOwner: { login: "fork-owner" },
}),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const reads = recordCurrentPullRequestStatusReads(service);
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
});
await vi.advanceTimersByTimeAsync(0);
expect(reads).toEqual([
expect.objectContaining({
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
reason: "self-heal-github",
}),
]);
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
subscription?.unsubscribe();
service.dispose?.();
});
it("polls PR status at slow cadence after stable checks", async () => {
let now = 0;
const runner = createRunner([
+15 -2
View File
@@ -642,6 +642,7 @@ export interface GitHubService {
retainCurrentPullRequestStatusPoll?(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
onStatus?: (status: GitHubCurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
@@ -720,6 +721,7 @@ interface InFlightCacheEntry {
interface GitHubPollTarget {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
retainCount: number;
timer: NodeJS.Timeout | null;
latestStatus: GitHubCurrentPullRequestStatus | null;
@@ -813,17 +815,25 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
}
function getPollTargetKey(target: { cwd: string; headRef: string }): string {
function getPollTargetKey(target: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
}): string {
return buildCacheKey({
cwd: target.cwd,
method: "getCurrentPullRequestStatus",
args: { headRef: target.headRef },
args: {
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
},
});
}
function updatePollTargetAfterSuccess(update: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
status: GitHubCurrentPullRequestStatus | null;
notify: boolean;
}): void {
@@ -872,6 +882,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
await api.getCurrentPullRequestStatus({
cwd: target.cwd,
headRef: target.headRef,
headRepositoryOwner: target.headRepositoryOwner,
reason: "self-heal-github",
});
} catch (error) {
@@ -1027,6 +1038,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
updatePollTargetAfterSuccess({
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
status,
notify: input.reason === "self-heal-github",
});
@@ -1241,6 +1253,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
target = {
cwd: input.cwd,
headRef: input.headRef,
headRepositoryOwner: input.headRepositoryOwner,
retainCount: 0,
timer: null,
latestStatus: null,