964bf3e13f
* 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>
85 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|