Keep Git status and diffs current across platforms (#2775)
* fix(server): make Git observation cross-platform Platform-specific watchers and forced full refreshes amplified Git processes and could miss nested changes. Share checkout and repository observation internally, scope final-state refreshes, and bound degraded polling so streamed summaries and diffs stay correct under bursts. * fix(server): refresh Git worktree state on metadata changes * fix(server): keep Git watcher runtime inputs current * fix(server): normalize watcher runtime boundaries * fix(server): reconcile Git observer topology * test(server): report Git watcher event batches * fix(server): close Git observation gaps * chore(ci): remove temporary Git observation jobs
This commit is contained in:
@@ -2664,6 +2664,22 @@ export async function getCheckoutShortstat(
|
||||
return getOrLoadCheckoutShortstat(cwd, context, options);
|
||||
}
|
||||
|
||||
export interface CheckoutWorktreeState {
|
||||
isDirty: boolean;
|
||||
diffStat: CheckoutShortstat | null;
|
||||
}
|
||||
|
||||
export async function getCheckoutWorktreeState(
|
||||
cwd: string,
|
||||
context: CheckoutContext,
|
||||
): Promise<CheckoutWorktreeState> {
|
||||
const [isDirty, diffStat] = await Promise.all([
|
||||
isWorkingTreeDirty(cwd, context),
|
||||
getCheckoutShortstat(cwd, context, { force: true }),
|
||||
]);
|
||||
return { isDirty, diffStat };
|
||||
}
|
||||
|
||||
export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined {
|
||||
return shortstatCache.get(getShortstatCacheKey(cwd));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
|
||||
interface GitCommandTraceContext {
|
||||
id: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
submittedAtMs: number;
|
||||
startedAtMs: number | null;
|
||||
spawnedAtMs: number | null;
|
||||
pid: number | null;
|
||||
submittedQueue: GitCommandTraceQueueState;
|
||||
startedQueue: GitCommandTraceQueueState | null;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
interface GitCommandTraceQueueState {
|
||||
active: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
interface GitCommandTraceSettlement {
|
||||
outcome: "closed" | "spawn_error" | "timed_out";
|
||||
exitCode?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
let nextTraceId = 1;
|
||||
let pendingTraceWrite = Promise.resolve();
|
||||
|
||||
function appendTrace(event: Record<string, unknown>): void {
|
||||
const tracePath = process.env.PASEO_GIT_TRACE_FILE?.trim();
|
||||
if (!tracePath) return;
|
||||
|
||||
pendingTraceWrite = pendingTraceWrite
|
||||
.then(() => appendFile(tracePath, `${JSON.stringify(event)}\n`, "utf8"))
|
||||
.catch(() => {
|
||||
// Diagnostic tracing must never change command execution.
|
||||
});
|
||||
}
|
||||
|
||||
export async function flushGitCommandTrace(): Promise<void> {
|
||||
await pendingTraceWrite;
|
||||
}
|
||||
|
||||
export function submitGitCommandTrace(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
queue: GitCommandTraceQueueState,
|
||||
): GitCommandTraceContext | null {
|
||||
if (!process.env.PASEO_GIT_TRACE_FILE?.trim()) return null;
|
||||
|
||||
const submittedAtMs = Date.now();
|
||||
const context: GitCommandTraceContext = {
|
||||
id: `${process.pid}:${nextTraceId++}`,
|
||||
args: [...args],
|
||||
cwd,
|
||||
submittedAtMs,
|
||||
startedAtMs: null,
|
||||
spawnedAtMs: null,
|
||||
pid: null,
|
||||
submittedQueue: { ...queue },
|
||||
startedQueue: null,
|
||||
settled: false,
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
export function startGitCommandTrace(
|
||||
context: GitCommandTraceContext | null,
|
||||
queue: GitCommandTraceQueueState,
|
||||
): void {
|
||||
if (!context) return;
|
||||
|
||||
context.startedAtMs = Date.now();
|
||||
context.startedQueue = { ...queue };
|
||||
}
|
||||
|
||||
export function spawnGitCommandTrace(
|
||||
context: GitCommandTraceContext | null,
|
||||
pid: number | undefined,
|
||||
): void {
|
||||
if (!context) return;
|
||||
|
||||
context.spawnedAtMs = Date.now();
|
||||
context.pid = pid ?? null;
|
||||
}
|
||||
|
||||
export function settleGitCommandTrace(
|
||||
context: GitCommandTraceContext | null,
|
||||
settlement: GitCommandTraceSettlement,
|
||||
): void {
|
||||
if (!context || context.settled) return;
|
||||
context.settled = true;
|
||||
|
||||
const atMs = Date.now();
|
||||
appendTrace({
|
||||
kind: "git_command",
|
||||
event: "settled",
|
||||
id: context.id,
|
||||
atMs,
|
||||
submittedAtMs: context.submittedAtMs,
|
||||
startedAtMs: context.startedAtMs,
|
||||
args: context.args,
|
||||
cwd: context.cwd,
|
||||
queueWaitMs: context.startedAtMs === null ? null : context.startedAtMs - context.submittedAtMs,
|
||||
spawnCallMs:
|
||||
context.startedAtMs === null || context.spawnedAtMs === null
|
||||
? null
|
||||
: context.spawnedAtMs - context.startedAtMs,
|
||||
pid: context.pid,
|
||||
queue: {
|
||||
submitted: context.submittedQueue,
|
||||
started: context.startedQueue,
|
||||
},
|
||||
durationMs: atMs - context.submittedAtMs,
|
||||
...settlement,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushGitCommandTrace } from "./git-command-trace.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
|
||||
describe("git command trace", () => {
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await Promise.all(
|
||||
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes one asynchronous settlement record for a real git process", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-git-trace-"));
|
||||
tempDirectories.push(directory);
|
||||
const tracePath = path.join(directory, "git.jsonl");
|
||||
vi.stubEnv("PASEO_GIT_TRACE_FILE", tracePath);
|
||||
|
||||
await runGitCommand(["--version"], { cwd: directory });
|
||||
await flushGitCommandTrace();
|
||||
|
||||
const events = (await readFile(tracePath, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
kind: "git_command",
|
||||
event: "settled",
|
||||
args: ["--version"],
|
||||
cwd: directory,
|
||||
queue: {
|
||||
submitted: { active: 0, pending: 0 },
|
||||
started: expect.objectContaining({
|
||||
active: expect.any(Number),
|
||||
pending: expect.any(Number),
|
||||
}),
|
||||
},
|
||||
queueWaitMs: expect.any(Number),
|
||||
spawnCallMs: expect.any(Number),
|
||||
pid: expect.any(Number),
|
||||
outcome: "closed",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
durationMs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
GitCommandRuntimeMetricsWindow,
|
||||
type GitCommandRuntimeMetricsSnapshot,
|
||||
} from "./git-command-runtime-metrics.js";
|
||||
import {
|
||||
settleGitCommandTrace,
|
||||
spawnGitCommandTrace,
|
||||
startGitCommandTrace,
|
||||
submitGitCommandTrace,
|
||||
} from "./git-command-trace.js";
|
||||
import { spawnProcess } from "./spawn.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
@@ -135,10 +141,18 @@ export function runGitCommand(
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<GitCommandResult> {
|
||||
const commandTrace = submitGitCommandTrace(args, options.cwd, {
|
||||
active: gitLimit.activeCount,
|
||||
pending: gitLimit.pendingCount,
|
||||
});
|
||||
const runtimeMetric = gitRuntimeMetrics.submit(getGitOperation(args));
|
||||
const promise = gitLimit(
|
||||
() =>
|
||||
new Promise<GitCommandResult>((resolve, reject) => {
|
||||
startGitCommandTrace(commandTrace, {
|
||||
active: gitLimit.activeCount,
|
||||
pending: gitLimit.pendingCount,
|
||||
});
|
||||
gitRuntimeMetrics.start(runtimeMetric);
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
||||
@@ -173,6 +187,7 @@ export function runGitCommand(
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spawnGitCommandTrace(commandTrace, child.pid);
|
||||
|
||||
let settled = false;
|
||||
let metricFinished = false;
|
||||
@@ -199,6 +214,11 @@ export function runGitCommand(
|
||||
const timer = setTimeout(() => {
|
||||
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
||||
child.kill("SIGKILL");
|
||||
settleGitCommandTrace(commandTrace, {
|
||||
outcome: "timed_out",
|
||||
exitCode: null,
|
||||
signal: "SIGKILL",
|
||||
});
|
||||
finishMetricOnce(
|
||||
{
|
||||
args,
|
||||
@@ -255,6 +275,11 @@ export function runGitCommand(
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
settleGitCommandTrace(commandTrace, {
|
||||
outcome: "spawn_error",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
});
|
||||
finishMetricOnce({
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
@@ -278,6 +303,12 @@ export function runGitCommand(
|
||||
});
|
||||
|
||||
child.on("close", (exitCode, signal) => {
|
||||
settleGitCommandTrace(commandTrace, {
|
||||
outcome: "closed",
|
||||
exitCode,
|
||||
signal,
|
||||
truncated,
|
||||
});
|
||||
const result: GitCommandResult = {
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
||||
|
||||
Reference in New Issue
Block a user