feat(server,app): redesign git/GitHub refresh architecture with server authority

Centralizes refresh orchestration in WorkspaceGitService and GitHubService:
server coalesces, deduplicates, and throttles git/GitHub reads; clients peek
snapshots (cold reads fall through to a one-shot fetch) and never drive
refreshes. Introduces a two-TTL model (consumer vs internal), force-emit
bypass so fingerprint-matched refreshes still propagate, notifyGitMutation
wiring at ~13 action handlers, adaptive GitHub polling based on check state,
and checkout_status_update / prStatus push-to-cache wire messages. Fixes the
stale diff-stat bug and the inverted base-branch diff direction, and flips
next-action affordances promptly after commit / PR merge.
This commit is contained in:
Mohamed Boudra
2026-04-19 22:35:26 +07:00
parent 128d6668ae
commit 5a2708653c
50 changed files with 6320 additions and 1073 deletions
+194 -3
View File
@@ -1,8 +1,13 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClient,
QueryClientProvider,
focusManager,
onlineManager,
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
@@ -18,14 +23,25 @@ type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
const { mockRuntime, mockClient } = vi.hoisted(() => {
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
const mockClient = {
checkoutPrStatus: vi.fn(),
pullRequestTimeline: vi.fn(),
on: vi.fn((type: string, handler: (message: unknown) => void) => {
if (type !== "checkout_status_update") {
return () => {};
}
checkoutStatusUpdateHandlers.add(handler);
return () => {
checkoutStatusUpdateHandlers.delete(handler);
};
}),
};
return {
mockClient,
checkoutStatusUpdateHandlers,
mockRuntime: {
client: mockClient,
isConnected: true,
@@ -85,6 +101,34 @@ function timelinePayload(
};
}
async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise<void> {
await act(async () => {
for (const handler of checkoutStatusUpdateHandlers) {
handler({
type: "checkout_status_update",
payload: {
cwd: payload.cwd,
requestId: `subscription:${payload.cwd}`,
isGit: true,
isPaseoOwnedWorktree: false,
repoRoot: payload.cwd,
currentBranch: "main",
isDirty: false,
baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: "https://github.com/getpaseo/paseo.git",
error: null,
prStatus: payload,
},
});
}
await Promise.resolve();
});
}
function unsupportedTimelineError(): Error {
const error = new Error(
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
@@ -246,6 +290,14 @@ describe("usePrPaneData", () => {
mockRuntime.isConnected = true;
mockClient.checkoutPrStatus.mockReset();
mockClient.pullRequestTimeline.mockReset();
mockClient.on.mockClear();
checkoutStatusUpdateHandlers.clear();
});
afterEach(() => {
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
vi.useRealTimers();
});
it("returns null when status has no PR number", async () => {
@@ -349,6 +401,82 @@ describe("usePrPaneData", () => {
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
});
it("server-pushed PR status update writes into the checkout PR status cache", async () => {
const queryClient = createTestQueryClient();
mockClient.checkoutPrStatus.mockResolvedValue(
statusPayload({
status: status({
checksStatus: "pending",
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
}),
}),
);
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
});
mockClient.checkoutPrStatus.mockClear();
await emitCheckoutStatusUpdatePrStatus(
statusPayload({
requestId: "server-push",
status: status({
checksStatus: "success",
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
}),
}),
);
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("success");
});
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
statusPayload({
requestId: "server-push",
status: status({
checksStatus: "success",
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
}),
}),
);
expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled();
});
it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => {
const queryClient = createTestQueryClient();
const initial = statusPayload({
status: status({
checksStatus: "pending",
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
}),
});
mockClient.checkoutPrStatus.mockResolvedValue(initial);
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
});
await emitCheckoutStatusUpdatePrStatus(
statusPayload({
cwd: "/other-repo",
requestId: "other-server-push",
status: status({
checksStatus: "success",
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
}),
}),
);
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
});
it("passes repoOwner and repoName to the timeline request when present", async () => {
mockClient.checkoutPrStatus.mockResolvedValue(
statusPayload({
@@ -517,6 +645,50 @@ describe("usePrPaneData", () => {
expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled();
});
it("does not poll PR status or timeline after the initial load", async () => {
vi.useFakeTimers();
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook();
await hook.mount();
await waitForHookResult(() => {
expect(hook.latest.data?.number).toBe(42);
});
await act(async () => {
vi.advanceTimersByTime(120_000);
await Promise.resolve();
});
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
});
it("does not refetch PR data on focus or reconnect", async () => {
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
const hook = renderPrPaneHook();
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.data?.number).toBe(42);
});
await act(async () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
onlineManager.setOnline(false);
onlineManager.setOnline(true);
await Promise.resolve();
});
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
});
it("reports first-load, background refresh, and non-suppressed errors", async () => {
const statusDeferred = createDeferred<CheckoutPrStatusPayload>();
mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise);
@@ -563,6 +735,25 @@ describe("usePrPaneData", () => {
});
});
async function waitForHookResult(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await act(async () => {
vi.advanceTimersByTime(10);
await Promise.resolve();
});
}
}
throw lastError;
}
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;