Reduce startup git work for workspace snapshots
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
__setPullRequestStatusCacheTtlForTests,
|
||||
commitAll,
|
||||
getCachedCheckoutShortstat,
|
||||
getCheckoutSnapshotFacts,
|
||||
getCurrentBranch,
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
isDescendantPath,
|
||||
warmCheckoutShortstatInBackground,
|
||||
} from "./checkout-git.js";
|
||||
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
||||
import {
|
||||
GitHubCommandError,
|
||||
GitHubCliMissingError,
|
||||
@@ -329,6 +331,48 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("reuses checkout snapshot facts across status, shortstat, and PR status reads", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execFileSync("git", ["checkout", "-b", "feature/facts"], { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature");
|
||||
writeFileSync(join(repoDir, "feature.txt"), "feature\nchanged\n");
|
||||
const github = createGitHubServiceForStatus(createPullRequestStatus());
|
||||
|
||||
const facts = await getCheckoutSnapshotFacts(repoDir, { paseoHome });
|
||||
const status = await getCheckoutStatus(repoDir, { paseoHome, facts });
|
||||
const shortstat = await getCheckoutShortstat(repoDir, { paseoHome, facts }, { force: true });
|
||||
const prStatus = await getPullRequestStatus(
|
||||
repoDir,
|
||||
github,
|
||||
{ force: true, reason: "snapshot-equivalence" },
|
||||
{ paseoHome, facts },
|
||||
);
|
||||
|
||||
__resetCheckoutShortstatCacheForTests();
|
||||
__resetPullRequestStatusCacheForTests();
|
||||
startGitCommandMetrics();
|
||||
const statusWithFacts = await getCheckoutStatus(repoDir, { paseoHome, facts });
|
||||
const shortstatWithFacts = await getCheckoutShortstat(
|
||||
repoDir,
|
||||
{ paseoHome, facts },
|
||||
{ force: true },
|
||||
);
|
||||
const prStatusWithFacts = await getPullRequestStatus(
|
||||
repoDir,
|
||||
github,
|
||||
{ force: true, reason: "snapshot-equivalence-with-facts" },
|
||||
{ paseoHome, facts },
|
||||
);
|
||||
const metrics = stopGitCommandMetrics();
|
||||
const commands = metrics.commands.map((command) => command.args.join(" "));
|
||||
|
||||
expect(statusWithFacts).toEqual(status);
|
||||
expect(shortstatWithFacts).toEqual(shortstat);
|
||||
expect(prStatusWithFacts).toEqual(prStatus);
|
||||
expect(commands).not.toContain("rev-parse --show-toplevel");
|
||||
expect(commands).not.toContain("rev-parse --abbrev-ref HEAD");
|
||||
});
|
||||
|
||||
it("hides whitespace-only changes when requested", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "hello \n");
|
||||
|
||||
|
||||
@@ -723,8 +723,29 @@ export interface MergeFromBaseOptions {
|
||||
export interface CheckoutContext {
|
||||
paseoHome?: string;
|
||||
logger?: Pick<Logger, "trace">;
|
||||
facts?: CheckoutSnapshotFacts | null;
|
||||
}
|
||||
|
||||
export type CheckoutSnapshotFacts =
|
||||
| {
|
||||
isGit: false;
|
||||
}
|
||||
| {
|
||||
isGit: true;
|
||||
worktreeRoot: string;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
paseoWorktree: PaseoWorktreeForCwd;
|
||||
storedBaseRef: string | null;
|
||||
resolvedBaseRef: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
comparisonBaseRef: string | null;
|
||||
branchRemoteName: string | null;
|
||||
branchMergeRef: string | null;
|
||||
trackedOriginBranch: string | null;
|
||||
pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
|
||||
};
|
||||
|
||||
function isGitError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
@@ -915,6 +936,7 @@ type PaseoWorktreeForCwd =
|
||||
async function getPaseoWorktreeForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
knownWorktreeRoot?: string | null,
|
||||
): Promise<PaseoWorktreeForCwd> {
|
||||
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||
@@ -928,7 +950,7 @@ async function getPaseoWorktreeForCwd(
|
||||
|
||||
return {
|
||||
isPaseoOwnedWorktree: true,
|
||||
worktreeRoot: (await getWorktreeRoot(cwd)) ?? cwd,
|
||||
worktreeRoot: knownWorktreeRoot ?? (await getWorktreeRoot(cwd)) ?? cwd,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -940,6 +962,9 @@ async function getStoredBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
if (context?.facts?.isGit) {
|
||||
return context.facts.storedBaseRef;
|
||||
}
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
|
||||
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
||||
return null;
|
||||
@@ -952,6 +977,9 @@ async function getResolvedBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
if (context?.facts?.isGit) {
|
||||
return context.facts.resolvedBaseRef;
|
||||
}
|
||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
return resolvedBaseRef;
|
||||
}
|
||||
@@ -965,6 +993,12 @@ async function resolveBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<BaseRefResolution> {
|
||||
if (context?.facts?.isGit) {
|
||||
return {
|
||||
storedBaseRef: context.facts.storedBaseRef,
|
||||
resolvedBaseRef: context.facts.resolvedBaseRef,
|
||||
};
|
||||
}
|
||||
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
|
||||
return {
|
||||
storedBaseRef,
|
||||
@@ -1029,7 +1063,11 @@ function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
|
||||
async function resolvePullRequestStatusLookupTarget(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<PullRequestStatusLookupTarget> {
|
||||
if (context?.facts?.isGit && context.facts.pullRequestLookupTarget) {
|
||||
return context.facts.pullRequestLookupTarget;
|
||||
}
|
||||
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
|
||||
if (!remoteName?.startsWith("paseo-pr-")) {
|
||||
return { headRef: currentBranch };
|
||||
@@ -1251,7 +1289,13 @@ async function getAheadBehind(
|
||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef, context);
|
||||
const comparisonBaseRef =
|
||||
context?.facts?.isGit && context.facts.resolvedBaseRef === baseRef
|
||||
? context.facts.comparisonBaseRef
|
||||
: await resolveBestComparisonBaseRef(cwd, baseRef, context);
|
||||
if (!comparisonBaseRef) {
|
||||
return null;
|
||||
}
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
|
||||
@@ -1309,6 +1353,9 @@ async function getTrackedOriginBranch(
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) {
|
||||
return context.facts.trackedOriginBranch;
|
||||
}
|
||||
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
|
||||
if (remoteName !== "origin") {
|
||||
return null;
|
||||
@@ -1358,7 +1405,7 @@ async function inspectCheckoutContext(
|
||||
const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
getPaseoWorktreeForCwd(cwd, context),
|
||||
getPaseoWorktreeForCwd(cwd, context, root),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -1375,6 +1422,108 @@ async function inspectCheckoutContext(
|
||||
}
|
||||
}
|
||||
|
||||
function buildPullRequestLookupTargetFromBranchConfig(input: {
|
||||
currentBranch: string;
|
||||
branchRemoteName: string | null;
|
||||
branchMergeRef: string | null;
|
||||
branchRemoteUrl: string | null;
|
||||
}): PullRequestStatusLookupTarget {
|
||||
if (!input.branchRemoteName?.startsWith("paseo-pr-")) {
|
||||
return { headRef: input.currentBranch };
|
||||
}
|
||||
|
||||
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
|
||||
if (!trackedHeadRef) {
|
||||
return { headRef: input.currentBranch };
|
||||
}
|
||||
|
||||
const remoteRepo = input.branchRemoteUrl
|
||||
? parseGitHubRepoFromRemote(input.branchRemoteUrl)
|
||||
: null;
|
||||
const headRepositoryOwner = remoteRepo?.split("/")[0];
|
||||
return {
|
||||
headRef: trackedHeadRef,
|
||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutSnapshotFacts(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutSnapshotFacts> {
|
||||
if (context?.facts) {
|
||||
return context.facts;
|
||||
}
|
||||
|
||||
const inspected = await inspectCheckoutContext(cwd, context);
|
||||
if (!inspected) {
|
||||
return { isGit: false };
|
||||
}
|
||||
|
||||
const storedBaseRef = inspected.paseoWorktree.isPaseoOwnedWorktree
|
||||
? readPaseoWorktreeBaseRef(inspected.paseoWorktree.worktreeRoot)
|
||||
: null;
|
||||
const resolvedBaseRef = storedBaseRef ?? (await resolveBaseRef(cwd));
|
||||
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
|
||||
let comparisonBaseRef: string | null = null;
|
||||
if (
|
||||
resolvedBaseRef &&
|
||||
inspected.currentBranch &&
|
||||
normalizeLocalBranchRefName(resolvedBaseRef) !== inspected.currentBranch
|
||||
) {
|
||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
let branchRemoteName: string | null = null;
|
||||
let branchMergeRef: string | null = null;
|
||||
let branchRemoteUrl: string | null = null;
|
||||
if (inspected.remoteUrl && inspected.currentBranch) {
|
||||
branchRemoteName = await getGitConfigValue(
|
||||
cwd,
|
||||
`branch.${inspected.currentBranch}.remote`,
|
||||
context,
|
||||
);
|
||||
if (branchRemoteName) {
|
||||
branchMergeRef = await getGitConfigValue(
|
||||
cwd,
|
||||
`branch.${inspected.currentBranch}.merge`,
|
||||
context,
|
||||
);
|
||||
if (branchRemoteName.startsWith("paseo-pr-")) {
|
||||
branchRemoteUrl = await getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
const trackedOriginBranch =
|
||||
branchRemoteName === "origin" ? parseBranchMergeHeadRef(branchMergeRef) : null;
|
||||
const pullRequestLookupTarget = inspected.currentBranch
|
||||
? buildPullRequestLookupTargetFromBranchConfig({
|
||||
currentBranch: inspected.currentBranch,
|
||||
branchRemoteName,
|
||||
branchMergeRef,
|
||||
branchRemoteUrl,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
isGit: true,
|
||||
worktreeRoot: inspected.worktreeRoot,
|
||||
currentBranch: inspected.currentBranch,
|
||||
remoteUrl: inspected.remoteUrl,
|
||||
paseoWorktree: inspected.paseoWorktree,
|
||||
storedBaseRef,
|
||||
resolvedBaseRef,
|
||||
mainRepoRoot,
|
||||
comparisonBaseRef,
|
||||
branchRemoteName,
|
||||
branchMergeRef,
|
||||
trackedOriginBranch,
|
||||
pullRequestLookupTarget,
|
||||
};
|
||||
}
|
||||
|
||||
const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024;
|
||||
@@ -1489,28 +1638,29 @@ export async function getCheckoutStatus(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutStatusResult> {
|
||||
const inspected = await inspectCheckoutContext(cwd, context);
|
||||
if (!inspected) {
|
||||
const facts = await getCheckoutSnapshotFacts(cwd, context);
|
||||
if (!facts.isGit) {
|
||||
return { isGit: false };
|
||||
}
|
||||
|
||||
const worktreeRoot = inspected.worktreeRoot;
|
||||
const currentBranch = inspected.currentBranch;
|
||||
const remoteUrl = inspected.remoteUrl;
|
||||
const paseoWorktree = inspected.paseoWorktree;
|
||||
const worktreeRoot = facts.worktreeRoot;
|
||||
const currentBranch = facts.currentBranch;
|
||||
const remoteUrl = facts.remoteUrl;
|
||||
const paseoWorktree = facts.paseoWorktree;
|
||||
const isDirty = await isWorkingTreeDirty(cwd, context);
|
||||
const hasRemote = remoteUrl !== null;
|
||||
const { resolvedBaseRef: baseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
|
||||
const baseRef = facts.resolvedBaseRef;
|
||||
const mainRepoRoot = facts.mainRepoRoot;
|
||||
const factsContext = { ...context, facts };
|
||||
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
|
||||
baseRef && currentBranch
|
||||
? getAheadBehind(cwd, baseRef, currentBranch, context)
|
||||
? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
|
||||
: Promise.resolve(null),
|
||||
hasRemote && currentBranch
|
||||
? getAheadOfOrigin(cwd, currentBranch, baseRef, context)
|
||||
? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext)
|
||||
: Promise.resolve(null),
|
||||
hasRemote && currentBranch
|
||||
? getBehindOfOrigin(cwd, currentBranch, context)
|
||||
? getBehindOfOrigin(cwd, currentBranch, factsContext)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
@@ -1616,30 +1766,29 @@ async function getCheckoutShortstatUncached(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
try {
|
||||
await requireGitRepo(cwd);
|
||||
} catch {
|
||||
if (context?.facts?.isGit === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localBaseRef = await getResolvedBaseRefForCwd(cwd, context);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
|
||||
let comparisonRef: string;
|
||||
|
||||
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
||||
if (!context?.facts?.isGit) {
|
||||
try {
|
||||
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
await requireGitRepo(cwd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (currentBranch) {
|
||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
||||
if (!hasOrigin) {
|
||||
return null;
|
||||
}
|
||||
comparisonRef = `origin/${currentBranch}`;
|
||||
} else {
|
||||
}
|
||||
|
||||
const facts = context?.facts;
|
||||
const localBaseRef = facts?.isGit
|
||||
? facts.resolvedBaseRef
|
||||
: await getResolvedBaseRefForCwd(cwd, context);
|
||||
const currentBranch = facts?.isGit ? facts.currentBranch : await getCurrentBranch(cwd);
|
||||
const comparisonRef = await resolveShortstatComparisonRef({
|
||||
cwd,
|
||||
currentBranch,
|
||||
localBaseRef,
|
||||
facts,
|
||||
});
|
||||
if (!comparisonRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1675,6 +1824,31 @@ async function getCheckoutShortstatUncached(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveShortstatComparisonRef(input: {
|
||||
cwd: string;
|
||||
currentBranch: string | null;
|
||||
localBaseRef: string | null;
|
||||
facts?: CheckoutSnapshotFacts | null;
|
||||
}): Promise<string | null> {
|
||||
const { cwd, currentBranch, localBaseRef, facts } = input;
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (localBaseRef && currentBranch !== localBaseRef) {
|
||||
try {
|
||||
return facts?.isGit && facts.resolvedBaseRef === localBaseRef && facts.comparisonBaseRef
|
||||
? facts.comparisonBaseRef
|
||||
: await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
||||
return hasOrigin ? `origin/${currentBranch}` : null;
|
||||
}
|
||||
|
||||
function getOrLoadCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
@@ -2460,6 +2634,7 @@ export async function getPullRequestStatus(
|
||||
cwd: string,
|
||||
github: GitHubService = createGitHubService(),
|
||||
options?: CheckoutReadCacheOptions,
|
||||
context?: CheckoutContext,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
||||
if (!options?.force) {
|
||||
@@ -2474,7 +2649,7 @@ export async function getPullRequestStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const lookup = getPullRequestStatusUncached(cwd, github, options)
|
||||
const lookup = getPullRequestStatusUncached(cwd, github, options, context)
|
||||
.then((status) => {
|
||||
pullRequestStatusCache.set(cacheKey, status);
|
||||
rememberPullRequestStatus(cacheKey, status);
|
||||
@@ -2501,9 +2676,18 @@ async function getPullRequestStatusUncached(
|
||||
cwd: string,
|
||||
github: GitHubService,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
context?: CheckoutContext,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
if (context?.facts?.isGit === false) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
if (!context?.facts?.isGit) {
|
||||
await requireGitRepo(cwd);
|
||||
}
|
||||
const head = context?.facts?.isGit ? context.facts.currentBranch : await getCurrentBranch(cwd);
|
||||
if (!head) {
|
||||
return {
|
||||
status: null,
|
||||
@@ -2511,7 +2695,7 @@ async function getPullRequestStatusUncached(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
|
||||
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head, context);
|
||||
let status: GitHubCurrentPullRequestStatus | null;
|
||||
if (options?.force) {
|
||||
const reason = options.reason;
|
||||
|
||||
@@ -29,6 +29,79 @@ export interface GitCommandResult {
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export interface GitCommandMetric {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
startedAtMs: number;
|
||||
durationMs: number;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface GitCommandMetricsSnapshot {
|
||||
commands: GitCommandMetric[];
|
||||
total: number;
|
||||
failed: number;
|
||||
maxConcurrent: number;
|
||||
}
|
||||
|
||||
interface GitCommandMetricsState {
|
||||
commands: GitCommandMetric[];
|
||||
active: number;
|
||||
maxConcurrent: number;
|
||||
}
|
||||
|
||||
let gitCommandMetricsState: GitCommandMetricsState | null = null;
|
||||
|
||||
export function startGitCommandMetrics(): void {
|
||||
gitCommandMetricsState = {
|
||||
commands: [],
|
||||
active: 0,
|
||||
maxConcurrent: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function stopGitCommandMetrics(): GitCommandMetricsSnapshot {
|
||||
const state = gitCommandMetricsState;
|
||||
gitCommandMetricsState = null;
|
||||
if (!state) {
|
||||
return {
|
||||
commands: [],
|
||||
total: 0,
|
||||
failed: 0,
|
||||
maxConcurrent: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
commands: [...state.commands],
|
||||
total: state.commands.length,
|
||||
failed: state.commands.filter((command) => !command.success).length,
|
||||
maxConcurrent: state.maxConcurrent,
|
||||
};
|
||||
}
|
||||
|
||||
function beginGitCommandMetric(): GitCommandMetricsState | null {
|
||||
const state = gitCommandMetricsState;
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
state.active += 1;
|
||||
state.maxConcurrent = Math.max(state.maxConcurrent, state.active);
|
||||
return state;
|
||||
}
|
||||
|
||||
function finishGitCommandMetric(
|
||||
state: GitCommandMetricsState | null,
|
||||
metric: GitCommandMetric,
|
||||
): void {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.active = Math.max(0, state.active - 1);
|
||||
state.commands.push(metric);
|
||||
}
|
||||
|
||||
function mergeEnvOverlays(
|
||||
env: ProcessEnvRecord | undefined,
|
||||
envOverlay: ProcessEnvRecord | undefined,
|
||||
@@ -59,6 +132,7 @@ export function runGitCommand(
|
||||
const command = formatGitCommand(args);
|
||||
const envOverlay = mergeEnvOverlays(options.env, options.envOverlay);
|
||||
const startedAt = Date.now();
|
||||
const metricsState = beginGitCommandMetric();
|
||||
const logger = typeof options.logger?.trace === "function" ? options.logger : undefined;
|
||||
const traceContext = logger
|
||||
? {
|
||||
@@ -87,6 +161,7 @@ export function runGitCommand(
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let metricFinished = false;
|
||||
let truncated = false;
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
@@ -100,9 +175,24 @@ export function runGitCommand(
|
||||
callback();
|
||||
};
|
||||
|
||||
const finishMetricOnce = (metric: GitCommandMetric) => {
|
||||
if (metricFinished) return;
|
||||
metricFinished = true;
|
||||
finishGitCommandMetric(metricsState, metric);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
||||
child.kill("SIGKILL");
|
||||
finishMetricOnce({
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
startedAtMs: startedAt,
|
||||
durationMs: Date.now() - startedAt,
|
||||
exitCode: null,
|
||||
signal: "SIGKILL",
|
||||
success: false,
|
||||
});
|
||||
settle(() => reject(error));
|
||||
}, timeout);
|
||||
|
||||
@@ -147,6 +237,15 @@ export function runGitCommand(
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
finishMetricOnce({
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
startedAtMs: startedAt,
|
||||
durationMs: Date.now() - startedAt,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
success: false,
|
||||
});
|
||||
if (logger && traceContext) {
|
||||
logger.trace(
|
||||
{
|
||||
@@ -184,6 +283,15 @@ export function runGitCommand(
|
||||
}
|
||||
|
||||
if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) {
|
||||
finishMetricOnce({
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
startedAtMs: startedAt,
|
||||
durationMs: Date.now() - startedAt,
|
||||
exitCode,
|
||||
signal,
|
||||
success: false,
|
||||
});
|
||||
const stderrPreview = result.stderr.trim() || "(no stderr)";
|
||||
const truncationNote = result.truncated ? " (stdout truncated)" : "";
|
||||
|
||||
@@ -197,6 +305,15 @@ export function runGitCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
finishMetricOnce({
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
startedAtMs: startedAt,
|
||||
durationMs: Date.now() - startedAt,
|
||||
exitCode,
|
||||
signal,
|
||||
success: true,
|
||||
});
|
||||
settle(() => resolve(result));
|
||||
});
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user