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 type { ServiceRouteStore } from "./service-proxy.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCachedCheckoutShortstat,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutStatusLite,
|
getCheckoutStatusLite,
|
||||||
listBranchSuggestions,
|
listBranchSuggestions,
|
||||||
@@ -140,6 +140,7 @@ import {
|
|||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
|
warmCheckoutShortstatInBackground,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import { getProjectIcon } from "../utils/project-icon.js";
|
import { getProjectIcon } from "../utils/project-icon.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
@@ -5176,10 +5177,11 @@ export class Session {
|
|||||||
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||||
|
|
||||||
let diffStat: { additions: number; deletions: number } | null = null;
|
let diffStat: { additions: number; deletions: number } | null = null;
|
||||||
try {
|
const cachedShortstat = getCachedCheckoutShortstat(workspace.directory);
|
||||||
diffStat = await getCheckoutShortstat(workspace.directory);
|
if (cachedShortstat !== undefined) {
|
||||||
} catch {
|
diffStat = cachedShortstat;
|
||||||
// Non-critical — leave null on failure.
|
} else {
|
||||||
|
warmCheckoutShortstatInBackground(workspace.directory);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import { join } from "path";
|
|||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import {
|
import {
|
||||||
__resetGhPathCacheForTests,
|
__resetGhPathCacheForTests,
|
||||||
|
__resetCheckoutShortstatCacheForTests,
|
||||||
__resetPullRequestStatusCacheForTests,
|
__resetPullRequestStatusCacheForTests,
|
||||||
__setGhPathForTests,
|
__setGhPathForTests,
|
||||||
__setPullRequestStatusCacheTtlForTests,
|
__setPullRequestStatusCacheTtlForTests,
|
||||||
commitAll,
|
commitAll,
|
||||||
|
getCachedCheckoutShortstat,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
parseWorktreeList,
|
parseWorktreeList,
|
||||||
isPaseoWorktreePath,
|
isPaseoWorktreePath,
|
||||||
isDescendantPath,
|
isDescendantPath,
|
||||||
|
warmCheckoutShortstatInBackground,
|
||||||
} from "./checkout-git.js";
|
} from "./checkout-git.js";
|
||||||
import { createWorktree } from "./worktree.js";
|
import { createWorktree } from "./worktree.js";
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||||
@@ -65,11 +68,13 @@ describe("checkout git utilities", () => {
|
|||||||
repoDir = setup.repoDir;
|
repoDir = setup.repoDir;
|
||||||
paseoHome = join(tempDir, "paseo-home");
|
paseoHome = join(tempDir, "paseo-home");
|
||||||
__resetGhPathCacheForTests();
|
__resetGhPathCacheForTests();
|
||||||
|
__resetCheckoutShortstatCacheForTests();
|
||||||
__resetPullRequestStatusCacheForTests();
|
__resetPullRequestStatusCacheForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
__resetGhPathCacheForTests();
|
__resetGhPathCacheForTests();
|
||||||
|
__resetCheckoutShortstatCacheForTests();
|
||||||
__resetPullRequestStatusCacheForTests();
|
__resetPullRequestStatusCacheForTests();
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -238,6 +243,24 @@ const x = 1;
|
|||||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
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 () => {
|
it("commits messages with quotes safely", async () => {
|
||||||
const message = `He said "hello" and it's fine`;
|
const message = `He said "hello" and it's fine`;
|
||||||
writeFileSync(join(repoDir, "file.txt"), "quoted\n");
|
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 SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
|
||||||
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
||||||
const PULL_REQUEST_STATUS_CACHE_MAX = 1_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 pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS;
|
||||||
let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
|
let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
|
||||||
const pullRequestStatusInFlight = new Map<string, Promise<PullRequestStatusResult>>();
|
const pullRequestStatusInFlight = new Map<string, Promise<PullRequestStatusResult>>();
|
||||||
let cachedGhPath: string | null | undefined = undefined;
|
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) {
|
function createPullRequestStatusCache(ttlMs: number) {
|
||||||
return new TTLCache<string, PullRequestStatusResult>({
|
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 {
|
function getPullRequestStatusCacheKey(cwd: string): string {
|
||||||
return resolve(cwd);
|
return resolve(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getShortstatCacheKey(cwd: string): string {
|
||||||
|
return resolve(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
export function __resetPullRequestStatusCacheForTests(): void {
|
export function __resetPullRequestStatusCacheForTests(): void {
|
||||||
pullRequestStatusCache.clear();
|
pullRequestStatusCache.clear();
|
||||||
pullRequestStatusCache.cancelTimer();
|
pullRequestStatusCache.cancelTimer();
|
||||||
@@ -62,6 +79,22 @@ export function __setGhPathForTests(path: string | null): void {
|
|||||||
cachedGhPath = path;
|
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(
|
async function execGit(
|
||||||
command: string,
|
command: string,
|
||||||
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
||||||
@@ -1194,7 +1227,7 @@ export interface CheckoutShortstat {
|
|||||||
deletions: number;
|
deletions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCheckoutShortstat(
|
async function getCheckoutShortstatUncached(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<CheckoutShortstat | null> {
|
): 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(
|
export async function getCheckoutDiff(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: CheckoutDiffCompare,
|
compare: CheckoutDiffCompare,
|
||||||
|
|||||||
Reference in New Issue
Block a user