server: cache workspace shortstat and warm in background
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user