Reduce startup git work for workspace snapshots

This commit is contained in:
Mohamed Boudra
2026-05-27 20:18:49 +07:00
parent dbfd42da46
commit 5d8dc800fc
13 changed files with 862 additions and 57 deletions
@@ -0,0 +1,218 @@
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { copyFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { startGitCommandMetrics, stopGitCommandMetrics } from "../src/utils/run-git-command.js";
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
type Scenario = "snapshotOnly" | "legacyPrFanout";
interface BenchmarkResult {
scenario: Scenario;
sourceHome: string;
frozenHomeRoot: string;
workspaceCount: number;
elapsedMs: number;
git: {
total: number;
failed: number;
maxConcurrent: number;
byCommand: Array<{ key: string; count: number }>;
byCwd: Array<{ key: string; count: number }>;
};
process: {
cpuUserMs: number;
cpuSystemMs: number;
rssDeltaMb: number;
heapUsedDeltaMb: number;
};
}
function parseArgs(): { sourceHome: string; frozenHomeRoot: string | null; scenario: Scenario } {
let sourceHome = process.env.PASEO_BENCHMARK_SOURCE_HOME ?? path.join(os.homedir(), ".paseo");
let frozenHomeRoot = process.env.PASEO_BENCHMARK_FROZEN_HOME_ROOT ?? null;
let scenario = (process.env.PASEO_BENCHMARK_SCENARIO ?? "snapshotOnly") as Scenario;
for (const arg of process.argv.slice(2)) {
const [key, value] = arg.split("=", 2);
if (key === "--source-home" && value) sourceHome = value;
if (key === "--frozen-home-root" && value) frozenHomeRoot = value;
if (key === "--scenario" && (value === "snapshotOnly" || value === "legacyPrFanout")) {
scenario = value;
}
}
return { sourceHome, frozenHomeRoot, scenario };
}
function copyJsonTree(sourceDir: string, targetDir: string): void {
if (!existsSync(sourceDir)) {
return;
}
mkdirSync(targetDir, { recursive: true });
for (const entry of readdirSync(sourceDir)) {
const sourcePath = path.join(sourceDir, entry);
const targetPath = path.join(targetDir, entry);
const stat = statSync(sourcePath);
if (stat.isDirectory()) {
copyJsonTree(sourcePath, targetPath);
continue;
}
if (stat.isFile() && entry.endsWith(".json")) {
mkdirSync(path.dirname(targetPath), { recursive: true });
copyFileSync(sourcePath, targetPath);
}
}
}
async function freezeHome(sourceHome: string, requestedRoot: string | null): Promise<string> {
const frozenHomeRoot = requestedRoot ?? mkdtempSync(path.join(os.tmpdir(), "paseo-real-home-"));
if (process.env.PASEO_BENCHMARK_REUSE_FROZEN_HOME === "1") {
return frozenHomeRoot;
}
const frozenHome = path.join(frozenHomeRoot, ".paseo");
rmSync(frozenHome, { recursive: true, force: true });
mkdirSync(frozenHome, { recursive: true });
copyJsonTree(path.join(sourceHome, "agents"), path.join(frozenHome, "agents"));
copyJsonTree(path.join(sourceHome, "projects"), path.join(frozenHome, "projects"));
const configPath = path.join(sourceHome, "config.json");
if (existsSync(configPath)) {
await copyFile(configPath, path.join(frozenHome, "config.json"));
}
return frozenHomeRoot;
}
function topCounts(items: string[], limit = 20): Array<{ key: string; count: number }> {
const counts = new Map<string, number>();
for (const item of items) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
return Array.from(counts, ([key, count]) => ({ key, count }))
.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
.slice(0, limit);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
let timeout: NodeJS.Timeout | null = null;
try {
return await Promise.race([
promise,
new Promise<undefined>((resolve) => {
timeout = setTimeout(() => resolve(undefined), timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function main(): Promise<void> {
const { sourceHome, frozenHomeRoot: requestedFrozenRoot, scenario } = parseArgs();
const frozenHomeRoot = await freezeHome(sourceHome, requestedFrozenRoot);
const cpuBefore = process.cpuUsage();
const memoryBefore = process.memoryUsage();
const startedAt = performance.now();
const daemon = await createTestPaseoDaemon({ paseoHomeRoot: frozenHomeRoot, cleanup: false });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.90",
});
let metrics = stopGitCommandMetrics();
try {
startGitCommandMetrics();
await client.connect();
const seen = new Set<string>();
client.on("checkout_status_update", (message) => seen.add(message.payload.cwd));
const workspaces = await client.fetchWorkspaces({
subscribe: { subscriptionId: `startup-git-${scenario}` },
sort: [{ key: "activity_at", direction: "desc" }],
page: { limit: 200 },
});
await client.fetchAgents({
scope: "active",
subscribe: { subscriptionId: `startup-agents-${scenario}` },
page: { limit: 200 },
});
const workspaceCwds = workspaces.entries
.map((entry) => entry.workspaceDirectory)
.filter((cwd): cwd is string => Boolean(cwd));
if (scenario === "legacyPrFanout") {
await Promise.all(workspaceCwds.map((cwd) => client.checkoutPrStatus(cwd).catch(() => null)));
}
const deadline = Date.now() + 30_000;
while (Date.now() < deadline && workspaceCwds.some((cwd) => !seen.has(cwd))) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 500));
metrics = stopGitCommandMetrics();
const elapsedMs = Math.round(performance.now() - startedAt);
const cpu = process.cpuUsage(cpuBefore);
const memoryAfter = process.memoryUsage();
const result: BenchmarkResult = {
scenario,
sourceHome,
frozenHomeRoot,
workspaceCount: workspaceCwds.length,
elapsedMs,
git: {
total: metrics.total,
failed: metrics.failed,
maxConcurrent: metrics.maxConcurrent,
byCommand: topCounts(metrics.commands.map((command) => command.args.join(" "))),
byCwd: topCounts(metrics.commands.map((command) => command.cwd)),
},
process: {
cpuUserMs: Math.round(cpu.user / 1000),
cpuSystemMs: Math.round(cpu.system / 1000),
rssDeltaMb: Number(((memoryAfter.rss - memoryBefore.rss) / 1024 / 1024).toFixed(1)),
heapUsedDeltaMb: Number(
((memoryAfter.heapUsed - memoryBefore.heapUsed) / 1024 / 1024).toFixed(1),
),
},
};
console.log(`REAL_HOME_STARTUP_GIT_BENCHMARK ${JSON.stringify(result)}`);
} finally {
if (metrics.total === 0) {
stopGitCommandMetrics();
}
await withTimeout(
client.close().catch(() => undefined),
1_000,
);
await withTimeout(
daemon.close().catch(() => undefined),
3_000,
);
if (!requestedFrozenRoot) {
await rm(frozenHomeRoot, { recursive: true, force: true });
} else {
await mkdir(frozenHomeRoot, { recursive: true });
}
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -4,7 +4,11 @@ import path, { join } from "node:path";
import type { FSWatcher } from "node:fs";
import type pino from "pino";
import type { GitHubService } from "../services/github-service.js";
import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js";
import type {
CheckoutSnapshotFacts,
CheckoutStatusGit,
PullRequestStatusResult,
} from "../utils/checkout-git.js";
import {
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
@@ -102,6 +106,24 @@ function createCheckoutStatus(
};
}
function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts {
return {
isGit: true,
worktreeRoot: cwd,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
paseoWorktree: { isPaseoOwnedWorktree: false },
storedBaseRef: null,
resolvedBaseRef: "main",
mainRepoRoot: null,
comparisonBaseRef: null,
branchRemoteName: "origin",
branchMergeRef: "refs/heads/main",
trackedOriginBranch: "main",
pullRequestLookupTarget: { headRef: "main" },
};
}
function createPullRequestStatusResult(
overrides?: Partial<PullRequestStatusResult>,
): PullRequestStatusResult {
@@ -179,6 +201,7 @@ function createGitHubServiceStub(): GitHubService {
interface CreateServiceTestOptions {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
github?: GitHubService;
@@ -195,6 +218,7 @@ function buildDefaultTestServiceDeps() {
return {
watch: (() => createWatcher()) as unknown as typeof import("node:fs").watch,
readdir: vi.fn(async () => []),
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => createCheckoutSnapshotFacts(cwd)),
getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)),
getCheckoutShortstat: vi.fn(async () => ({
additions: 1,
@@ -8,9 +8,11 @@ import type { CheckoutContext } from "../utils/checkout-git.js";
import {
type BranchCheckoutResolution,
type BranchSuggestion,
type CheckoutSnapshotFacts,
type CheckoutDiffCompare,
type CheckoutDiffResult,
getCheckoutDiff,
getCheckoutSnapshotFacts,
getCheckoutShortstat,
getCheckoutStatus,
getPullRequestStatus,
@@ -231,6 +233,7 @@ type WorkspaceGitRefreshState =
interface WorkspaceGitServiceDependencies {
watch: typeof watch;
readdir: typeof readdir;
getCheckoutSnapshotFacts: typeof getCheckoutSnapshotFacts;
getCheckoutStatus: typeof getCheckoutStatus;
getCheckoutShortstat: typeof getCheckoutShortstat;
getCheckoutDiff: typeof getCheckoutDiff;
@@ -308,6 +311,7 @@ function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies
return {
watch,
readdir,
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutDiff,
@@ -1481,9 +1485,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
await this.refreshGitSnapshot(target, request);
const facts = await this.refreshGitSnapshot(target, request);
if (request.includeGitHub) {
await this.refreshGitHubSnapshot(target, request);
await this.refreshGitHubSnapshot(target, request, facts);
}
const snapshot = this.combineSnapshot(target);
@@ -1494,13 +1498,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private async refreshGitSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
): Promise<CheckoutSnapshotFacts> {
const now = this.deps.now();
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const facts = await this.deps.getCheckoutSnapshotFacts(cwd, baseContext);
const context: CheckoutContext = { ...baseContext, facts };
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
if (!checkoutStatus.isGit) {
target.latestGit = buildNotGitSnapshot(cwd).git;
@@ -1508,7 +1514,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.cachedGitHubRemote = null;
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
return;
return facts;
}
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
@@ -1537,11 +1543,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
}
return facts;
}
private async refreshGitHubSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
facts: CheckoutSnapshotFacts,
): Promise<void> {
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
const forceGitHub = request.force && request.includeGitHub;
@@ -1556,6 +1564,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
deps: this.deps,
force: forceGitHub,
reason: request.reason,
facts,
});
target.latestGithubLoadedAtMs = this.deps.now().getTime();
}
@@ -1768,6 +1777,7 @@ async function loadGitHubSnapshot(options: {
deps: Pick<WorkspaceGitServiceDependencies, "getPullRequestStatus" | "github">;
force?: boolean;
reason?: string;
facts?: CheckoutSnapshotFacts;
}): Promise<WorkspaceGitRuntimeSnapshot["github"]> {
if (!options.githubRemote) {
return {
@@ -1788,10 +1798,15 @@ async function loadGitHubSnapshot(options: {
}
try {
const result = await options.deps.getPullRequestStatus(options.cwd, options.deps.github, {
force: options.force,
reason: options.reason,
});
const result = await options.deps.getPullRequestStatus(
options.cwd,
options.deps.github,
{
force: options.force,
reason: options.reason,
},
{ facts: options.facts },
);
return {
featuresEnabled: true,
pullRequest: result.status,
+19
View File
@@ -2338,12 +2338,31 @@ const WorkspaceGitHubRuntimePayloadSchema = z
featuresEnabled: z.boolean().optional(),
pullRequest: z
.object({
number: z.number().optional(),
url: z.string(),
title: z.string(),
state: z.string(),
baseRefName: z.string(),
headRefName: z.string(),
isMerged: z.boolean(),
isDraft: z.boolean().optional(),
mergeable: z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]).catch("UNKNOWN").optional(),
checks: z
.array(
z.object({
name: z.string(),
status: z.enum(["success", "failure", "pending", "skipped", "cancelled"]),
url: z.string().nullable(),
workflow: z.string().optional(),
duration: z.string().optional(),
}),
)
.optional(),
checksStatus: z.enum(["none", "pending", "success", "failure"]).optional(),
reviewDecision: z.enum(["approved", "changes_requested", "pending"]).nullable().optional(),
repoOwner: z.string().optional(),
repoName: z.string().optional(),
github: z.unknown().optional(),
})
.nullable()
.optional(),
@@ -17,6 +17,7 @@ import {
__setPullRequestStatusCacheTtlForTests,
commitAll,
getCachedCheckoutShortstat,
getCheckoutSnapshotFacts,
getCurrentBranch,
getCheckoutDiff,
getCheckoutShortstat,
@@ -39,6 +40,7 @@ import {
isDescendantPath,
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
import {
GitHubCommandError,
GitHubCliMissingError,
@@ -329,6 +331,48 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file");
});
it("reuses checkout snapshot facts across status, shortstat, and PR status reads", async () => {
setupRemoteTrackingMain(repoDir, tempDir);
execFileSync("git", ["checkout", "-b", "feature/facts"], { cwd: repoDir });
commitFile(repoDir, "feature.txt", "feature\n", "feature");
writeFileSync(join(repoDir, "feature.txt"), "feature\nchanged\n");
const github = createGitHubServiceForStatus(createPullRequestStatus());
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
const status = await getCheckoutStatus(repoDir, { paseoHome, facts });
const shortstat = await getCheckoutShortstat(repoDir, { paseoHome, facts }, { force: true });
const prStatus = await getPullRequestStatus(
repoDir,
github,
{ force: true, reason: "snapshot-equivalence" },
{ paseoHome, facts },
);
__resetCheckoutShortstatCacheForTests();
__resetPullRequestStatusCacheForTests();
startGitCommandMetrics();
const statusWithFacts = await getCheckoutStatus(repoDir, { paseoHome, facts });
const shortstatWithFacts = await getCheckoutShortstat(
repoDir,
{ paseoHome, facts },
{ force: true },
);
const prStatusWithFacts = await getPullRequestStatus(
repoDir,
github,
{ force: true, reason: "snapshot-equivalence-with-facts" },
{ paseoHome, facts },
);
const metrics = stopGitCommandMetrics();
const commands = metrics.commands.map((command) => command.args.join(" "));
expect(statusWithFacts).toEqual(status);
expect(shortstatWithFacts).toEqual(shortstat);
expect(prStatusWithFacts).toEqual(prStatus);
expect(commands).not.toContain("rev-parse --show-toplevel");
expect(commands).not.toContain("rev-parse --abbrev-ref HEAD");
});
it("hides whitespace-only changes when requested", async () => {
writeFileSync(join(repoDir, "file.txt"), "hello \n");
+220 -36
View File
@@ -723,8 +723,29 @@ export interface MergeFromBaseOptions {
export interface CheckoutContext {
paseoHome?: string;
logger?: Pick<Logger, "trace">;
facts?: CheckoutSnapshotFacts | null;
}
export type CheckoutSnapshotFacts =
| {
isGit: false;
}
| {
isGit: true;
worktreeRoot: string;
currentBranch: string | null;
remoteUrl: string | null;
paseoWorktree: PaseoWorktreeForCwd;
storedBaseRef: string | null;
resolvedBaseRef: string | null;
mainRepoRoot: string | null;
comparisonBaseRef: string | null;
branchRemoteName: string | null;
branchMergeRef: string | null;
trackedOriginBranch: string | null;
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
};
function isGitError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
@@ -915,6 +936,7 @@ type PaseoWorktreeForCwd =
async function getPaseoWorktreeForCwd(
cwd: string,
context?: CheckoutContext,
knownWorktreeRoot?: string | null,
): Promise<PaseoWorktreeForCwd> {
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
@@ -928,7 +950,7 @@ async function getPaseoWorktreeForCwd(
return {
isPaseoOwnedWorktree: true,
worktreeRoot: (await getWorktreeRoot(cwd)) ?? cwd,
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd,
};
}
@@ -940,6 +962,9 @@ async function getStoredBaseRefForCwd(
cwd: string,
context?: CheckoutContext,
): Promise<string | null> {
if (context?.facts?.isGit) {
return context.facts.storedBaseRef;
}
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
if (!paseoWorktree.isPaseoOwnedWorktree) {
return null;
@@ -952,6 +977,9 @@ async function getResolvedBaseRefForCwd(
cwd: string,
context?: CheckoutContext,
): Promise<string | null> {
if (context?.facts?.isGit) {
return context.facts.resolvedBaseRef;
}
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
return resolvedBaseRef;
}
@@ -965,6 +993,12 @@ async function resolveBaseRefForCwd(
cwd: string,
context?: CheckoutContext,
): Promise<BaseRefResolution> {
if (context?.facts?.isGit) {
return {
storedBaseRef: context.facts.storedBaseRef,
resolvedBaseRef: context.facts.resolvedBaseRef,
};
}
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
return {
storedBaseRef,
@@ -1029,7 +1063,11 @@ function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
async function resolvePullRequestStatusLookupTarget(
cwd: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<PullRequestStatusLookupTarget> {
if (context?.facts?.isGit && context.facts.pullRequestLookupTarget) {
return context.facts.pullRequestLookupTarget;
}
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
if (!remoteName?.startsWith("paseo-pr-")) {
return { headRef: currentBranch };
@@ -1251,7 +1289,13 @@ async function getAheadBehind(
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
return null;
}
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef, context);
const comparisonBaseRef =
context?.facts?.isGit && context.facts.resolvedBaseRef === baseRef
? context.facts.comparisonBaseRef
: await resolveBestComparisonBaseRef(cwd, baseRef, context);
if (!comparisonBaseRef) {
return null;
}
const { stdout } = await runGitCommand(
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
@@ -1309,6 +1353,9 @@ async function getTrackedOriginBranch(
currentBranch: string,
context?: CheckoutContext,
): Promise<string | null> {
if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) {
return context.facts.trackedOriginBranch;
}
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
if (remoteName !== "origin") {
return null;
@@ -1358,7 +1405,7 @@ async function inspectCheckoutContext(
const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([
getCurrentBranch(cwd),
getOriginRemoteUrl(cwd),
getPaseoWorktreeForCwd(cwd, context),
getPaseoWorktreeForCwd(cwd, context, root),
]);
return {
@@ -1375,6 +1422,108 @@ async function inspectCheckoutContext(
}
}
function buildPullRequestLookupTargetFromBranchConfig(input: {
currentBranch: string;
branchRemoteName: string | null;
branchMergeRef: string | null;
branchRemoteUrl: string | null;
}): PullRequestStatusLookupTarget {
if (!input.branchRemoteName?.startsWith("paseo-pr-")) {
return { headRef: input.currentBranch };
}
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
if (!trackedHeadRef) {
return { headRef: input.currentBranch };
}
const remoteRepo = input.branchRemoteUrl
? parseGitHubRepoFromRemote(input.branchRemoteUrl)
: null;
const headRepositoryOwner = remoteRepo?.split("/")[0];
return {
headRef: trackedHeadRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
export async function getCheckoutSnapshotFacts(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutSnapshotFacts> {
if (context?.facts) {
return context.facts;
}
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
return { isGit: false };
}
const storedBaseRef = inspected.paseoWorktree.isPaseoOwnedWorktree
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
: null;
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
let comparisonBaseRef: string | null = null;
if (
resolvedBaseRef &&
inspected.currentBranch &&
normalizeLocalBranchRefName(resolvedBaseRef) !== inspected.currentBranch
) {
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
() => null,
);
}
let branchRemoteName: string | null = null;
let branchMergeRef: string | null = null;
let branchRemoteUrl: string | null = null;
if (inspected.remoteUrl && inspected.currentBranch) {
branchRemoteName = await getGitConfigValue(
cwd,
`branch.${inspected.currentBranch}.remote`,
context,
);
if (branchRemoteName) {
branchMergeRef = await getGitConfigValue(
cwd,
`branch.${inspected.currentBranch}.merge`,
context,
);
if (branchRemoteName.startsWith("paseo-pr-")) {
branchRemoteUrl = await getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context);
}
}
}
const trackedOriginBranch =
branchRemoteName === "origin" ? parseBranchMergeHeadRef(branchMergeRef) : null;
const pullRequestLookupTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromBranchConfig({
currentBranch: inspected.currentBranch,
branchRemoteName,
branchMergeRef,
branchRemoteUrl,
})
: null;
return {
isGit: true,
worktreeRoot: inspected.worktreeRoot,
currentBranch: inspected.currentBranch,
remoteUrl: inspected.remoteUrl,
paseoWorktree: inspected.paseoWorktree,
storedBaseRef,
resolvedBaseRef,
mainRepoRoot,
comparisonBaseRef,
branchRemoteName,
branchMergeRef,
trackedOriginBranch,
pullRequestLookupTarget,
};
}
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
@@ -1489,28 +1638,29 @@ export async function getCheckoutStatus(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutStatusResult> {
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
const facts = await getCheckoutSnapshotFacts(cwd, context);
if (!facts.isGit) {
return { isGit: false };
}
const worktreeRoot = inspected.worktreeRoot;
const currentBranch = inspected.currentBranch;
const remoteUrl = inspected.remoteUrl;
const paseoWorktree = inspected.paseoWorktree;
const worktreeRoot = facts.worktreeRoot;
const currentBranch = facts.currentBranch;
const remoteUrl = facts.remoteUrl;
const paseoWorktree = facts.paseoWorktree;
const isDirty = await isWorkingTreeDirty(cwd, context);
const hasRemote = remoteUrl !== null;
const { resolvedBaseRef: baseRef } = await resolveBaseRefForCwd(cwd, context);
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
const baseRef = facts.resolvedBaseRef;
const mainRepoRoot = facts.mainRepoRoot;
const factsContext = { ...context, facts };
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
baseRef && currentBranch
? getAheadBehind(cwd, baseRef, currentBranch, context)
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
: Promise.resolve(null),
hasRemote && currentBranch
? getAheadOfOrigin(cwd, currentBranch, baseRef, context)
? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext)
: Promise.resolve(null),
hasRemote && currentBranch
? getBehindOfOrigin(cwd, currentBranch, context)
? getBehindOfOrigin(cwd, currentBranch, factsContext)
: Promise.resolve(null),
]);
@@ -1616,30 +1766,29 @@ async function getCheckoutShortstatUncached(
cwd: string,
context?: CheckoutContext,
): Promise<CheckoutShortstat | null> {
try {
await requireGitRepo(cwd);
} catch {
if (context?.facts?.isGit === false) {
return null;
}
const localBaseRef = await getResolvedBaseRefForCwd(cwd, context);
const currentBranch = await getCurrentBranch(cwd);
let comparisonRef: string;
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
if (!context?.facts?.isGit) {
try {
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
await requireGitRepo(cwd);
} catch {
return null;
}
} else if (currentBranch) {
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
if (!hasOrigin) {
return null;
}
comparisonRef = `origin/${currentBranch}`;
} else {
}
const facts = context?.facts;
const localBaseRef = facts?.isGit
? facts.resolvedBaseRef
: await getResolvedBaseRefForCwd(cwd, context);
const currentBranch = facts?.isGit ? facts.currentBranch : await getCurrentBranch(cwd);
const comparisonRef = await resolveShortstatComparisonRef({
cwd,
currentBranch,
localBaseRef,
facts,
});
if (!comparisonRef) {
return null;
}
@@ -1675,6 +1824,31 @@ async function getCheckoutShortstatUncached(
}
}
async function resolveShortstatComparisonRef(input: {
cwd: string;
currentBranch: string | null;
localBaseRef: string | null;
facts?: CheckoutSnapshotFacts | null;
}): Promise<string | null> {
const { cwd, currentBranch, localBaseRef, facts } = input;
if (!currentBranch) {
return null;
}
if (localBaseRef && currentBranch !== localBaseRef) {
try {
return facts?.isGit && facts.resolvedBaseRef === localBaseRef && facts.comparisonBaseRef
? facts.comparisonBaseRef
: await resolveBestComparisonBaseRef(cwd, localBaseRef);
} catch {
return null;
}
}
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
return hasOrigin ? `origin/${currentBranch}` : null;
}
function getOrLoadCheckoutShortstat(
cwd: string,
context?: CheckoutContext,
@@ -2460,6 +2634,7 @@ export async function getPullRequestStatus(
cwd: string,
github: GitHubService = createGitHubService(),
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
const cacheKey = getPullRequestStatusCacheKey(cwd);
if (!options?.force) {
@@ -2474,7 +2649,7 @@ export async function getPullRequestStatus(
}
}
const lookup = getPullRequestStatusUncached(cwd, github, options)
const lookup = getPullRequestStatusUncached(cwd, github, options, context)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
@@ -2501,9 +2676,18 @@ async function getPullRequestStatusUncached(
cwd: string,
github: GitHubService,
options?: CheckoutReadCacheOptions,
context?: CheckoutContext,
): Promise<PullRequestStatusResult> {
await requireGitRepo(cwd);
const head = await getCurrentBranch(cwd);
if (context?.facts?.isGit === false) {
return {
status: null,
githubFeaturesEnabled: false,
};
}
if (!context?.facts?.isGit) {
await requireGitRepo(cwd);
}
const head = context?.facts?.isGit ? context.facts.currentBranch : await getCurrentBranch(cwd);
if (!head) {
return {
status: null,
@@ -2511,7 +2695,7 @@ async function getPullRequestStatusUncached(
};
}
try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
let status: GitHubCurrentPullRequestStatus | null;
if (options?.force) {
const reason = options.reason;
@@ -29,6 +29,79 @@ export interface GitCommandResult {
signal: NodeJS.Signals | null;
}
export interface GitCommandMetric {
args: string[];
cwd: string;
startedAtMs: number;
durationMs: number;
exitCode: number | null;
signal: NodeJS.Signals | null;
success: boolean;
}
export interface GitCommandMetricsSnapshot {
commands: GitCommandMetric[];
total: number;
failed: number;
maxConcurrent: number;
}
interface GitCommandMetricsState {
commands: GitCommandMetric[];
active: number;
maxConcurrent: number;
}
let gitCommandMetricsState: GitCommandMetricsState | null = null;
export function startGitCommandMetrics(): void {
gitCommandMetricsState = {
commands: [],
active: 0,
maxConcurrent: 0,
};
}
export function stopGitCommandMetrics(): GitCommandMetricsSnapshot {
const state = gitCommandMetricsState;
gitCommandMetricsState = null;
if (!state) {
return {
commands: [],
total: 0,
failed: 0,
maxConcurrent: 0,
};
}
return {
commands: [...state.commands],
total: state.commands.length,
failed: state.commands.filter((command) => !command.success).length,
maxConcurrent: state.maxConcurrent,
};
}
function beginGitCommandMetric(): GitCommandMetricsState | null {
const state = gitCommandMetricsState;
if (!state) {
return null;
}
state.active += 1;
state.maxConcurrent = Math.max(state.maxConcurrent, state.active);
return state;
}
function finishGitCommandMetric(
state: GitCommandMetricsState | null,
metric: GitCommandMetric,
): void {
if (!state) {
return;
}
state.active = Math.max(0, state.active - 1);
state.commands.push(metric);
}
function mergeEnvOverlays(
env: ProcessEnvRecord | undefined,
envOverlay: ProcessEnvRecord | undefined,
@@ -59,6 +132,7 @@ export function runGitCommand(
const command = formatGitCommand(args);
const envOverlay = mergeEnvOverlays(options.env, options.envOverlay);
const startedAt = Date.now();
const metricsState = beginGitCommandMetric();
const logger = typeof options.logger?.trace === "function" ? options.logger : undefined;
const traceContext = logger
? {
@@ -87,6 +161,7 @@ export function runGitCommand(
});
let settled = false;
let metricFinished = false;
let truncated = false;
let stdoutBytes = 0;
let stderrBytes = 0;
@@ -100,9 +175,24 @@ export function runGitCommand(
callback();
};
const finishMetricOnce = (metric: GitCommandMetric) => {
if (metricFinished) return;
metricFinished = true;
finishGitCommandMetric(metricsState, metric);
};
const timer = setTimeout(() => {
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
child.kill("SIGKILL");
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: null,
signal: "SIGKILL",
success: false,
});
settle(() => reject(error));
}, timeout);
@@ -147,6 +237,15 @@ export function runGitCommand(
});
child.on("error", (error) => {
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode: null,
signal: null,
success: false,
});
if (logger && traceContext) {
logger.trace(
{
@@ -184,6 +283,15 @@ export function runGitCommand(
}
if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) {
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode,
signal,
success: false,
});
const stderrPreview = result.stderr.trim() || "(no stderr)";
const truncationNote = result.truncated ? " (stdout truncated)" : "";
@@ -197,6 +305,15 @@ export function runGitCommand(
return;
}
finishMetricOnce({
args,
cwd: options.cwd,
startedAtMs: startedAt,
durationMs: Date.now() - startedAt,
exitCode,
signal,
success: true,
});
settle(() => resolve(result));
});
}),