diff --git a/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md b/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md index af23e1f26..87571bc2e 100644 --- a/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md +++ b/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md @@ -70,14 +70,17 @@ The frozen home used for the comparison contained 22 workspaces. ### Before/After -| run | code shape | client shape | git commands | failures | elapsed | -| ----------- | ------------- | ----------------------------------------- | -----------: | -------: | ------: | -| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms | -| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms | -| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms | +| run | code shape | client shape | git commands | failures | elapsed | +| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: | +| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms | +| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms | +| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms | +| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms | The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran. +The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline). + ### Baseline: before change + legacy PR fanout ```json @@ -148,6 +151,41 @@ The server-side fact reuse accounts for nearly all measured git command reductio } ``` +### After 2: shared service-level facts + +```json +{ + "scenario": "snapshotOnly", + "workspaceCount": 22, + "elapsedMs": 31334, + "git": { + "total": 308, + "failed": 15, + "maxConcurrent": 8, + "byCommand": [ + { "key": "show-ref --verify --quiet refs/heads/main", "count": 31 }, + { "key": "rev-parse --git-common-dir", "count": 26 }, + { "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 }, + { "key": "ls-files --others --exclude-standard", "count": 18 }, + { "key": "status --porcelain", "count": 18 }, + { "key": "merge-base HEAD origin/main", "count": 17 }, + { "key": "config --get remote.origin.url", "count": 13 }, + { "key": "rev-parse --abbrev-ref HEAD", "count": 13 }, + { "key": "rev-parse --absolute-git-dir", "count": 13 }, + { "key": "rev-parse --show-toplevel", "count": 13 }, + { "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 }, + { "key": "fetch origin --prune", "count": 5 } + ] + }, + "process": { + "cpuUserMs": 1817, + "cpuSystemMs": 1869, + "rssDeltaMb": 16.7, + "heapUsedDeltaMb": 10.4 + } +} +``` + ## Snapshot Equivalence Guard Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run: @@ -165,9 +203,9 @@ This pass reshaped the data flow and removed the sidebar PR badge special path. The benchmark still shows repeated per-workspace reads that are candidates for the next pass: -- origin URL lookup repeats across snapshot facts and GitHub remote resolution paths. - base ref existence checks still repeat as `show-ref` probes. -- repo/worktree identity still requires one root/current-branch rev-parse per workspace. +- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`. +- repo common-dir lookup is lower, but still above the apparent git workspace count. - shortstat still runs its own merge-base/diff/untracked scan per workspace. The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`. diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 610f6dfa6..fde84414f 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -112,6 +112,8 @@ function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts { worktreeRoot: cwd, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + absoluteGitDir: join(cwd, ".git"), + gitCommonDir: join(cwd, ".git"), paseoWorktree: { isPaseoOwnedWorktree: false }, storedBaseRef: null, resolvedBaseRef: "main", @@ -427,10 +429,12 @@ describe("WorkspaceGitServiceImpl", () => { test("multiple listeners on the same workspace share one observation setup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); let nowMs = Date.parse("2026-04-12T00:00:00.000Z"); const service = createService({ + getCheckoutSnapshotFacts, getPullRequestStatus, resolveAbsoluteGitDir, now: () => new Date(nowMs), @@ -441,7 +445,8 @@ describe("WorkspaceGitServiceImpl", () => { await flushPromises(); expect(getPullRequestStatus).toHaveBeenCalledTimes(0); - expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); first.unsubscribe(); second.unsubscribe(); @@ -473,7 +478,7 @@ describe("WorkspaceGitServiceImpl", () => { ); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); - expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); subscription.unsubscribe(); service.dispose(); @@ -482,9 +487,15 @@ describe("WorkspaceGitServiceImpl", () => { test("repo-level fetch intervals are shared for workspaces in the same repo", async () => { const runGitFetch = vi.fn(async () => {}); const hasOriginRemote = vi.fn(async () => true); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ + ...createCheckoutSnapshotFacts(cwd), + gitCommonDir: join(REPO_CWD, ".git"), + absoluteGitDir: join(REPO_CWD, ".git"), + })); const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git")); const service = createService({ + getCheckoutSnapshotFacts, resolveAbsoluteGitDir, hasOriginRemote, runGitFetch, @@ -496,11 +507,12 @@ describe("WorkspaceGitServiceImpl", () => { vi.fn(), ); await vi.waitFor(() => { - expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); expect(runGitFetch).toHaveBeenCalledTimes(1); }); - expect(hasOriginRemote).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); + expect(hasOriginRemote).toHaveBeenCalledTimes(0); expect(runGitFetch).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(180_000); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index d9acd9284..305ea91bd 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -47,6 +47,7 @@ const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_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. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; +const WORKSPACE_GIT_FACTS_REUSE_TTL_MS = 1_000; const LINUX_WATCH_MAX_DIRS = 5_000; const LINUX_WATCH_REFRESH_COOLDOWN_MS = 2_000; const LINUX_WATCH_IGNORE_TTL_MS = 5 * 60 * 1_000; @@ -271,6 +272,9 @@ interface WorkspaceGitTarget { latestGithubLoadedAtMs: number | null; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; latestSnapshotLoadedAtMs: number | null; + latestFacts: CheckoutSnapshotFacts | null; + latestFactsLoadedAtMs: number | null; + factsPromise: Promise | null; latestFingerprint: string | null; lastShellOutAtMs: number | null; repoGitRoot: string | null; @@ -787,6 +791,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { latestGithubLoadedAtMs: null, latestSnapshot: null, latestSnapshotLoadedAtMs: null, + latestFacts: null, + latestFactsLoadedAtMs: null, + factsPromise: null, latestFingerprint: null, lastShellOutAtMs: null, repoGitRoot: null, @@ -837,7 +844,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise { - const gitDir = await this.deps.resolveAbsoluteGitDir(target.cwd); + const facts = await this.getFactsForObservation(target); + const gitDir = facts?.isGit ? facts.absoluteGitDir : null; if (!this.isActiveObservedWorkspaceTarget(target)) { return; } @@ -846,7 +854,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir); + const repoGitRoot = + facts?.isGit && facts.gitCommonDir + ? facts.gitCommonDir + : await this.resolveWorkspaceGitRefsRoot(gitDir); if (!this.isActiveObservedWorkspaceTarget(target)) { return; } @@ -858,6 +869,48 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } + private async getFactsForObservation( + target: WorkspaceGitTarget, + ): Promise { + return this.loadCheckoutFacts(target, { + paseoHome: this.paseoHome, + logger: this.logger, + allowRecent: true, + }); + } + + private loadCheckoutFacts( + target: WorkspaceGitTarget, + options: CheckoutContext & { allowRecent: boolean }, + ): Promise { + if (options.allowRecent && target.latestFacts && target.latestFactsLoadedAtMs !== null) { + const ageMs = this.deps.now().getTime() - target.latestFactsLoadedAtMs; + if (ageMs < WORKSPACE_GIT_FACTS_REUSE_TTL_MS) { + return Promise.resolve(target.latestFacts); + } + } + + if (target.factsPromise) { + return target.factsPromise; + } + + const { allowRecent: _allowRecent, ...context } = options; + const promise = this.deps + .getCheckoutSnapshotFacts(target.cwd, context) + .then((facts) => { + target.latestFacts = facts; + target.latestFactsLoadedAtMs = this.deps.now().getTime(); + return facts; + }) + .finally(() => { + if (target.factsPromise === promise) { + target.factsPromise = null; + } + }); + target.factsPromise = promise; + return promise; + } + private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean { return ( !target.closed && @@ -996,7 +1049,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } - const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd); + const facts = workspaceTarget.latestFacts; + const hasOrigin = + facts?.isGit === true + ? facts.remoteUrl !== null + : await this.deps.hasOriginRemote(workspaceTarget.cwd); if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) { return; } @@ -1505,7 +1562,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = target.cwd; const previousGitHubPollKey = this.getGitHubPollKey(target); const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; - const facts = await this.deps.getCheckoutSnapshotFacts(cwd, baseContext); + const facts = await this.loadCheckoutFacts(target, { + ...baseContext, + allowRecent: !request.force, + }); const context: CheckoutContext = { ...baseContext, facts }; const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 832fde454..ee7ffc6d1 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -735,6 +735,8 @@ export type CheckoutSnapshotFacts = worktreeRoot: string; currentBranch: string | null; remoteUrl: string | null; + absoluteGitDir: string | null; + gitCommonDir: string | null; paseoWorktree: PaseoWorktreeForCwd; storedBaseRef: string | null; resolvedBaseRef: string | null; @@ -817,7 +819,13 @@ export async function getMainRepoRoot(cwd: string): Promise { cwd, envOverlay: READ_ONLY_GIT_ENV, }); - const commonDir = resolveGitRevParsePath(cwd, commonDirOut); + return getMainRepoRootFromCommonDir(cwd, resolveGitRevParsePath(cwd, commonDirOut)); +} + +async function getMainRepoRootFromCommonDir( + cwd: string, + commonDir: string | null, +): Promise { if (!commonDir) { throw new Error("Not in a git repository"); } @@ -1101,6 +1109,18 @@ export async function resolveAbsoluteGitDir(cwd: string): Promise } } +async function resolveGitCommonDir(cwd: string): Promise { + try { + const { stdout } = await runGitCommand(["rev-parse", "--git-common-dir"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + return resolveGitRevParsePath(cwd, stdout); + } catch { + return null; + } +} + async function abortGitPullConflictState(cwd: string): Promise { const gitDir = await resolveAbsoluteGitDir(cwd); if (!gitDir) { @@ -1389,6 +1409,8 @@ interface CheckoutInspectionContext { worktreeRoot: string; currentBranch: string | null; remoteUrl: string | null; + absoluteGitDir: string | null; + gitCommonDir: string | null; paseoWorktree: PaseoWorktreeForCwd; } @@ -1402,16 +1424,21 @@ async function inspectCheckoutContext( return null; } - const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([ - getCurrentBranch(cwd), - getOriginRemoteUrl(cwd), - getPaseoWorktreeForCwd(cwd, context, root), - ]); + const [currentBranch, remoteUrl, absoluteGitDir, gitCommonDir, paseoWorktree] = + await Promise.all([ + getCurrentBranch(cwd), + getOriginRemoteUrl(cwd), + resolveAbsoluteGitDir(cwd), + resolveGitCommonDir(cwd), + getPaseoWorktreeForCwd(cwd, context, root), + ]); return { worktreeRoot: root, currentBranch, remoteUrl, + absoluteGitDir, + gitCommonDir, paseoWorktree, }; } catch (error) { @@ -1464,7 +1491,9 @@ export async function getCheckoutSnapshotFacts( ? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot) : null; const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd)); - const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null); + const mainRepoRoot = await getMainRepoRootFromCommonDir(cwd, inspected.gitCommonDir).catch( + () => null, + ); let comparisonBaseRef: string | null = null; if ( resolvedBaseRef && @@ -1512,6 +1541,8 @@ export async function getCheckoutSnapshotFacts( worktreeRoot: inspected.worktreeRoot, currentBranch: inspected.currentBranch, remoteUrl: inspected.remoteUrl, + absoluteGitDir: inspected.absoluteGitDir, + gitCommonDir: inspected.gitCommonDir, paseoWorktree: inspected.paseoWorktree, storedBaseRef, resolvedBaseRef,