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
@@ -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));
});
}),