Files
paseo/packages/server/src/utils/checkout-git-batching.test.ts
T
Mohamed BoudraGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
964bf3e13f fix(server): centralize git subprocesses with p-limit throttling (#310)
* Fix uncaught timeout rejection when archiving agents

* fix(server): centralize git subprocesses under runGitCommand with p-limit throttling

Supersedes #299. Same problem (unbounded git subprocess concurrency causing
zombie accumulation), different approach: a single `runGitCommand(args, opts)`
function backed by `p-limit` instead of a custom semaphore pool.

- Always uses `spawn` (one code path, no exec/execFile split)
- Concurrency configurable via PASEO_GIT_CONCURRENCY env var (default 8)
- 30s default timeout for read-only ops, 120s for mutations
- Centralized error formatting, stdout truncation, process reaping
- Runtime tests verifying throttling, timeout kills, deadlock prevention

* fix: update lockfile signatures and Nix hash

* fix(server): add Windows compatibility to runGitCommand

- shell: true on Windows (resolves .cmd/.bat git shims)
- windowsHide: true always (prevents console window flashes)
- quoteForCmd for args with spaces when routing through cmd.exe
- Tests verify shell/windowsHide behavior per platform

* refactor: use spawnProcess instead of duplicating Windows handling

spawnProcess from spawn.ts already handles shell: true on Windows,
arg quoting for cmd.exe, and windowsHide: true. No need to reimplement.

* fix: non-null assertions for stdio streams in strict build

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-13 13:03:01 +08:00

85 lines
2.8 KiB
TypeScript

import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const spawnCounters = vi.hoisted(() => ({
trackedTextDiffCalls: 0,
}));
vi.mock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
spawn: (...args: Parameters<typeof actual.spawn>) => {
const [command, commandArgs] = args;
if (command === "git" && Array.isArray(commandArgs)) {
const normalizedArgs = commandArgs.map((arg) => String(arg));
const isTrackedTextDiff =
normalizedArgs[0] === "diff" &&
normalizedArgs.includes("HEAD") &&
!normalizedArgs.includes("--numstat") &&
!normalizedArgs.includes("--no-index") &&
!normalizedArgs.includes("--shortstat") &&
!normalizedArgs.includes("--name-status");
if (isTrackedTextDiff) {
spawnCounters.trackedTextDiffCalls += 1;
}
}
return actual.spawn(...args);
},
};
});
import { getCheckoutDiff } from "./checkout-git.js";
function initRepoWithTrackedChanges(fileCount: number): { tempDir: string; repoDir: string } {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-batch-test-")));
const repoDir = join(tempDir, "repo");
execSync(`mkdir -p ${repoDir}`);
execSync("git init -b main", { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `before-${i}\n`);
}
execSync("git add .", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
for (let i = 0; i < fileCount; i += 1) {
writeFileSync(join(repoDir, `file-${i}.txt`), `after-${i}\n`);
}
return { tempDir, repoDir };
}
describe("checkout git diff batching", () => {
let tempDir: string;
let repoDir: string;
beforeEach(() => {
const setup = initRepoWithTrackedChanges(20);
tempDir = setup.tempDir;
repoDir = setup.repoDir;
spawnCounters.trackedTextDiffCalls = 0;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("uses a single tracked git diff command for tracked file diffs", async () => {
const result = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: false,
});
expect(result.diff).toContain("file-0.txt");
expect(result.diff).toContain("file-19.txt");
expect(spawnCounters.trackedTextDiffCalls).toBe(1);
});
});