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:
Mohamed Boudra
2026-08-02 12:20:20 +02:00
committed by GitHub
parent f888bae04d
commit 0a106dd35f
16 changed files with 3558 additions and 631 deletions
+16
View File
@@ -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"),