diff --git a/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md b/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md new file mode 100644 index 000000000..af23e1f26 --- /dev/null +++ b/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md @@ -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`. diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 72e3330a6..974c734c7 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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, diff --git a/packages/app/src/git/use-pr-status-query.ts b/packages/app/src/git/use-pr-status-query.ts index 3a3dc9391..64c5de284 100644 --- a/packages/app/src/git/use-pr-status-query.ts +++ b/packages/app/src/git/use-pr-status-query.ts @@ -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, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 963b4041c..a8a3d5730 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -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, diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 461357058..d60d38ceb 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -20,6 +20,7 @@ function workspace(overrides: Partial = {}): SidebarWorks name: "paseo", statusBucket: "done", diffStat: null, + prHint: null, archiveHasUncommittedChanges: null, archiveUnpushedCommitCount: null, scripts: [], diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index b9e3c0ec4..403653eda 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -25,6 +25,7 @@ function workspace(input: { statusBucket: "done", archivingAt: null, diffStat: null, + prHint: null, archiveHasUncommittedChanges: null, archiveUnpushedCommitCount: null, scripts: [], diff --git a/packages/server/scripts/benchmark-startup-git-real-home.ts b/packages/server/scripts/benchmark-startup-git-real-home.ts new file mode 100644 index 000000000..ba1d1129e --- /dev/null +++ b/packages/server/scripts/benchmark-startup-git-real-home.ts @@ -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 { + 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(); + 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(promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(undefined), timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +async function main(): Promise { + 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(); + 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; +}); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 974bf5eb4..610f6dfa6 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -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 { @@ -179,6 +201,7 @@ function createGitHubServiceStub(): GitHubService { interface CreateServiceTestOptions { getCheckoutStatus?: ReturnType; + getCheckoutSnapshotFacts?: ReturnType; getCheckoutShortstat?: ReturnType; getPullRequestStatus?: ReturnType; 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, diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 3d00cab32..d9acd9284 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -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 { - 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 { + ): Promise { 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 { 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; force?: boolean; reason?: string; + facts?: CheckoutSnapshotFacts; }): Promise { 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, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index d7894503f..41c37e334 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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(), diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index d8042d29b..23bb84168 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -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"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 4e4dee915..832fde454 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -723,8 +723,29 @@ export interface MergeFromBaseOptions { export interface CheckoutContext { paseoHome?: string; logger?: Pick; + 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 { // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { - 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 { - 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 { + 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 { 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 { - 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; diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index bef9b73be..d2b1fb9f5 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -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)); }); }),