Reduce startup git work for workspace snapshots
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user