From 25213ce83abaa61df84a5c21fb81e1513303ceeb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Apr 2026 15:43:58 +0700 Subject: [PATCH] server: cache workspace shortstat and warm in background --- packages/server/src/server/session.ts | 12 +-- .../server/src/utils/checkout-git.test.ts | 23 +++++ packages/server/src/utils/checkout-git.ts | 85 ++++++++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4368fdadc..db41ca7f1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -130,7 +130,7 @@ import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import type { ServiceRouteStore } from "./service-proxy.js"; import { getCheckoutDiff, - getCheckoutShortstat, + getCachedCheckoutShortstat, getCheckoutStatus, getCheckoutStatusLite, listBranchSuggestions, @@ -140,6 +140,7 @@ import { pushCurrentBranch, createPullRequest, getPullRequestStatus, + warmCheckoutShortstatInBackground, } from "../utils/checkout-git.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; @@ -5176,10 +5177,11 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - try { - diffStat = await getCheckoutShortstat(workspace.directory); - } catch { - // Non-critical — leave null on failure. + const cachedShortstat = getCachedCheckoutShortstat(workspace.directory); + if (cachedShortstat !== undefined) { + diffStat = cachedShortstat; + } else { + warmCheckoutShortstatInBackground(workspace.directory); } return { diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index cdd7f8ff0..0e1cd0c2d 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -13,10 +13,12 @@ import { join } from "path"; import { tmpdir } from "os"; import { __resetGhPathCacheForTests, + __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, __setGhPathForTests, __setPullRequestStatusCacheTtlForTests, commitAll, + getCachedCheckoutShortstat, getCheckoutDiff, getCheckoutShortstat, getPullRequestStatus, @@ -33,6 +35,7 @@ import { parseWorktreeList, isPaseoWorktreePath, isDescendantPath, + warmCheckoutShortstatInBackground, } from "./checkout-git.js"; import { createWorktree } from "./worktree.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; @@ -65,11 +68,13 @@ describe("checkout git utilities", () => { repoDir = setup.repoDir; paseoHome = join(tempDir, "paseo-home"); __resetGhPathCacheForTests(); + __resetCheckoutShortstatCacheForTests(); __resetPullRequestStatusCacheForTests(); }); afterEach(() => { __resetGhPathCacheForTests(); + __resetCheckoutShortstatCacheForTests(); __resetPullRequestStatusCacheForTests(); rmSync(tempDir, { recursive: true, force: true }); }); @@ -238,6 +243,24 @@ const x = 1; expect(shortstat).toEqual({ additions: 1, deletions: 0 }); }); + it("warms shortstat cache in the background without blocking listing callers", async () => { + expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined(); + + warmCheckoutShortstatInBackground(repoDir); + + // A repo with no origin/main computes to null, but null should still be cached. + for (let attempts = 0; attempts < 20; attempts += 1) { + const cached = getCachedCheckoutShortstat(repoDir); + if (cached !== undefined) { + expect(cached).toBeNull(); + return; + } + await sleep(25); + } + + throw new Error("shortstat background warm did not populate cache in time"); + }); + it("commits messages with quotes safely", async () => { const message = `He said "hello" and it's fine`; writeFileSync(join(repoDir, "file.txt"), "quoted\n"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 3745731ca..5a0a8b620 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -20,11 +20,16 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000; const PULL_REQUEST_STATUS_CACHE_MAX = 1_000; +const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000; +const SHORTSTAT_CACHE_MAX = 1_000; let pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS; let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs); const pullRequestStatusInFlight = new Map>(); let cachedGhPath: string | null | undefined = undefined; +let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS; +let shortstatCache = createShortstatCache(shortstatCacheTtlMs); +const shortstatInFlight = new Map>(); function createPullRequestStatusCache(ttlMs: number) { return new TTLCache({ @@ -34,10 +39,22 @@ function createPullRequestStatusCache(ttlMs: number) { }); } +function createShortstatCache(ttlMs: number) { + return new TTLCache({ + ttl: ttlMs, + max: SHORTSTAT_CACHE_MAX, + checkAgeOnGet: true, + }); +} + function getPullRequestStatusCacheKey(cwd: string): string { return resolve(cwd); } +function getShortstatCacheKey(cwd: string): string { + return resolve(cwd); +} + export function __resetPullRequestStatusCacheForTests(): void { pullRequestStatusCache.clear(); pullRequestStatusCache.cancelTimer(); @@ -62,6 +79,22 @@ export function __setGhPathForTests(path: string | null): void { cachedGhPath = path; } +export function __resetCheckoutShortstatCacheForTests(): void { + shortstatCache.clear(); + shortstatCache.cancelTimer(); + shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS; + shortstatCache = createShortstatCache(shortstatCacheTtlMs); + shortstatInFlight.clear(); +} + +export function __setCheckoutShortstatCacheTtlForTests(ttlMs: number): void { + shortstatCache.clear(); + shortstatCache.cancelTimer(); + shortstatCacheTtlMs = ttlMs; + shortstatCache = createShortstatCache(ttlMs); + shortstatInFlight.clear(); +} + async function execGit( command: string, options: { cwd: string; env?: NodeJS.ProcessEnv }, @@ -1194,7 +1227,7 @@ export interface CheckoutShortstat { deletions: number; } -export async function getCheckoutShortstat( +async function getCheckoutShortstatUncached( cwd: string, context?: CheckoutContext, ): Promise { @@ -1273,6 +1306,56 @@ export async function getCheckoutShortstat( } } +function getOrLoadCheckoutShortstat( + cwd: string, + context?: CheckoutContext, +): Promise { + const cacheKey = getShortstatCacheKey(cwd); + const cached = shortstatCache.get(cacheKey); + if (cached !== undefined) { + return Promise.resolve(cached); + } + + const existing = shortstatInFlight.get(cacheKey); + if (existing) { + return existing; + } + + const load = getCheckoutShortstatUncached(cwd, context) + .then((shortstat) => { + shortstatCache.set(cacheKey, shortstat); + return shortstat; + }) + .finally(() => { + shortstatInFlight.delete(cacheKey); + }); + + shortstatInFlight.set(cacheKey, load); + return load; +} + +export async function getCheckoutShortstat( + cwd: string, + context?: CheckoutContext, +): Promise { + return getOrLoadCheckoutShortstat(cwd, context); +} + +export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined { + return shortstatCache.get(getShortstatCacheKey(cwd)); +} + +export function warmCheckoutShortstatInBackground(cwd: string, context?: CheckoutContext): void { + const cacheKey = getShortstatCacheKey(cwd); + if (shortstatCache.get(cacheKey) !== undefined || shortstatInFlight.has(cacheKey)) { + return; + } + + void getOrLoadCheckoutShortstat(cwd, context).catch(() => { + // Non-critical: keep listing path resilient even if git commands fail. + }); +} + export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare,