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 type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
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 { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
@@ -1346,14 +1346,7 @@ function WorkspaceRowInner({
|
|||||||
const _isCompact = useIsCompactFormFactor();
|
const _isCompact = useIsCompactFormFactor();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isTouchPlatform = platformIsNative;
|
const isTouchPlatform = platformIsNative;
|
||||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
const prHint = workspace.prHint;
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
|
||||||
});
|
|
||||||
const prHint = useWorkspacePrHint({
|
|
||||||
serverId: workspace.serverId,
|
|
||||||
cwd: workspaceDirectory ?? "",
|
|
||||||
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
|
|
||||||
});
|
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
menuController,
|
menuController,
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export interface PrHint {
|
|||||||
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
|
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 {
|
function parsePullRequestNumber(url: string): number | null {
|
||||||
try {
|
try {
|
||||||
const pathname = new URL(url).pathname;
|
const pathname = new URL(url).pathname;
|
||||||
@@ -36,8 +45,7 @@ function parsePullRequestNumber(url: string): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
|
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
|
||||||
const status = payload.status;
|
|
||||||
if (!status?.url) {
|
if (!status?.url) {
|
||||||
return null;
|
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({
|
export function useCheckoutPrStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
|
import { selectPrHintFromStatus, type PrHint } from "@/git/use-pr-status-query";
|
||||||
import {
|
import {
|
||||||
useWorkspaceStructure,
|
useWorkspaceStructure,
|
||||||
type WorkspaceStructureProject,
|
type WorkspaceStructureProject,
|
||||||
@@ -31,6 +32,7 @@ export interface SidebarWorkspaceEntry {
|
|||||||
statusBucket: SidebarStateBucket;
|
statusBucket: SidebarStateBucket;
|
||||||
archivingAt: string | null;
|
archivingAt: string | null;
|
||||||
diffStat: { additions: number; deletions: number } | null;
|
diffStat: { additions: number; deletions: number } | null;
|
||||||
|
prHint: PrHint | null;
|
||||||
archiveHasUncommittedChanges: boolean | null;
|
archiveHasUncommittedChanges: boolean | null;
|
||||||
archiveUnpushedCommitCount: number | null;
|
archiveUnpushedCommitCount: number | null;
|
||||||
scripts: WorkspaceDescriptor["scripts"];
|
scripts: WorkspaceDescriptor["scripts"];
|
||||||
@@ -71,6 +73,7 @@ function createStructuralWorkspaceEntry(input: {
|
|||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
|
prHint: null,
|
||||||
archiveHasUncommittedChanges: null,
|
archiveHasUncommittedChanges: null,
|
||||||
archiveUnpushedCommitCount: null,
|
archiveUnpushedCommitCount: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
@@ -95,6 +98,7 @@ export function createSidebarWorkspaceEntry(input: {
|
|||||||
statusBucket: input.workspace.status,
|
statusBucket: input.workspace.status,
|
||||||
archivingAt: input.workspace.archivingAt,
|
archivingAt: input.workspace.archivingAt,
|
||||||
diffStat: input.workspace.diffStat,
|
diffStat: input.workspace.diffStat,
|
||||||
|
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
|
||||||
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
|
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
|
||||||
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
|
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
|
||||||
scripts: input.workspace.scripts,
|
scripts: input.workspace.scripts,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
|||||||
name: "paseo",
|
name: "paseo",
|
||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
|
prHint: null,
|
||||||
archiveHasUncommittedChanges: null,
|
archiveHasUncommittedChanges: null,
|
||||||
archiveUnpushedCommitCount: null,
|
archiveUnpushedCommitCount: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function workspace(input: {
|
|||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
archivingAt: null,
|
archivingAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
|
prHint: null,
|
||||||
archiveHasUncommittedChanges: null,
|
archiveHasUncommittedChanges: null,
|
||||||
archiveUnpushedCommitCount: null,
|
archiveUnpushedCommitCount: null,
|
||||||
scripts: [],
|
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 { FSWatcher } from "node:fs";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
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 {
|
import {
|
||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
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(
|
function createPullRequestStatusResult(
|
||||||
overrides?: Partial<PullRequestStatusResult>,
|
overrides?: Partial<PullRequestStatusResult>,
|
||||||
): PullRequestStatusResult {
|
): PullRequestStatusResult {
|
||||||
@@ -179,6 +201,7 @@ function createGitHubServiceStub(): GitHubService {
|
|||||||
|
|
||||||
interface CreateServiceTestOptions {
|
interface CreateServiceTestOptions {
|
||||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||||
|
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||||
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
||||||
github?: GitHubService;
|
github?: GitHubService;
|
||||||
@@ -195,6 +218,7 @@ function buildDefaultTestServiceDeps() {
|
|||||||
return {
|
return {
|
||||||
watch: (() => createWatcher()) as unknown as typeof import("node:fs").watch,
|
watch: (() => createWatcher()) as unknown as typeof import("node:fs").watch,
|
||||||
readdir: vi.fn(async () => []),
|
readdir: vi.fn(async () => []),
|
||||||
|
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)),
|
||||||
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
|
||||||
getCheckoutShortstat: vi.fn(async () => ({
|
getCheckoutShortstat: vi.fn(async () => ({
|
||||||
additions: 1,
|
additions: 1,
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import type { CheckoutContext } from "../utils/checkout-git.js";
|
|||||||
import {
|
import {
|
||||||
type BranchCheckoutResolution,
|
type BranchCheckoutResolution,
|
||||||
type BranchSuggestion,
|
type BranchSuggestion,
|
||||||
|
type CheckoutSnapshotFacts,
|
||||||
type CheckoutDiffCompare,
|
type CheckoutDiffCompare,
|
||||||
type CheckoutDiffResult,
|
type CheckoutDiffResult,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getPullRequestStatus,
|
getPullRequestStatus,
|
||||||
@@ -231,6 +233,7 @@ type WorkspaceGitRefreshState =
|
|||||||
interface WorkspaceGitServiceDependencies {
|
interface WorkspaceGitServiceDependencies {
|
||||||
watch: typeof watch;
|
watch: typeof watch;
|
||||||
readdir: typeof readdir;
|
readdir: typeof readdir;
|
||||||
|
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
|
||||||
getCheckoutStatus: typeof getCheckoutStatus;
|
getCheckoutStatus: typeof getCheckoutStatus;
|
||||||
getCheckoutShortstat: typeof getCheckoutShortstat;
|
getCheckoutShortstat: typeof getCheckoutShortstat;
|
||||||
getCheckoutDiff: typeof getCheckoutDiff;
|
getCheckoutDiff: typeof getCheckoutDiff;
|
||||||
@@ -308,6 +311,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
|
|||||||
return {
|
return {
|
||||||
watch,
|
watch,
|
||||||
readdir,
|
readdir,
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
@@ -1481,9 +1485,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
await this.refreshGitSnapshot(target, request);
|
const facts = await this.refreshGitSnapshot(target, request);
|
||||||
if (request.includeGitHub) {
|
if (request.includeGitHub) {
|
||||||
await this.refreshGitHubSnapshot(target, request);
|
await this.refreshGitHubSnapshot(target, request, facts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.combineSnapshot(target);
|
const snapshot = this.combineSnapshot(target);
|
||||||
@@ -1494,13 +1498,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private async refreshGitSnapshot(
|
private async refreshGitSnapshot(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<void> {
|
): Promise<CheckoutSnapshotFacts> {
|
||||||
const now = this.deps.now();
|
const now = this.deps.now();
|
||||||
target.lastShellOutAtMs = now.getTime();
|
target.lastShellOutAtMs = now.getTime();
|
||||||
|
|
||||||
const cwd = target.cwd;
|
const cwd = target.cwd;
|
||||||
const previousGitHubPollKey = this.getGitHubPollKey(target);
|
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);
|
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
||||||
if (!checkoutStatus.isGit) {
|
if (!checkoutStatus.isGit) {
|
||||||
target.latestGit = buildNotGitSnapshot(cwd).git;
|
target.latestGit = buildNotGitSnapshot(cwd).git;
|
||||||
@@ -1508,7 +1514,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.cachedGitHubRemote = null;
|
target.cachedGitHubRemote = null;
|
||||||
target.latestGithub = buildGitHubUnavailableSnapshot();
|
target.latestGithub = buildGitHubUnavailableSnapshot();
|
||||||
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
|
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
|
||||||
return;
|
return facts;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
|
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
|
||||||
@@ -1537,11 +1543,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.latestGithub = buildGitHubUnavailableSnapshot();
|
target.latestGithub = buildGitHubUnavailableSnapshot();
|
||||||
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
|
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
|
||||||
}
|
}
|
||||||
|
return facts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshGitHubSnapshot(
|
private async refreshGitHubSnapshot(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
|
facts: CheckoutSnapshotFacts,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
|
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
|
||||||
const forceGitHub = request.force && request.includeGitHub;
|
const forceGitHub = request.force && request.includeGitHub;
|
||||||
@@ -1556,6 +1564,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
deps: this.deps,
|
deps: this.deps,
|
||||||
force: forceGitHub,
|
force: forceGitHub,
|
||||||
reason: request.reason,
|
reason: request.reason,
|
||||||
|
facts,
|
||||||
});
|
});
|
||||||
target.latestGithubLoadedAtMs = this.deps.now().getTime();
|
target.latestGithubLoadedAtMs = this.deps.now().getTime();
|
||||||
}
|
}
|
||||||
@@ -1768,6 +1777,7 @@ async function loadGitHubSnapshot(options: {
|
|||||||
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
|
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
facts?: CheckoutSnapshotFacts;
|
||||||
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
|
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
|
||||||
if (!options.githubRemote) {
|
if (!options.githubRemote) {
|
||||||
return {
|
return {
|
||||||
@@ -1788,10 +1798,15 @@ async function loadGitHubSnapshot(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github, {
|
const result = await options.deps.getPullRequestStatus(
|
||||||
force: options.force,
|
options.cwd,
|
||||||
reason: options.reason,
|
options.deps.github,
|
||||||
});
|
{
|
||||||
|
force: options.force,
|
||||||
|
reason: options.reason,
|
||||||
|
},
|
||||||
|
{ facts: options.facts },
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
featuresEnabled: true,
|
featuresEnabled: true,
|
||||||
pullRequest: result.status,
|
pullRequest: result.status,
|
||||||
|
|||||||
@@ -2338,12 +2338,31 @@ const WorkspaceGitHubRuntimePayloadSchema = z
|
|||||||
featuresEnabled: z.boolean().optional(),
|
featuresEnabled: z.boolean().optional(),
|
||||||
pullRequest: z
|
pullRequest: z
|
||||||
.object({
|
.object({
|
||||||
|
number: z.number().optional(),
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
state: z.string(),
|
state: z.string(),
|
||||||
baseRefName: z.string(),
|
baseRefName: z.string(),
|
||||||
headRefName: z.string(),
|
headRefName: z.string(),
|
||||||
isMerged: z.boolean(),
|
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()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
__setPullRequestStatusCacheTtlForTests,
|
__setPullRequestStatusCacheTtlForTests,
|
||||||
commitAll,
|
commitAll,
|
||||||
getCachedCheckoutShortstat,
|
getCachedCheckoutShortstat,
|
||||||
|
getCheckoutSnapshotFacts,
|
||||||
getCurrentBranch,
|
getCurrentBranch,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
isDescendantPath,
|
isDescendantPath,
|
||||||
warmCheckoutShortstatInBackground,
|
warmCheckoutShortstatInBackground,
|
||||||
} from "./checkout-git.js";
|
} from "./checkout-git.js";
|
||||||
|
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
||||||
import {
|
import {
|
||||||
GitHubCommandError,
|
GitHubCommandError,
|
||||||
GitHubCliMissingError,
|
GitHubCliMissingError,
|
||||||
@@ -329,6 +331,48 @@ describe("checkout git utilities", () => {
|
|||||||
expect(message).toBe("update file");
|
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 () => {
|
it("hides whitespace-only changes when requested", async () => {
|
||||||
writeFileSync(join(repoDir, "file.txt"), "hello \n");
|
writeFileSync(join(repoDir, "file.txt"), "hello \n");
|
||||||
|
|
||||||
|
|||||||
@@ -723,8 +723,29 @@ export interface MergeFromBaseOptions {
|
|||||||
export interface CheckoutContext {
|
export interface CheckoutContext {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
logger?: Pick<Logger, "trace">;
|
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 {
|
function isGitError(error: unknown): boolean {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -915,6 +936,7 @@ type PaseoWorktreeForCwd =
|
|||||||
async function getPaseoWorktreeForCwd(
|
async function getPaseoWorktreeForCwd(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
|
knownWorktreeRoot?: string | null,
|
||||||
): Promise<PaseoWorktreeForCwd> {
|
): Promise<PaseoWorktreeForCwd> {
|
||||||
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||||
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||||
@@ -928,7 +950,7 @@ async function getPaseoWorktreeForCwd(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isPaseoOwnedWorktree: true,
|
isPaseoOwnedWorktree: true,
|
||||||
worktreeRoot: (await getWorktreeRoot(cwd)) ?? cwd,
|
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,6 +962,9 @@ async function getStoredBaseRefForCwd(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
if (context?.facts?.isGit) {
|
||||||
|
return context.facts.storedBaseRef;
|
||||||
|
}
|
||||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
|
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
|
||||||
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
||||||
return null;
|
return null;
|
||||||
@@ -952,6 +977,9 @@ async function getResolvedBaseRefForCwd(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
if (context?.facts?.isGit) {
|
||||||
|
return context.facts.resolvedBaseRef;
|
||||||
|
}
|
||||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||||
return resolvedBaseRef;
|
return resolvedBaseRef;
|
||||||
}
|
}
|
||||||
@@ -965,6 +993,12 @@ async function resolveBaseRefForCwd(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<BaseRefResolution> {
|
): Promise<BaseRefResolution> {
|
||||||
|
if (context?.facts?.isGit) {
|
||||||
|
return {
|
||||||
|
storedBaseRef: context.facts.storedBaseRef,
|
||||||
|
resolvedBaseRef: context.facts.resolvedBaseRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
|
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
|
||||||
return {
|
return {
|
||||||
storedBaseRef,
|
storedBaseRef,
|
||||||
@@ -1029,7 +1063,11 @@ function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
|
|||||||
async function resolvePullRequestStatusLookupTarget(
|
async function resolvePullRequestStatusLookupTarget(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
|
context?: CheckoutContext,
|
||||||
): Promise<PullRequestStatusLookupTarget> {
|
): Promise<PullRequestStatusLookupTarget> {
|
||||||
|
if (context?.facts?.isGit && context.facts.pullRequestLookupTarget) {
|
||||||
|
return context.facts.pullRequestLookupTarget;
|
||||||
|
}
|
||||||
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
|
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
|
||||||
if (!remoteName?.startsWith("paseo-pr-")) {
|
if (!remoteName?.startsWith("paseo-pr-")) {
|
||||||
return { headRef: currentBranch };
|
return { headRef: currentBranch };
|
||||||
@@ -1251,7 +1289,13 @@ async function getAheadBehind(
|
|||||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||||
return null;
|
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(
|
const { stdout } = await runGitCommand(
|
||||||
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
|
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
|
||||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||||
@@ -1309,6 +1353,9 @@ async function getTrackedOriginBranch(
|
|||||||
currentBranch: string,
|
currentBranch: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) {
|
||||||
|
return context.facts.trackedOriginBranch;
|
||||||
|
}
|
||||||
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
|
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
|
||||||
if (remoteName !== "origin") {
|
if (remoteName !== "origin") {
|
||||||
return null;
|
return null;
|
||||||
@@ -1358,7 +1405,7 @@ async function inspectCheckoutContext(
|
|||||||
const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([
|
const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([
|
||||||
getCurrentBranch(cwd),
|
getCurrentBranch(cwd),
|
||||||
getOriginRemoteUrl(cwd),
|
getOriginRemoteUrl(cwd),
|
||||||
getPaseoWorktreeForCwd(cwd, context),
|
getPaseoWorktreeForCwd(cwd, context, root),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
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 PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||||
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
||||||
@@ -1489,28 +1638,29 @@ export async function getCheckoutStatus(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<CheckoutStatusResult> {
|
): Promise<CheckoutStatusResult> {
|
||||||
const inspected = await inspectCheckoutContext(cwd, context);
|
const facts = await getCheckoutSnapshotFacts(cwd, context);
|
||||||
if (!inspected) {
|
if (!facts.isGit) {
|
||||||
return { isGit: false };
|
return { isGit: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeRoot = inspected.worktreeRoot;
|
const worktreeRoot = facts.worktreeRoot;
|
||||||
const currentBranch = inspected.currentBranch;
|
const currentBranch = facts.currentBranch;
|
||||||
const remoteUrl = inspected.remoteUrl;
|
const remoteUrl = facts.remoteUrl;
|
||||||
const paseoWorktree = inspected.paseoWorktree;
|
const paseoWorktree = facts.paseoWorktree;
|
||||||
const isDirty = await isWorkingTreeDirty(cwd, context);
|
const isDirty = await isWorkingTreeDirty(cwd, context);
|
||||||
const hasRemote = remoteUrl !== null;
|
const hasRemote = remoteUrl !== null;
|
||||||
const { resolvedBaseRef: baseRef } = await resolveBaseRefForCwd(cwd, context);
|
const baseRef = facts.resolvedBaseRef;
|
||||||
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
|
const mainRepoRoot = facts.mainRepoRoot;
|
||||||
|
const factsContext = { ...context, facts };
|
||||||
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
|
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
|
||||||
baseRef && currentBranch
|
baseRef && currentBranch
|
||||||
? getAheadBehind(cwd, baseRef, currentBranch, context)
|
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
hasRemote && currentBranch
|
hasRemote && currentBranch
|
||||||
? getAheadOfOrigin(cwd, currentBranch, baseRef, context)
|
? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
hasRemote && currentBranch
|
hasRemote && currentBranch
|
||||||
? getBehindOfOrigin(cwd, currentBranch, context)
|
? getBehindOfOrigin(cwd, currentBranch, factsContext)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1616,30 +1766,29 @@ async function getCheckoutShortstatUncached(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<CheckoutShortstat | null> {
|
): Promise<CheckoutShortstat | null> {
|
||||||
try {
|
if (context?.facts?.isGit === false) {
|
||||||
await requireGitRepo(cwd);
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!context?.facts?.isGit) {
|
||||||
const localBaseRef = await getResolvedBaseRefForCwd(cwd, context);
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
|
||||||
|
|
||||||
let comparisonRef: string;
|
|
||||||
|
|
||||||
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
|
||||||
try {
|
try {
|
||||||
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
await requireGitRepo(cwd);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if (currentBranch) {
|
}
|
||||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
|
||||||
if (!hasOrigin) {
|
const facts = context?.facts;
|
||||||
return null;
|
const localBaseRef = facts?.isGit
|
||||||
}
|
? facts.resolvedBaseRef
|
||||||
comparisonRef = `origin/${currentBranch}`;
|
: await getResolvedBaseRefForCwd(cwd, context);
|
||||||
} else {
|
const currentBranch = facts?.isGit ? facts.currentBranch : await getCurrentBranch(cwd);
|
||||||
|
const comparisonRef = await resolveShortstatComparisonRef({
|
||||||
|
cwd,
|
||||||
|
currentBranch,
|
||||||
|
localBaseRef,
|
||||||
|
facts,
|
||||||
|
});
|
||||||
|
if (!comparisonRef) {
|
||||||
return null;
|
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(
|
function getOrLoadCheckoutShortstat(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
@@ -2460,6 +2634,7 @@ export async function getPullRequestStatus(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
github: GitHubService = createGitHubService(),
|
github: GitHubService = createGitHubService(),
|
||||||
options?: CheckoutReadCacheOptions,
|
options?: CheckoutReadCacheOptions,
|
||||||
|
context?: CheckoutContext,
|
||||||
): Promise<PullRequestStatusResult> {
|
): Promise<PullRequestStatusResult> {
|
||||||
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
||||||
if (!options?.force) {
|
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) => {
|
.then((status) => {
|
||||||
pullRequestStatusCache.set(cacheKey, status);
|
pullRequestStatusCache.set(cacheKey, status);
|
||||||
rememberPullRequestStatus(cacheKey, status);
|
rememberPullRequestStatus(cacheKey, status);
|
||||||
@@ -2501,9 +2676,18 @@ async function getPullRequestStatusUncached(
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
github: GitHubService,
|
github: GitHubService,
|
||||||
options?: CheckoutReadCacheOptions,
|
options?: CheckoutReadCacheOptions,
|
||||||
|
context?: CheckoutContext,
|
||||||
): Promise<PullRequestStatusResult> {
|
): Promise<PullRequestStatusResult> {
|
||||||
await requireGitRepo(cwd);
|
if (context?.facts?.isGit === false) {
|
||||||
const head = await getCurrentBranch(cwd);
|
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) {
|
if (!head) {
|
||||||
return {
|
return {
|
||||||
status: null,
|
status: null,
|
||||||
@@ -2511,7 +2695,7 @@ async function getPullRequestStatusUncached(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
|
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
|
||||||
let status: GitHubCurrentPullRequestStatus | null;
|
let status: GitHubCurrentPullRequestStatus | null;
|
||||||
if (options?.force) {
|
if (options?.force) {
|
||||||
const reason = options.reason;
|
const reason = options.reason;
|
||||||
|
|||||||
@@ -29,6 +29,79 @@ export interface GitCommandResult {
|
|||||||
signal: NodeJS.Signals | null;
|
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(
|
function mergeEnvOverlays(
|
||||||
env: ProcessEnvRecord | undefined,
|
env: ProcessEnvRecord | undefined,
|
||||||
envOverlay: ProcessEnvRecord | undefined,
|
envOverlay: ProcessEnvRecord | undefined,
|
||||||
@@ -59,6 +132,7 @@ export function runGitCommand(
|
|||||||
const command = formatGitCommand(args);
|
const command = formatGitCommand(args);
|
||||||
const envOverlay = mergeEnvOverlays(options.env, options.envOverlay);
|
const envOverlay = mergeEnvOverlays(options.env, options.envOverlay);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const metricsState = beginGitCommandMetric();
|
||||||
const logger = typeof options.logger?.trace === "function" ? options.logger : undefined;
|
const logger = typeof options.logger?.trace === "function" ? options.logger : undefined;
|
||||||
const traceContext = logger
|
const traceContext = logger
|
||||||
? {
|
? {
|
||||||
@@ -87,6 +161,7 @@ export function runGitCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
let metricFinished = false;
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
let stdoutBytes = 0;
|
let stdoutBytes = 0;
|
||||||
let stderrBytes = 0;
|
let stderrBytes = 0;
|
||||||
@@ -100,9 +175,24 @@ export function runGitCommand(
|
|||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const finishMetricOnce = (metric: GitCommandMetric) => {
|
||||||
|
if (metricFinished) return;
|
||||||
|
metricFinished = true;
|
||||||
|
finishGitCommandMetric(metricsState, metric);
|
||||||
|
};
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
|
finishMetricOnce({
|
||||||
|
args,
|
||||||
|
cwd: options.cwd,
|
||||||
|
startedAtMs: startedAt,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
exitCode: null,
|
||||||
|
signal: "SIGKILL",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
settle(() => reject(error));
|
settle(() => reject(error));
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
@@ -147,6 +237,15 @@ export function runGitCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", (error) => {
|
child.on("error", (error) => {
|
||||||
|
finishMetricOnce({
|
||||||
|
args,
|
||||||
|
cwd: options.cwd,
|
||||||
|
startedAtMs: startedAt,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
if (logger && traceContext) {
|
if (logger && traceContext) {
|
||||||
logger.trace(
|
logger.trace(
|
||||||
{
|
{
|
||||||
@@ -184,6 +283,15 @@ export function runGitCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) {
|
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 stderrPreview = result.stderr.trim() || "(no stderr)";
|
||||||
const truncationNote = result.truncated ? " (stdout truncated)" : "";
|
const truncationNote = result.truncated ? " (stdout truncated)" : "";
|
||||||
|
|
||||||
@@ -197,6 +305,15 @@ export function runGitCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finishMetricOnce({
|
||||||
|
args,
|
||||||
|
cwd: options.cwd,
|
||||||
|
startedAtMs: startedAt,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
exitCode,
|
||||||
|
signal,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
settle(() => resolve(result));
|
settle(() => resolve(result));
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user