diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 12f7204e9..a330522bb 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { vi.useRealTimers(); }); - test("getSnapshot returns cached warm value without shelling out within consumer TTL", async () => { + test("getSnapshot returns the current snapshot without shelling out", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ @@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("warm non-forced getSnapshot calls attach to an in-flight refresh instead of returning stale cache", async () => { + test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const refreshStatus = createDeferred(); const getCheckoutStatus = vi @@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { }), ); - nowMs += 16_000; - const first = service.getSnapshot(REPO_CWD); - await flushPromises(); - const second = service.getSnapshot(REPO_CWD); + const initialSnapshot = createSnapshot(REPO_CWD, { + git: { diffStat: { additions: 4, deletions: 2 } }, + }); + + nowMs += 3_000; + const refresh = service.refresh(REPO_CWD); await flushPromises(); + const directRead = service.getSnapshot(REPO_CWD); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutShortstat).toHaveBeenCalledTimes(1); + await expect(directRead).resolves.toEqual(initialSnapshot); refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" })); - - const freshSnapshot = createSnapshot(REPO_CWD, { - git: { - currentBranch: "feature", - diffStat: { additions: 4, deletions: 2 }, - }, - }); - await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]); + await refresh; + expect(service.peekSnapshot(REPO_CWD)).toEqual( + createSnapshot(REPO_CWD, { + git: { + currentBranch: "feature", + diffStat: { additions: 4, deletions: 2 }, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + }, + }), + ); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); expect(getCheckoutShortstat).toHaveBeenCalledTimes(2); @@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { .fn<() => Promise>() .mockImplementationOnce(async () => forcedGitRefresh.promise) .mockImplementation(async () => createCheckoutStatus(REPO_CWD)); - const getPullRequestStatus = vi - .fn() - .mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR")) - .mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR")); + const getPullRequestStatus = vi.fn(async () => + createPullRequestStatusResult("Fresh validation PR"), + ); const service = createService({ getCheckoutStatus, getPullRequestStatus }); const gitRefresh = service.getSnapshot(REPO_CWD, { @@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await gitRefresh; expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), { + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), { force: true, reason: "merge-pr-validation", }); @@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("consumer TTL triggers cold-load after expiry on getSnapshot", async () => { + test("non-forced getSnapshot keeps returning the current snapshot after time passes", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ @@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { nowMs = 16_000; await service.getSnapshot(REPO_CWD); - expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); service.dispose(); }); - test("self-heal timer fires at the interval and calls getSnapshot without force", async () => { + test("self-heal timer refreshes git without refreshing GitHub", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); @@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), { + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), { force: false, - reason: "self-heal-git", + reason: "initial", }); subscription.unsubscribe(); @@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { github, now: () => new Date(nowMs), }); - const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + const listener = vi.fn(); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); await flushPromises(); + await vi.advanceTimersByTimeAsync(0); + await flushPromises(); + const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length; nowMs = 20_000; await vi.advanceTimersByTimeAsync(20_000); @@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { reason: "self-heal-github", tickMs: 20_000, }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + github: expect.objectContaining({ + pullRequest: expect.objectContaining({ + checksStatus: "pending", + }), + }), + }), + ); + + subscription.unsubscribe(); + service.dispose(); + github.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 }> = []; + const github = createGitHubService({ + ttlMs: 0, + runner: vi.fn(async () => ({ + stdout: currentPullRequestJson(), + stderr: "", + })), + resolveGhPath: async () => "/usr/bin/gh", + now: () => nowMs, + }); + const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github); + github.getCurrentPullRequestStatus = vi.fn( + async (options): Promise => { + githubReadCalls.push({ reason: options.reason, tickMs: nowMs }); + return getCurrentPullRequestStatus(options); + }, + ); + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { currentBranch: "feature" }), + ); + const service = createService({ + getCheckoutStatus, + github, + now: () => new Date(nowMs), + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await flushPromises(); + await vi.advanceTimersByTimeAsync(0); + await flushPromises(); + const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length; + + nowMs = 20_000; + await vi.advanceTimersByTimeAsync(20_000); + await flushPromises(); + + expect(githubReadCalls).not.toContainEqual({ + reason: "self-heal-github", + tickMs: 20_000, + }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot); + + nowMs = 120_000; + await vi.advanceTimersByTimeAsync(100_000); + await flushPromises(); + + expect(githubReadCalls).toContainEqual({ + reason: "self-heal-github", + tickMs: 120_000, + }); subscription.unsubscribe(); service.dispose(); @@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(0); }); - test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => { + test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => { let nowMs = 0; const selfHealRefresh = createDeferred(); const getCheckoutStatus = vi @@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD)); selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD)); - await directRead; + await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index d3969a356..974bf5eb4 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -341,7 +341,7 @@ describe("WorkspaceGitServiceImpl", () => { ); }); - test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => { + test("non-forced workspace refresh does not reload GitHub or emit when state is unchanged", async () => { let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const service = createService({ @@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => { nowMs += 3_000; await service.refresh(REPO_CWD); - expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(listener).not.toHaveBeenCalled(); subscription.unsubscribe(); @@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); - test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { + test("multiple listeners on the same workspace share one observation setup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); @@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => { const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await flushPromises(); - expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(0); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); first.unsubscribe(); @@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => { createSnapshot(REPO_CWD), ); - expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); - test("explicit refresh recomputes github state and notifies listeners", async () => { + test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => { const getPullRequestStatus = vi .fn<() => Promise>() .mockResolvedValueOnce( @@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => { expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh"); - await service.refresh(REPO_CWD); + await service.getSnapshot(REPO_CWD, { + force: true, + reason: "test-force-github-refresh", + }); await flushPromises(); expect(getPullRequestStatus).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 739ed1a32..3d00cab32 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; -// Consumer reads may reuse cached values within this window; older peeks cold-load through the service. -const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000; +// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. +const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; const LINUX_WATCH_MAX_DIRS = 5_000; @@ -262,6 +262,10 @@ interface WorkspaceGitTarget { githubPollSubscription: { unsubscribe: () => void } | null; githubPollHeadRef: string | null; refreshState: WorkspaceGitRefreshState; + latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; + latestGitLoadedAtMs: number | null; + latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null; + latestGithubLoadedAtMs: number | null; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; latestSnapshotLoadedAtMs: number | null; latestFingerprint: string | null; @@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { cwd = normalizeWorkspaceId(cwd); const request = this.normalizeRefreshRequest(options, "getSnapshot", true); const target = this.ensureWorkspaceTarget(cwd); - if (!request.force && this.isSnapshotWarm(target)) { + if (!request.force && target.latestSnapshot) { return target.latestSnapshot; } @@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const nowMs = this.deps.now().getTime(); if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) { const ageMs = nowMs - entry.loadedAtMs; - if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) { + if (ageMs <= WORKSPACE_GIT_AUXILIARY_READ_TTL_MS) { return Promise.resolve(entry.value); } if ( @@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { githubPollSubscription: null, githubPollHeadRef: null, refreshState: { status: "idle" }, + latestGit: null, + latestGitLoadedAtMs: null, + latestGithub: null, + latestGithubLoadedAtMs: null, latestSnapshot: null, latestSnapshotLoadedAtMs: null, latestFingerprint: null, @@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!target.selfHealTimer) { target.selfHealTimer = setInterval(() => { this.scheduleWorkspaceObservationSetup(target); - this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => { + this.refreshWorkspaceTarget(target, { + force: false, + includeGitHub: false, + reason: "self-heal-git", + notify: true, + }).catch((error) => { this.logger.warn( { err: error, cwd: target.cwd, reason: "self-heal-git" }, "Failed to run workspace git self-heal refresh", @@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - const snapshot = target.latestSnapshot; - if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) { + const git = target.latestGit; + if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) { this.stopGitHubPollForTarget(target); return; } - const headRef = snapshot.git.currentBranch; + const headRef = git.currentBranch; const hasGitHubRemote = - target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl && + target.cachedGitHubRemote?.remoteUrl === git.remoteUrl && target.cachedGitHubRemote.identity !== null; if (!headRef || !hasGitHubRemote) { this.stopGitHubPollForTarget(target); @@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({ cwd: target.cwd, headRef, - onStatus: () => { + onStatus: (status) => { if (!this.isActiveObservedWorkspaceTarget(target)) { return; } - void this.refreshWorkspaceTarget(target, { - force: false, - includeGitHub: true, - reason: "self-heal-github", + this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), { notify: true, }); }, @@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const force = options?.force === true; return { force, - includeGitHub: options?.includeGitHub ?? force, + includeGitHub: options?.includeGitHub ?? true, reason: options?.reason ?? defaultReason, notify, }; } - private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & { - latestSnapshot: WorkspaceGitRuntimeSnapshot; - latestSnapshotLoadedAtMs: number; - } { - if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) { - return false; - } - - return ( - this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS - ); - } - private async resolveGitHubRemoteForTarget( target: WorkspaceGitTarget, remoteUrl: string | null, @@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { + await this.refreshGitSnapshot(target, request); + if (request.includeGitHub) { + await this.refreshGitHubSnapshot(target, request); + } + + const snapshot = this.combineSnapshot(target); + target.latestSnapshotLoadedAtMs = this.deps.now().getTime(); + return snapshot; + } + + private async refreshGitSnapshot( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { const now = this.deps.now(); target.lastShellOutAtMs = now.getTime(); + + const cwd = target.cwd; + const previousGitHubPollKey = this.getGitHubPollKey(target); + const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; + const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); + if (!checkoutStatus.isGit) { + target.latestGit = buildNotGitSnapshot(cwd).git; + target.latestGitLoadedAtMs = this.deps.now().getTime(); + target.cachedGitHubRemote = null; + target.latestGithub = buildGitHubUnavailableSnapshot(); + target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs; + return; + } + + await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl); + const diffStat = await this.deps + .getCheckoutShortstat(cwd, context, { force: request.force }) + .catch(() => null); + + target.latestGit = { + isGit: true, + repoRoot: checkoutStatus.repoRoot, + mainRepoRoot: checkoutStatus.mainRepoRoot, + currentBranch: checkoutStatus.currentBranch, + remoteUrl: checkoutStatus.remoteUrl, + isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, + isDirty: checkoutStatus.isDirty, + baseRef: checkoutStatus.baseRef, + aheadBehind: checkoutStatus.aheadBehind, + aheadOfOrigin: checkoutStatus.aheadOfOrigin, + behindOfOrigin: checkoutStatus.behindOfOrigin, + hasRemote: checkoutStatus.hasRemote, + diffStat, + }; + target.latestGitLoadedAtMs = this.deps.now().getTime(); + + if (previousGitHubPollKey !== this.getGitHubPollKey(target)) { + target.latestGithub = buildGitHubUnavailableSnapshot(); + target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs; + } + } + + private async refreshGitHubSnapshot( + target: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { + const githubRemote = target.cachedGitHubRemote?.identity ?? null; const forceGitHub = request.force && request.includeGitHub; if (forceGitHub) { this.deps.github.invalidate({ cwd: target.cwd }); } - const cwd = target.cwd; - const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; - const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); - if (!checkoutStatus.isGit) { - target.latestSnapshotLoadedAtMs = now.getTime(); - return buildNotGitSnapshot(cwd); + target.latestGithub = await loadGitHubSnapshot({ + cwd: target.cwd, + githubRemote, + now: this.deps.now(), + deps: this.deps, + force: forceGitHub, + reason: request.reason, + }); + target.latestGithubLoadedAtMs = this.deps.now().getTime(); + } + + private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot { + if (!target.latestGit) { + return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd); } - const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl); - - const [diffStat, github] = await Promise.all([ - this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null), - loadGitHubSnapshot({ - cwd, - githubRemote, - now, - deps: this.deps, - force: forceGitHub, - reason: request.reason, - }), - ]); - - target.latestSnapshotLoadedAtMs = now.getTime(); return { - cwd, - git: { - isGit: true, - repoRoot: checkoutStatus.repoRoot, - mainRepoRoot: checkoutStatus.mainRepoRoot, - currentBranch: checkoutStatus.currentBranch, - remoteUrl: checkoutStatus.remoteUrl, - isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, - isDirty: checkoutStatus.isDirty, - baseRef: checkoutStatus.baseRef, - aheadBehind: checkoutStatus.aheadBehind, - aheadOfOrigin: checkoutStatus.aheadOfOrigin, - behindOfOrigin: checkoutStatus.behindOfOrigin, - hasRemote: checkoutStatus.hasRemote, - diffStat, - }, - github, + cwd: target.cwd, + git: target.latestGit, + github: target.latestGithub ?? buildGitHubUnavailableSnapshot(), }; } + private getGitHubPollKey(target: WorkspaceGitTarget): string | null { + const git = target.latestGit; + if (!git?.currentBranch || !git.remoteUrl) { + return null; + } + + const githubRemote = target.cachedGitHubRemote; + if (!githubRemote || githubRemote.remoteUrl !== git.remoteUrl || !githubRemote.identity) { + return null; + } + + return JSON.stringify([git.remoteUrl, git.currentBranch]); + } + + private rememberGitHubSnapshot( + target: WorkspaceGitTarget, + github: WorkspaceGitRuntimeSnapshot["github"], + options?: { notify?: boolean }, + ): void { + if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { + return; + } + + target.latestGithub = github; + target.latestGithubLoadedAtMs = this.deps.now().getTime(); + this.rememberSnapshot(target, this.combineSnapshot(target), { + notify: options?.notify, + forceEmit: false, + }); + } + private rememberSnapshot( target: WorkspaceGitTarget, snapshot: WorkspaceGitRuntimeSnapshot, @@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { hasRemote: false, diffStat: null, }, - github: { - featuresEnabled: false, - pullRequest: null, - error: null, - }, + github: buildGitHubUnavailableSnapshot(), + }; +} + +function buildGitHubUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["github"] { + return { + featuresEnabled: false, + pullRequest: null, + error: null, + }; +} + +function buildGitHubSnapshotFromStatus( + status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"], +): WorkspaceGitRuntimeSnapshot["github"] { + return { + featuresEnabled: true, + pullRequest: status, + error: null, }; }