Reduce startup git work for workspace snapshots

This commit is contained in:
Mohamed Boudra
2026-05-27 20:18:49 +07:00
parent dbfd42da46
commit 5d8dc800fc
13 changed files with 862 additions and 57 deletions
@@ -0,0 +1,173 @@
# Git Snapshot Startup Reshaping - 2026-05-27
## What changed
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
```text
daemon startup / workspace subscription
-> WorkspaceGitService.refreshSnapshot(cwd)
-> getCheckoutSnapshotFacts(cwd)
-> getCheckoutStatus(cwd, { facts })
-> getCheckoutShortstat(cwd, { facts })
-> getPullRequestStatus(cwd, github, ..., { facts })
-> WorkspaceGitRuntimeSnapshot
-> session workspace descriptor githubRuntime.pullRequest
-> app useSidebarWorkspacesList()
-> SidebarWorkspaceEntry.prHint
-> Sidebar row badge + hover card checks
```
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
## Shared Git Facts
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
- worktree root: `rev-parse --show-toplevel`
- current branch: `rev-parse --abbrev-ref HEAD`
- origin remote URL
- Paseo worktree ownership and stored base ref
- resolved base ref and best comparison base
- main repo root
- branch remote/merge config
- tracked origin branch
- pull request lookup target for fork/PR worktrees
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
## Current Data Flow
```text
Workspace subscription / fetch_workspaces
-> session workspace registry
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
-> refresh queue/throttle/dedupe per normalized cwd
-> refreshGitSnapshot()
-> getCheckoutSnapshotFacts()
-> getCheckoutStatus({ facts })
-> getCheckoutShortstat({ facts })
-> refreshGitHubSnapshot()
-> getPullRequestStatus({ facts })
-> cached WorkspaceGitRuntimeSnapshot
-> WorkspaceDescriptorPayload.gitRuntime
-> WorkspaceDescriptorPayload.githubRuntime
-> app session store
-> useSidebarWorkspacesList()
-> diffStat from descriptor
-> prHint from descriptor.githubRuntime.pullRequest
```
## Startup Benchmark
Added deterministic real-home benchmark:
`packages/server/scripts/benchmark-startup-git-real-home.ts`
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
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 |
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.
### Baseline: before change + legacy PR fanout
```json
{
"scenario": "legacyPrFanout",
"workspaceCount": 22,
"elapsedMs": 39039,
"git": {
"total": 529,
"failed": 20,
"maxConcurrent": 8,
"byCommand": [
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
{ "key": "rev-parse --git-common-dir", "count": 58 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
{ "key": "rev-parse --git-dir", "count": 36 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
{ "key": "config --get remote.origin.url", "count": 32 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 14 },
{ "key": "status --porcelain", "count": 14 }
]
},
"process": {
"cpuUserMs": 2009,
"cpuSystemMs": 2428,
"rssDeltaMb": -1.5,
"heapUsedDeltaMb": 16.9
}
}
```
### After: after change + snapshot-only sidebar
```json
{
"scenario": "snapshotOnly",
"workspaceCount": 22,
"elapsedMs": 31273,
"git": {
"total": 372,
"failed": 15,
"maxConcurrent": 8,
"byCommand": [
{ "key": "config --get remote.origin.url", "count": 35 },
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
{ "key": "rev-parse --git-common-dir", "count": 31 },
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
{ "key": "status --porcelain", "count": 22 },
{ "key": "ls-files --others --exclude-standard", "count": 18 },
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
{ "key": "merge-base HEAD origin/main", "count": 17 },
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
{ "key": "rev-parse --show-toplevel", "count": 17 },
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
{ "key": "rev-list --count main..origin/main", "count": 7 }
]
},
"process": {
"cpuUserMs": 1871,
"cpuSystemMs": 2152,
"rssDeltaMb": 4.4,
"heapUsedDeltaMb": 8.8
}
}
```
## 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:
- `rev-parse --show-toplevel`
- `rev-parse --abbrev-ref HEAD`
Test:
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
## Remaining Waste Visible In Baseline
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
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.
- 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`.
@@ -99,7 +99,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -1346,14 +1346,7 @@ function WorkspaceRowInner({
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspaceDirectory ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
});
const prHint = workspace.prHint;
const interaction = useLongPressDragInteraction({
drag,
menuController,
+14 -2
View File
@@ -21,6 +21,15 @@ export interface PrHint {
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
interface PrStatusLike {
url: string;
state: string;
isMerged: boolean;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
@@ -36,8 +45,7 @@ function parsePullRequestNumber(url: string): number | null {
}
}
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
const status = payload.status;
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
if (!status?.url) {
return null;
}
@@ -62,6 +70,10 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
};
}
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
return selectPrHintFromStatus(payload.status);
}
export function useCheckoutPrStatusQuery({
serverId,
cwd,
@@ -5,6 +5,7 @@ import {
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { selectPrHintFromStatus, type PrHint } from "@/git/use-pr-status-query";
import {
useWorkspaceStructure,
type WorkspaceStructureProject,
@@ -31,6 +32,7 @@ export interface SidebarWorkspaceEntry {
statusBucket: SidebarStateBucket;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
prHint: PrHint | null;
archiveHasUncommittedChanges: boolean | null;
archiveUnpushedCommitCount: number | null;
scripts: WorkspaceDescriptor["scripts"];
@@ -71,6 +73,7 @@ function createStructuralWorkspaceEntry(input: {
statusBucket: "done",
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
@@ -95,6 +98,7 @@ export function createSidebarWorkspaceEntry(input: {
statusBucket: input.workspace.status,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
@@ -20,6 +20,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
name: "paseo",
statusBucket: "done",
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
@@ -25,6 +25,7 @@ function workspace(input: {
statusBucket: "done",
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
@@ -0,0 +1,218 @@
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { copyFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { startGitCommandMetrics, stopGitCommandMetrics } from "../src/utils/run-git-command.js";
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
type Scenario = "snapshotOnly" | "legacyPrFanout";
interface BenchmarkResult {
scenario: Scenario;
sourceHome: string;
frozenHomeRoot: string;
workspaceCount: number;
elapsedMs: number;
git: {
total: number;
failed: number;
maxConcurrent: number;
byCommand: Array<{ key: string; count: number }>;
byCwd: Array<{ key: string; count: number }>;
};
process: {
cpuUserMs: number;
cpuSystemMs: number;
rssDeltaMb: number;
heapUsedDeltaMb: number;
};
}
function parseArgs(): { sourceHome: string; frozenHomeRoot: string | null; scenario: Scenario } {
let sourceHome = process.env.PASEO_BENCHMARK_SOURCE_HOME ?? path.join(os.homedir(), ".paseo");
let frozenHomeRoot = process.env.PASEO_BENCHMARK_FROZEN_HOME_ROOT ?? null;
let scenario = (process.env.PASEO_BENCHMARK_SCENARIO ?? "snapshotOnly") as Scenario;
for (const arg of process.argv.slice(2)) {
const [key, value] = arg.split("=", 2);
if (key === "--source-home" && value) sourceHome = value;
if (key === "--frozen-home-root" && value) frozenHomeRoot = value;
if (key === "--scenario" && (value === "snapshotOnly" || value === "legacyPrFanout")) {
scenario = value;
}
}
return { sourceHome, frozenHomeRoot, scenario };
}
function copyJsonTree(sourceDir: string, targetDir: string): void {
if (!existsSync(sourceDir)) {
return;
}
mkdirSync(targetDir, { recursive: true });
for (const entry of readdirSync(sourceDir)) {
const sourcePath = path.join(sourceDir, entry);
const targetPath = path.join(targetDir, entry);
const stat = statSync(sourcePath);
if (stat.isDirectory()) {
copyJsonTree(sourcePath, targetPath);
continue;
}
if (stat.isFile() && entry.endsWith(".json")) {
mkdirSync(path.dirname(targetPath), { recursive: true });
copyFileSync(sourcePath, targetPath);
}
}
}
async function freezeHome(sourceHome: string, requestedRoot: string | null): Promise<string> {
const frozenHomeRoot = requestedRoot ?? mkdtempSync(path.join(os.tmpdir(), "paseo-real-home-"));
if (process.env.PASEO_BENCHMARK_REUSE_FROZEN_HOME === "1") {
return frozenHomeRoot;
}
const frozenHome = path.join(frozenHomeRoot, ".paseo");
rmSync(frozenHome, { recursive: true, force: true });
mkdirSync(frozenHome, { recursive: true });
copyJsonTree(path.join(sourceHome, "agents"), path.join(frozenHome, "agents"));
copyJsonTree(path.join(sourceHome, "projects"), path.join(frozenHome, "projects"));
const configPath = path.join(sourceHome, "config.json");
if (existsSync(configPath)) {
await copyFile(configPath, path.join(frozenHome, "config.json"));
}
return frozenHomeRoot;
}
function topCounts(items: string[], limit = 20): Array<{ key: string; count: number }> {
const counts = new Map<string, number>();
for (const item of items) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
return Array.from(counts, ([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, limit);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
let timeout: NodeJS.Timeout | null = null;
try {
return await Promise.race([
promise,
new Promise<undefined>((resolve) => {
timeout = setTimeout(() => resolve(undefined), timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function main(): Promise<void> {
const { sourceHome, frozenHomeRoot: requestedFrozenRoot, scenario } = parseArgs();
const frozenHomeRoot = await freezeHome(sourceHome, requestedFrozenRoot);
const cpuBefore = process.cpuUsage();
const memoryBefore = process.memoryUsage();
const startedAt = performance.now();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot: frozenHomeRoot, cleanup: false });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.90",
});
let metrics = stopGitCommandMetrics();
try {
startGitCommandMetrics();
await client.connect();
const seen = new Set<string>();
client.on("checkout_status_update", (message) => seen.add(message.payload.cwd));
const workspaces = await client.fetchWorkspaces({
subscribe: { subscriptionId: `startup-git-${scenario}` },
sort: [{ key: "activity_at", direction: "desc" }],
page: { limit: 200 },
});
await client.fetchAgents({
scope: "active",
subscribe: { subscriptionId: `startup-agents-${scenario}` },
page: { limit: 200 },
});
const workspaceCwds = workspaces.entries
.map((entry) => entry.workspaceDirectory)
.filter((cwd): cwd is string => Boolean(cwd));
if (scenario === "legacyPrFanout") {
await Promise.all(workspaceCwds.map((cwd) => client.checkoutPrStatus(cwd).catch(() => null)));
}
const deadline = Date.now() + 30_000;
while (Date.now() < deadline && workspaceCwds.some((cwd) => !seen.has(cwd))) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 500));
metrics = stopGitCommandMetrics();
const elapsedMs = Math.round(performance.now() - startedAt);
const cpu = process.cpuUsage(cpuBefore);
const memoryAfter = process.memoryUsage();
const result: BenchmarkResult = {
scenario,
sourceHome,
frozenHomeRoot,
workspaceCount: workspaceCwds.length,
elapsedMs,
git: {
total: metrics.total,
failed: metrics.failed,
maxConcurrent: metrics.maxConcurrent,
byCommand: topCounts(metrics.commands.map((command) => command.args.join(" "))),
byCwd: topCounts(metrics.commands.map((command) => command.cwd)),
},
process: {
cpuUserMs: Math.round(cpu.user / 1000),
cpuSystemMs: Math.round(cpu.system / 1000),
rssDeltaMb: Number(((memoryAfter.rss - memoryBefore.rss) / 1024 / 1024).toFixed(1)),
heapUsedDeltaMb: Number(
((memoryAfter.heapUsed - memoryBefore.heapUsed) / 1024 / 1024).toFixed(1),
),
},
};
console.log(`REAL_HOME_STARTUP_GIT_BENCHMARK ${JSON.stringify(result)}`);
} finally {
if (metrics.total === 0) {
stopGitCommandMetrics();
}
await withTimeout(
client.close().catch(() => undefined),
1_000,
);
await withTimeout(
daemon.close().catch(() => undefined),
3_000,
);
if (!requestedFrozenRoot) {
await rm(frozenHomeRoot, { recursive: true, force: true });
} else {
await mkdir(frozenHomeRoot, { recursive: true });
}
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -4,7 +4,11 @@ import path, { join } from "node:path";
import type { FSWatcher } from "node:fs";
import type pino from "pino";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
import type {
CheckoutSnapshotFacts,
CheckoutStatusGit,
PullRequestStatusResult,
} from "../utils/checkout-git.js";
import {
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
@@ -102,6 +106,24 @@ function createCheckoutStatus(
};
}
function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts {
return {
isGit: true,
worktreeRoot: cwd,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
paseoWorktree: { isPaseoOwnedWorktree: false },
storedBaseRef: null,
resolvedBaseRef: "main",
mainRepoRoot: null,
comparisonBaseRef: null,
branchRemoteName: "origin",
branchMergeRef: "refs/heads/main",
trackedOriginBranch: "main",
pullRequestLookupTarget: { headRef: "main" },
};
}
function createPullRequestStatusResult(
overrides?: Partial<PullRequestStatusResult>,
): PullRequestStatusResult {
@@ -179,6 +201,7 @@ function createGitHubServiceStub(): GitHubService {
interface CreateServiceTestOptions {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
github?: GitHubService;
@@ -195,6 +218,7 @@ function buildDefaultTestServiceDeps() {
return {
watch: (() => createWatcher()) as unknown as typeof import("node:fs").watch,
readdir: vi.fn(async () => []),
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)),
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat: vi.fn(async () => ({
additions: 1,
@@ -8,9 +8,11 @@ import type { CheckoutContext } from "../utils/checkout-git.js";
import {
type BranchCheckoutResolution,
type BranchSuggestion,
type CheckoutSnapshotFacts,
type CheckoutDiffCompare,
type CheckoutDiffResult,
getCheckoutDiff,
getCheckoutSnapshotFacts,
getCheckoutShortstat,
getCheckoutStatus,
getPullRequestStatus,
@@ -231,6 +233,7 @@ type WorkspaceGitRefreshState =
interface WorkspaceGitServiceDependencies {
watch: typeof watch;
readdir: typeof readdir;
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getCheckoutDiff: typeof getCheckoutDiff;
@@ -308,6 +311,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
return {
watch,
readdir,
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutDiff,
@@ -1481,9 +1485,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
await this.refreshGitSnapshot(target, request);
const facts = await this.refreshGitSnapshot(target, request);
if (request.includeGitHub) {
await this.refreshGitHubSnapshot(target, request);
await this.refreshGitHubSnapshot(target, request, facts);
}
const snapshot = this.combineSnapshot(target);
@@ -1494,13 +1498,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private async refreshGitSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
): Promise<CheckoutSnapshotFacts> {
const now = this.deps.now();
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const facts = await this.deps.getCheckoutSnapshotFacts(cwd, baseContext);
const context: CheckoutContext = { ...baseContext, facts };
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
if (!checkoutStatus.isGit) {
target.latestGit = buildNotGitSnapshot(cwd).git;
@@ -1508,7 +1514,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.cachedGitHubRemote = null;
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
return;
return facts;
}
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
@@ -1537,11 +1543,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
}
return facts;
}
private async refreshGitHubSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
facts: CheckoutSnapshotFacts,
): Promise<void> {
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
const forceGitHub = request.force && request.includeGitHub;
@@ -1556,6 +1564,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
deps: this.deps,
force: forceGitHub,
reason: request.reason,
facts,
});
target.latestGithubLoadedAtMs = this.deps.now().getTime();
}
@@ -1768,6 +1777,7 @@ async function loadGitHubSnapshot(options: {
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
force?: boolean;
reason?: string;
facts?: CheckoutSnapshotFacts;
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
if (!options.githubRemote) {
return {
@@ -1788,10 +1798,15 @@ async function loadGitHubSnapshot(options: {
}
try {
const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github, {
force: options.force,
reason: options.reason,
});
const result = await options.deps.getPullRequestStatus(
options.cwd,
options.deps.github,
{
force: options.force,
reason: options.reason,
},
{ facts: options.facts },
);
return {
featuresEnabled: true,
pullRequest: result.status,
+19
View File
@@ -2338,12 +2338,31 @@ const WorkspaceGitHubRuntimePayloadSchema = z
featuresEnabled: z.boolean().optional(),
pullRequest: z
.object({
number: z.number().optional(),
url: z.string(),
title: z.string(),
state: z.string(),
baseRefName: z.string(),
headRefName: z.string(),
isMerged: z.boolean(),
isDraft: z.boolean().optional(),
mergeable: z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN").optional(),
checks: z
.array(
z.object({
name: z.string(),
status: z.enum(["success", "failure", "pending", "skipped", "cancelled"]),
url: z.string().nullable(),
workflow: z.string().optional(),
duration: z.string().optional(),
}),
)
.optional(),
checksStatus: z.enum(["none", "pending", "success", "failure"]).optional(),
reviewDecision: z.enum(["approved", "changes_requested", "pending"]).nullable().optional(),
repoOwner: z.string().optional(),
repoName: z.string().optional(),
github: z.unknown().optional(),
})
.nullable()
.optional(),
@@ -17,6 +17,7 @@ import {
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCachedCheckoutShortstat,
getCheckoutSnapshotFacts,
getCurrentBranch,
getCheckoutDiff,
getCheckoutShortstat,
@@ -39,6 +40,7 @@ import {
isDescendantPath,
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
import {
GitHubCommandError,
GitHubCliMissingError,
@@ -329,6 +331,48 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file");
});
it("reuses checkout snapshot facts across status, shortstat, and PR status reads", async () => {
setupRemoteTrackingMain(repoDir, tempDir);
execFileSync("git", ["checkout", "-b", "feature/facts"], { cwd: repoDir });
commitFile(repoDir, "feature.txt", "feature\n", "feature");
writeFileSync(join(repoDir, "feature.txt"), "feature\nchanged\n");
const github = createGitHubServiceForStatus(createPullRequestStatus());
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
const status = await getCheckoutStatus(repoDir, { paseoHome, facts });
const shortstat = await getCheckoutShortstat(repoDir, { paseoHome, facts }, { force: true });
const prStatus = await getPullRequestStatus(
repoDir,
github,
{ force: true, reason: "snapshot-equivalence" },
{ paseoHome, facts },
);
__resetCheckoutShortstatCacheForTests();
__resetPullRequestStatusCacheForTests();
startGitCommandMetrics();
const statusWithFacts = await getCheckoutStatus(repoDir, { paseoHome, facts });
const shortstatWithFacts = await getCheckoutShortstat(
repoDir,
{ paseoHome, facts },
{ force: true },
);
const prStatusWithFacts = await getPullRequestStatus(
repoDir,
github,
{ force: true, reason: "snapshot-equivalence-with-facts" },
{ paseoHome, facts },
);
const metrics = stopGitCommandMetrics();
const commands = metrics.commands.map((command) => command.args.join(" "));
expect(statusWithFacts).toEqual(status);
expect(shortstatWithFacts).toEqual(shortstat);
expect(prStatusWithFacts).toEqual(prStatus);
expect(commands).not.toContain("rev-parse --show-toplevel");
expect(commands).not.toContain("rev-parse --abbrev-ref HEAD");
});
it("hides whitespace-only changes when requested", async () => {
writeFileSync(join(repoDir, "file.txt"), "hello \n");
+220 -36
View File
@@ -723,8 +723,29 @@ export interface MergeFromBaseOptions {
export interface CheckoutContext {
paseoHome?: string;
logger?: Pick<Logger, "trace">;
facts?: CheckoutSnapshotFacts | null;
}
export type CheckoutSnapshotFacts =
| {
isGit: false;
}
| {
isGit: true;
worktreeRoot: string;
currentBranch: string | null;
remoteUrl: string | null;
paseoWorktree: PaseoWorktreeForCwd;
storedBaseRef: string | null;
resolvedBaseRef: string | null;
mainRepoRoot: string | null;
comparisonBaseRef: string | null;
branchRemoteName: string | null;
branchMergeRef: string | null;
trackedOriginBranch: string | null;
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
};
function isGitError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
@@ -915,6 +936,7 @@ type PaseoWorktreeForCwd =
async function getPaseoWorktreeForCwd(
cwd: string,
context?: CheckoutContext,
knownWorktreeRoot?: string | null,
): Promise<PaseoWorktreeForCwd> {
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
@@ -928,7 +950,7 @@ async function getPaseoWorktreeForCwd(
return {
isPaseoOwnedWorktree: true,
worktreeRoot: (await getWorktreeRoot(cwd)) ?? cwd,
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd,
};
}
@@ -940,6 +962,9 @@ async function getStoredBaseRefForCwd(
cwd: string,
context?: CheckoutContext,
): Promise<string | null> {
if (context?.facts?.isGit) {
return context.facts.storedBaseRef;
}
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
if (!paseoWorktree.isPaseoOwnedWorktree) {
return null;
@@ -952,6 +977,9 @@ async function getResolvedBaseRefForCwd(
cwd: string,
context?: CheckoutContext,
): Promise<string | null> {
if (context?.facts?.isGit) {
return context.facts.resolvedBaseRef;
}
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
return resolvedBaseRef;
}
@@ -965,6 +993,12 @@ async function resolveBaseRefForCwd(
cwd: string,
context?: CheckoutContext,
): Promise<BaseRefResolution> {
if (context?.facts?.isGit) {
return {
storedBaseRef: context.facts.storedBaseRef,
resolvedBaseRef: context.facts.resolvedBaseRef,
};
}
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
return {
storedBaseRef,
@@ -1029,7 +1063,11 @@ function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
async function resolvePullRequestStatusLookupTarget(
cwd: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<PullRequestStatusLookupTarget> {
if (context?.facts?.isGit && context.facts.pullRequestLookupTarget) {
return context.facts.pullRequestLookupTarget;
}
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
if (!remoteName?.startsWith("paseo-pr-")) {
return { headRef: currentBranch };
@@ -1251,7 +1289,13 @@ async function getAheadBehind(
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
return null;
}
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef, context);
const comparisonBaseRef =
context?.facts?.isGit && context.facts.resolvedBaseRef === baseRef
? context.facts.comparisonBaseRef
: await resolveBestComparisonBaseRef(cwd, baseRef, context);
if (!comparisonBaseRef) {
return null;
}
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
@@ -1309,6 +1353,9 @@ async function getTrackedOriginBranch(
currentBranch: string,
context?: CheckoutContext,
): Promise<string | null> {
if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) {
return context.facts.trackedOriginBranch;
}
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
if (remoteName !== "origin") {
return null;
@@ -1358,7 +1405,7 @@ async function inspectCheckoutContext(
const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([
getCurrentBranch(cwd),
getOriginRemoteUrl(cwd),
getPaseoWorktreeForCwd(cwd, context),
getPaseoWorktreeForCwd(cwd, context, root),
]);
return {
@@ -1375,6 +1422,108 @@ async function inspectCheckoutContext(
}
}
function buildPullRequestLookupTargetFromBranchConfig(input: {
currentBranch: string;
branchRemoteName: string | null;
branchMergeRef: string | null;
branchRemoteUrl: string | null;
}): PullRequestStatusLookupTarget {
if (!input.branchRemoteName?.startsWith("paseo-pr-")) {
return { headRef: input.currentBranch };
}
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
if (!trackedHeadRef) {
return { headRef: input.currentBranch };
}
const remoteRepo = input.branchRemoteUrl
? parseGitHubRepoFromRemote(input.branchRemoteUrl)
: null;
const headRepositoryOwner = remoteRepo?.split("/")[0];
return {
headRef: trackedHeadRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
export async function getCheckoutSnapshotFacts(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutSnapshotFacts> {
if (context?.facts) {
return context.facts;
}
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
return { isGit: false };
}
const storedBaseRef = inspected.paseoWorktree.isPaseoOwnedWorktree
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
: null;
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
let comparisonBaseRef: string | null = null;
if (
resolvedBaseRef &&
inspected.currentBranch &&
normalizeLocalBranchRefName(resolvedBaseRef) !== inspected.currentBranch
) {
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
() => null,
);
}
let branchRemoteName: string | null = null;
let branchMergeRef: string | null = null;
let branchRemoteUrl: string | null = null;
if (inspected.remoteUrl && inspected.currentBranch) {
branchRemoteName = await getGitConfigValue(
cwd,
`branch.${inspected.currentBranch}.remote`,
context,
);
if (branchRemoteName) {
branchMergeRef = await getGitConfigValue(
cwd,
`branch.${inspected.currentBranch}.merge`,
context,
);
if (branchRemoteName.startsWith("paseo-pr-")) {
branchRemoteUrl = await getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context);
}
}
}
const trackedOriginBranch =
branchRemoteName === "origin" ? parseBranchMergeHeadRef(branchMergeRef) : null;
const pullRequestLookupTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromBranchConfig({
currentBranch: inspected.currentBranch,
branchRemoteName,
branchMergeRef,
branchRemoteUrl,
})
: null;
return {
isGit: true,
worktreeRoot: inspected.worktreeRoot,
currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl,
paseoWorktree: inspected.paseoWorktree,
storedBaseRef,
resolvedBaseRef,
mainRepoRoot,
comparisonBaseRef,
branchRemoteName,
branchMergeRef,
trackedOriginBranch,
pullRequestLookupTarget,
};
}
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
@@ -1489,28 +1638,29 @@ export async function getCheckoutStatus(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutStatusResult> {
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
const facts = await getCheckoutSnapshotFacts(cwd, context);
if (!facts.isGit) {
return { isGit: false };
}
const worktreeRoot = inspected.worktreeRoot;
const currentBranch = inspected.currentBranch;
const remoteUrl = inspected.remoteUrl;
const paseoWorktree = inspected.paseoWorktree;
const worktreeRoot = facts.worktreeRoot;
const currentBranch = facts.currentBranch;
const remoteUrl = facts.remoteUrl;
const paseoWorktree = facts.paseoWorktree;
const isDirty = await isWorkingTreeDirty(cwd, context);
const hasRemote = remoteUrl !== null;
const { resolvedBaseRef: baseRef } = await resolveBaseRefForCwd(cwd, context);
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
const baseRef = facts.resolvedBaseRef;
const mainRepoRoot = facts.mainRepoRoot;
const factsContext = { ...context, facts };
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
baseRef && currentBranch
? getAheadBehind(cwd, baseRef, currentBranch, context)
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
: Promise.resolve(null),
hasRemote && currentBranch
? getAheadOfOrigin(cwd, currentBranch, baseRef, context)
? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext)
: Promise.resolve(null),
hasRemote && currentBranch
? getBehindOfOrigin(cwd, currentBranch, context)
? getBehindOfOrigin(cwd, currentBranch, factsContext)
: Promise.resolve(null),
]);
@@ -1616,30 +1766,29 @@ async function getCheckoutShortstatUncached(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutShortstat | null> {
try {
await requireGitRepo(cwd);
} catch {
if (context?.facts?.isGit === false) {
return null;
}
const localBaseRef = await getResolvedBaseRefForCwd(cwd, context);
const currentBranch = await getCurrentBranch(cwd);
let comparisonRef: string;
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
if (!context?.facts?.isGit) {
try {
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
await requireGitRepo(cwd);
} catch {
return null;
}
} else if (currentBranch) {
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
if (!hasOrigin) {
return null;
}
comparisonRef = `origin/${currentBranch}`;
} else {
}
const facts = context?.facts;
const localBaseRef = facts?.isGit
? facts.resolvedBaseRef
: await getResolvedBaseRefForCwd(cwd, context);
const currentBranch = facts?.isGit ? facts.currentBranch : await getCurrentBranch(cwd);
const comparisonRef = await resolveShortstatComparisonRef({
cwd,
currentBranch,
localBaseRef,
facts,
});
if (!comparisonRef) {
return null;
}
@@ -1675,6 +1824,31 @@ async function getCheckoutShortstatUncached(
}
}
async function resolveShortstatComparisonRef(input: {
cwd: string;
currentBranch: string | null;
localBaseRef: string | null;
facts?: CheckoutSnapshotFacts | null;
}): Promise<string | null> {
const { cwd, currentBranch, localBaseRef, facts } = input;
if (!currentBranch) {
return null;
}
if (localBaseRef && currentBranch !== localBaseRef) {
try {
return facts?.isGit && facts.resolvedBaseRef === localBaseRef && facts.comparisonBaseRef
? facts.comparisonBaseRef
: await resolveBestComparisonBaseRef(cwd, localBaseRef);
} catch {
return null;
}
}
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
return hasOrigin ? `origin/${currentBranch}` : null;
}
function getOrLoadCheckoutShortstat(
cwd: string,
context?: CheckoutContext,
@@ -2460,6 +2634,7 @@ export async function getPullRequestStatus(
cwd: string,
github: GitHubService = createGitHubService(),
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
const cacheKey = getPullRequestStatusCacheKey(cwd);
if (!options?.force) {
@@ -2474,7 +2649,7 @@ export async function getPullRequestStatus(
}
}
const lookup = getPullRequestStatusUncached(cwd, github, options)
const lookup = getPullRequestStatusUncached(cwd, github, options, context)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
@@ -2501,9 +2676,18 @@ async function getPullRequestStatusUncached(
cwd: string,
github: GitHubService,
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd);
const head = await getCurrentBranch(cwd);
if (context?.facts?.isGit === false) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
if (!context?.facts?.isGit) {
await requireGitRepo(cwd);
}
const head = context?.facts?.isGit ? context.facts.currentBranch : await getCurrentBranch(cwd);
if (!head) {
return {
status: null,
@@ -2511,7 +2695,7 @@ async function getPullRequestStatusUncached(
};
}
try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
let status: GitHubCurrentPullRequestStatus | null;
if (options?.force) {
const reason = options.reason;
@@ -29,6 +29,79 @@ export interface GitCommandResult {
signal: NodeJS.Signals | null;
}
export interface GitCommandMetric {
args: string[];
cwd: string;
startedAtMs: number;
durationMs: number;
exitCode: number | null;
signal: NodeJS.Signals | null;
success: boolean;
}
export interface GitCommandMetricsSnapshot {
commands: GitCommandMetric[];
total: number;
failed: number;
maxConcurrent: number;
}
interface GitCommandMetricsState {
commands: GitCommandMetric[];
active: number;
maxConcurrent: number;
}
let gitCommandMetricsState: GitCommandMetricsState | null = null;
export function startGitCommandMetrics(): void {
gitCommandMetricsState = {
commands: [],
active: 0,
maxConcurrent: 0,
};
}
export function stopGitCommandMetrics(): GitCommandMetricsSnapshot {
const state = gitCommandMetricsState;
gitCommandMetricsState = null;
if (!state) {
return {
commands: [],
total: 0,
failed: 0,
maxConcurrent: 0,
};
}
return {
commands: [...state.commands],
total: state.commands.length,
failed: state.commands.filter((command) => !command.success).length,
maxConcurrent: state.maxConcurrent,
};
}
function beginGitCommandMetric(): GitCommandMetricsState | null {
const state = gitCommandMetricsState;
if (!state) {
return null;
}
state.active += 1;
state.maxConcurrent = Math.max(state.maxConcurrent, state.active);
return state;
}
function finishGitCommandMetric(
state: GitCommandMetricsState | null,
metric: GitCommandMetric,
): void {
if (!state) {
return;
}
state.active = Math.max(0, state.active - 1);
state.commands.push(metric);
}
function mergeEnvOverlays(
env: ProcessEnvRecord | undefined,
envOverlay: ProcessEnvRecord | undefined,
@@ -59,6 +132,7 @@ export function runGitCommand(
const command = formatGitCommand(args);
const envOverlay = mergeEnvOverlays(options.env, options.envOverlay);
const startedAt = Date.now();
const metricsState = beginGitCommandMetric();
const logger = typeof options.logger?.trace === "function" ? options.logger : undefined;
const traceContext = logger
? {
@@ -87,6 +161,7 @@ export function runGitCommand(
});
let settled = false;
let metricFinished = false;
let truncated = false;
let stdoutBytes = 0;
let stderrBytes = 0;
@@ -100,9 +175,24 @@ export function runGitCommand(
callback();
};
const finishMetricOnce = (metric: GitCommandMetric) => {
if (metricFinished) return;
metricFinished = true;
finishGitCommandMetric(metricsState, metric);
};
const timer = setTimeout(() => {
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
child.kill("SIGKILL");
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: null,
signal: "SIGKILL",
success: false,
});
settle(() => reject(error));
}, timeout);
@@ -147,6 +237,15 @@ export function runGitCommand(
});
child.on("error", (error) => {
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: null,
signal: null,
success: false,
});
if (logger && traceContext) {
logger.trace(
{
@@ -184,6 +283,15 @@ export function runGitCommand(
}
if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) {
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode,
signal,
success: false,
});
const stderrPreview = result.stderr.trim() || "(no stderr)";
const truncationNote = result.truncated ? " (stdout truncated)" : "";
@@ -197,6 +305,15 @@ export function runGitCommand(
return;
}
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode,
signal,
success: true,
});
settle(() => resolve(result));
});
}),