Reduce workspace git refresh polling (#1102)
* Reduce workspace git refresh polling * Keep workspace git snapshot reads passive * Update workspace git refresh tests
This commit is contained in:
@@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
vi.useRealTimers();
|
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");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
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");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const refreshStatus = createDeferred<CheckoutStatusGit>();
|
const refreshStatus = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
@@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
nowMs += 16_000;
|
const initialSnapshot = createSnapshot(REPO_CWD, {
|
||||||
const first = service.getSnapshot(REPO_CWD);
|
git: { diffStat: { additions: 4, deletions: 2 } },
|
||||||
await flushPromises();
|
});
|
||||||
const second = service.getSnapshot(REPO_CWD);
|
|
||||||
|
nowMs += 3_000;
|
||||||
|
const refresh = service.refresh(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
const directRead = service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(directRead).resolves.toEqual(initialSnapshot);
|
||||||
|
|
||||||
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||||
|
await refresh;
|
||||||
const freshSnapshot = createSnapshot(REPO_CWD, {
|
expect(service.peekSnapshot(REPO_CWD)).toEqual(
|
||||||
git: {
|
createSnapshot(REPO_CWD, {
|
||||||
currentBranch: "feature",
|
git: {
|
||||||
diffStat: { additions: 4, deletions: 2 },
|
currentBranch: "feature",
|
||||||
},
|
diffStat: { additions: 4, deletions: 2 },
|
||||||
});
|
},
|
||||||
await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]);
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockImplementationOnce(async () => forcedGitRefresh.promise)
|
.mockImplementationOnce(async () => forcedGitRefresh.promise)
|
||||||
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
||||||
const getPullRequestStatus = vi
|
const getPullRequestStatus = vi.fn(async () =>
|
||||||
.fn()
|
createPullRequestStatusResult("Fresh validation PR"),
|
||||||
.mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR"))
|
);
|
||||||
.mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR"));
|
|
||||||
const service = createService({ getCheckoutStatus, getPullRequestStatus });
|
const service = createService({ getCheckoutStatus, getPullRequestStatus });
|
||||||
|
|
||||||
const gitRefresh = service.getSnapshot(REPO_CWD, {
|
const gitRefresh = service.getSnapshot(REPO_CWD, {
|
||||||
@@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await gitRefresh;
|
await gitRefresh;
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
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,
|
force: true,
|
||||||
reason: "merge-pr-validation",
|
reason: "merge-pr-validation",
|
||||||
});
|
});
|
||||||
@@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
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;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
nowMs = 16_000;
|
nowMs = 16_000;
|
||||||
await service.getSnapshot(REPO_CWD);
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
service.dispose();
|
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;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
@@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
|
||||||
force: false,
|
force: false,
|
||||||
reason: "self-heal-git",
|
reason: "initial",
|
||||||
});
|
});
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
github,
|
github,
|
||||||
now: () => new Date(nowMs),
|
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 flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await flushPromises();
|
||||||
|
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
|
||||||
|
|
||||||
nowMs = 20_000;
|
nowMs = 20_000;
|
||||||
await vi.advanceTimersByTimeAsync(20_000);
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
@@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
reason: "self-heal-github",
|
reason: "self-heal-github",
|
||||||
tickMs: 20_000,
|
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<GitHubCurrentPullRequestStatus | null> => {
|
||||||
|
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();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
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;
|
let nowMs = 0;
|
||||||
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
@@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await directRead;
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh(REPO_CWD);
|
await service.refresh(REPO_CWD);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
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 getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
first.unsubscribe();
|
first.unsubscribe();
|
||||||
@@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
createSnapshot(REPO_CWD),
|
createSnapshot(REPO_CWD),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
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
|
const getPullRequestStatus = vi
|
||||||
.fn<() => Promise<PullRequestStatusResult>>()
|
.fn<() => Promise<PullRequestStatusResult>>()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
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();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
|||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_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.
|
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
|
||||||
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
|
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.
|
// 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 WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
|
||||||
const LINUX_WATCH_MAX_DIRS = 5_000;
|
const LINUX_WATCH_MAX_DIRS = 5_000;
|
||||||
@@ -262,6 +262,10 @@ interface WorkspaceGitTarget {
|
|||||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||||
githubPollHeadRef: string | null;
|
githubPollHeadRef: string | null;
|
||||||
refreshState: WorkspaceGitRefreshState;
|
refreshState: WorkspaceGitRefreshState;
|
||||||
|
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
||||||
|
latestGitLoadedAtMs: number | null;
|
||||||
|
latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null;
|
||||||
|
latestGithubLoadedAtMs: number | null;
|
||||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||||
latestSnapshotLoadedAtMs: number | null;
|
latestSnapshotLoadedAtMs: number | null;
|
||||||
latestFingerprint: string | null;
|
latestFingerprint: string | null;
|
||||||
@@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
cwd = normalizeWorkspaceId(cwd);
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
if (!request.force && this.isSnapshotWarm(target)) {
|
if (!request.force && target.latestSnapshot) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const nowMs = this.deps.now().getTime();
|
const nowMs = this.deps.now().getTime();
|
||||||
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
|
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
|
||||||
const ageMs = nowMs - entry.loadedAtMs;
|
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);
|
return Promise.resolve(entry.value);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
githubPollSubscription: null,
|
githubPollSubscription: null,
|
||||||
githubPollHeadRef: null,
|
githubPollHeadRef: null,
|
||||||
refreshState: { status: "idle" },
|
refreshState: { status: "idle" },
|
||||||
|
latestGit: null,
|
||||||
|
latestGitLoadedAtMs: null,
|
||||||
|
latestGithub: null,
|
||||||
|
latestGithubLoadedAtMs: null,
|
||||||
latestSnapshot: null,
|
latestSnapshot: null,
|
||||||
latestSnapshotLoadedAtMs: null,
|
latestSnapshotLoadedAtMs: null,
|
||||||
latestFingerprint: null,
|
latestFingerprint: null,
|
||||||
@@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!target.selfHealTimer) {
|
if (!target.selfHealTimer) {
|
||||||
target.selfHealTimer = setInterval(() => {
|
target.selfHealTimer = setInterval(() => {
|
||||||
this.scheduleWorkspaceObservationSetup(target);
|
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(
|
this.logger.warn(
|
||||||
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
||||||
"Failed to run workspace git self-heal refresh",
|
"Failed to run workspace git self-heal refresh",
|
||||||
@@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = target.latestSnapshot;
|
const git = target.latestGit;
|
||||||
if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
||||||
this.stopGitHubPollForTarget(target);
|
this.stopGitHubPollForTarget(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headRef = snapshot.git.currentBranch;
|
const headRef = git.currentBranch;
|
||||||
const hasGitHubRemote =
|
const hasGitHubRemote =
|
||||||
target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl &&
|
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
|
||||||
target.cachedGitHubRemote.identity !== null;
|
target.cachedGitHubRemote.identity !== null;
|
||||||
if (!headRef || !hasGitHubRemote) {
|
if (!headRef || !hasGitHubRemote) {
|
||||||
this.stopGitHubPollForTarget(target);
|
this.stopGitHubPollForTarget(target);
|
||||||
@@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
||||||
cwd: target.cwd,
|
cwd: target.cwd,
|
||||||
headRef,
|
headRef,
|
||||||
onStatus: () => {
|
onStatus: (status) => {
|
||||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void this.refreshWorkspaceTarget(target, {
|
this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), {
|
||||||
force: false,
|
|
||||||
includeGitHub: true,
|
|
||||||
reason: "self-heal-github",
|
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const force = options?.force === true;
|
const force = options?.force === true;
|
||||||
return {
|
return {
|
||||||
force,
|
force,
|
||||||
includeGitHub: options?.includeGitHub ?? force,
|
includeGitHub: options?.includeGitHub ?? true,
|
||||||
reason: options?.reason ?? defaultReason,
|
reason: options?.reason ?? defaultReason,
|
||||||
notify,
|
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(
|
private async resolveGitHubRemoteForTarget(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
remoteUrl: string | null,
|
remoteUrl: string | null,
|
||||||
@@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
|
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<void> {
|
||||||
const now = this.deps.now();
|
const now = this.deps.now();
|
||||||
target.lastShellOutAtMs = now.getTime();
|
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<void> {
|
||||||
|
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
|
||||||
const forceGitHub = request.force && request.includeGitHub;
|
const forceGitHub = request.force && request.includeGitHub;
|
||||||
if (forceGitHub) {
|
if (forceGitHub) {
|
||||||
this.deps.github.invalidate({ cwd: target.cwd });
|
this.deps.github.invalidate({ cwd: target.cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = target.cwd;
|
target.latestGithub = await loadGitHubSnapshot({
|
||||||
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
|
cwd: target.cwd,
|
||||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
githubRemote,
|
||||||
if (!checkoutStatus.isGit) {
|
now: this.deps.now(),
|
||||||
target.latestSnapshotLoadedAtMs = now.getTime();
|
deps: this.deps,
|
||||||
return buildNotGitSnapshot(cwd);
|
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 {
|
return {
|
||||||
cwd,
|
cwd: target.cwd,
|
||||||
git: {
|
git: target.latestGit,
|
||||||
isGit: true,
|
github: target.latestGithub ?? buildGitHubUnavailableSnapshot(),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private rememberSnapshot(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||||
@@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
},
|
},
|
||||||
github: {
|
github: buildGitHubUnavailableSnapshot(),
|
||||||
featuresEnabled: false,
|
};
|
||||||
pullRequest: null,
|
}
|
||||||
error: null,
|
|
||||||
},
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user