server: cache workspace shortstat and warm in background

This commit is contained in:
Mohamed Boudra
2026-04-05 22:28:07 +07:00
parent ce821afd27
commit 25213ce83a
3 changed files with 114 additions and 6 deletions
+7 -5
View File
@@ -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 {
@@ -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");
+84 -1
View File
@@ -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<string, Promise<PullRequestStatusResult>>();
let cachedGhPath: string | null | undefined = undefined;
let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS;
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
function createPullRequestStatusCache(ttlMs: number) {
return new TTLCache<string, PullRequestStatusResult>({
@@ -34,10 +39,22 @@ function createPullRequestStatusCache(ttlMs: number) {
});
}
function createShortstatCache(ttlMs: number) {
return new TTLCache<string, CheckoutShortstat | null>({
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<CheckoutShortstat | null> {
@@ -1273,6 +1306,56 @@ export async function getCheckoutShortstat(
}
}
function getOrLoadCheckoutShortstat(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutShortstat | null> {
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<CheckoutShortstat | null> {
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,