Skip to content

perf(server): batch Git ignore checks for wide directories - #28982

Closed
gchen0002 wants to merge 2 commits into
pingdotgg:mainfrom
gchen0002:codex/windows-ignore-check-batches-20261101
Closed

gchen0002 wants to merge 2 commits into
pingdotgg:mainfrom
gchen0002:codex/windows-ignore-check-batches-20261101

Conversation

@gchen0002

@gchen0002 gchen0002 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Listing a directory with 5,000 mixed files starts six sequential Git processes to check ignore rules. Paths already go through stdin, but each process handles at most 1,000 children.

This raises the limit to 10,000 paths per process, capped at 1 MiB of UTF-8 input including NUL separators. The parent-ignore shortcut from #20062 still starts above 1,000 children. If Git rejects a batch, retry in 1,000-path groups to keep labels from earlier successful groups.

Windows measurements

Full WorkspaceEntries.list calls with real Git and isolated synthetic fixtures. Two warm-up pairs, then 20 alternating pairs per scenario. These are arithmetic means against main at fa31b66. Returned objects matched in every pair, output was untruncated, and the fixture index stayed unchanged.

Scenario main this PR Mean change
5,000 mixed files 292.068 ms 193.691 ms -33.68%
12,501 mixed entries 729.801 ms 445.133 ms -39.01%
5,000 files, 261-byte paths 340.694 ms 267.158 ms -21.58%
2,500 files, 1,161-byte UTF-8 paths 269.792 ms 271.515 ms +0.64%
100-file control 27.170 ms 27.410 ms +0.88%
5,000 wholly ignored control 27.603 ms 26.888 ms -2.59%

Git calls fell from 6 to 2 for 5,000 mixed files, 14 to 3 for 12,501 entries, and 6 to 3 for 261-byte paths. The UTF-8 stress case still took 4 calls; both controls took 1. The controls and UTF-8 stress case show no meaningful gain.

Per-path work and total input/output bytes stayed the same in normal cases. The largest PR input and output were 1,048,383 bytes each. Larger batches use larger buffers and retain the 10-second timeout and 16 MiB output cap. A timeout can discard more ignore labels. Fatal retries also add work: the error probe attempted 8,004 paths versus 6,002 on main.

Measured on Windows 10.0.26300, Ryzen 5 5600, 32 GiB RAM, Node 24.18.0 and Git 2.41.0.windows.1. CPU, UI, native macOS/Linux and slow filesystems were not measured.

Verification

  • vp test run apps/server/src/workspace/WorkspaceEntries.test.ts passed, 23 cases. The extended wide-directory case fails on main because it launches too many Git processes. POSIX-only cases return early on Windows.
  • vp run check:changed origin/main passed formatting, lint, knip, scoped typechecks and 24 adjacent cases. The final focused tests, targeted format/lint and scoped server typecheck also passed.

Real Git probes confirmed cleanup on cancellation and timeout. A probe that made Git reject a later path returned the same 5,999 ignore labels as main. The POSIX-only filename test still needs a macOS or Linux run.

Reproduce the measurements

Save this as measure.mjs in a fresh directory outside the checkout. Install this PR's dependencies with vp i, then run node /absolute/path/to/measure.mjs from the checkout root using Node 24.18.0. The script creates synthetic files, compares fixed main source with the checkout source, and writes raw samples and summary JSON beside itself. It starts no server and uses no live T3 state.

The reported run reused the same unchanged synthetic fixture after the changed-file checks finished. This script builds that fixture afresh. Timings exclude setup and result comparison.

import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createHash } from "node:crypto";
import { createRequire, registerHooks } from "node:module";
import { fileURLToPath, pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { performance } from "node:perf_hooks";
import assert from "node:assert/strict";

const root = process.cwd(), out = path.dirname(fileURLToPath(import.meta.url));
const base = "fa31b66bbb2ae5234205a8628a78e038d917202f";
const sourceFile = "apps/server/src/workspace/WorkspaceEntries.ts";
const config = path.join(out, "empty.gitconfig");
fs.writeFileSync(config, "");
Object.assign(process.env, { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: config,
  GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" });
const git = (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", windowsHide: true, maxBuffer: 2 ** 24 });
const sources = { main: git(root, ["show", `${base}:${sourceFile}`]), pr: fs.readFileSync(path.join(root, sourceFile), "utf8") };
for (const [variant, source] of Object.entries(sources)) fs.writeFileSync(path.join(out, `${variant}-WorkspaceEntries.ts`), source);
assert.notEqual(sources.main, sources.pr);
const url = pathToFileURL(path.join(root, sourceFile)).href;
registerHooks({ load(input, context, next) {
  const loaded = next(input, context);
  for (const variant of Object.keys(sources)) if (input === `${url}?${variant}`) return { ...loaded, source: sources[variant] };
  return loaded;
} });
const require = createRequire(path.join(root, "apps/server/package.json"));
const dep = name => import(pathToFileURL(require.resolve(name)).href);
const Effect = await dep("effect/Effect"), NodeServices = await dep("@effect/platform-node/NodeServices");
const { ChildProcess, ChildProcessSpawner } = await dep("effect/process");
const local = part => import(pathToFileURL(path.join(root, "apps/server/src", part)).href);
const Config = await local("config.ts"), Paths = await local("workspace/WorkspacePaths.ts");
const Process = await local("vcs/VcsProcess.ts"), Search = await local("workspace/WorkspaceSearchIndex.ts");
const ProcessRunner = await local("processRunner.ts");
const modules = { main: await import(`${url}?main`), pr: await import(`${url}?pr`) };
const cwd = path.join(out, "fixture");
assert(!fs.existsSync(cwd), "Use a fresh owned artifact directory or move only this script to one");
fs.mkdirSync(cwd);
git(cwd, ["init", "-qb", "main"]);
git(cwd, ["config", "core.longpaths", "true"]);
git(cwd, ["config", "core.autocrlf", "false"]);
git(cwd, ["config", "core.fsmonitor", "false"]);
fs.writeFileSync(path.join(cwd, ".gitignore"), "*.tmp\n!keep.tmp\nignored/\n");
fs.mkdirSync(path.join(cwd, "tracked"));
for (let i = 0; i < 500; i++) fs.writeFileSync(path.join(cwd, "tracked", `${i}.txt`), "fixture\n");
git(cwd, ["add", "."]);
git(cwd, ["-c", "user.name=Fixture", "-c", "user.email=fixture@invalid", "commit", "-qm", "fixture"]);
const scenarios = [
  { name: "wide", directoryPath: "wide", count: 5000 },
  { name: "over-count-cap", directoryPath: "over-count-cap", count: 12500 },
  { name: "small", directoryPath: "small", count: 100 },
  { name: "ignored", directoryPath: "ignored", count: 5000 },
  { name: "long-paths", directoryPath: Array.from({ length: 3 }, () => "component".repeat(9)).join("/"), count: 5000 },
  { name: "utf8-byte-cap", directoryPath: Array.from({ length: 6 }, () => "é".repeat(95)).join("/"), count: 2500, allIgnored: true },
];
for (const scenario of scenarios) {
  const dir = path.join(cwd, scenario.directoryPath);
  fs.mkdirSync(dir, { recursive: true });
  const queue = Array.from({ length: scenario.count }, (_, i) => i).values();
  await Promise.all(Array.from({ length: 16 }, async () => {
    for (const i of queue) await fsp.writeFile(path.join(dir, `file-${String(i).padStart(5, "0")}.${scenario.allIgnored || i % 2 ? "tmp" : "txt"}`), "");
  }));
  if (scenario.name === "over-count-cap") {
    fs.writeFileSync(path.join(dir, "keep.tmp"), "");
    git(cwd, ["add", "-f", `${scenario.directoryPath}/file-00001.tmp`]);
    scenario.count++;
  }
}
const indexBefore = fs.readFileSync(path.join(cwd, ".git/index"));
const raw = [];
await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
  const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
  let spawns = [], calls = [], active = 0, peak = 0;
  const spawner = ChildProcessSpawner.make(command => delegate.spawn(command).pipe(Effect.tap(handle => Effect.sync(() => {
    spawns.push({ pid: handle.pid, args: ChildProcess.isStandardCommand(command) ? [...command.args] : [] });
  }))));
  const realProcess = yield* Process.make.pipe(Effect.provide(ProcessRunner.layer),
    Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
  const recorder = Process.VcsProcess.of({ run: input => Effect.gen(function* () {
    const call = { stdinBytes: Buffer.byteLength(input.stdin ?? ""), paths: (input.stdin?.match(/\0/g) ?? []).length };
    calls.push(call);
    peak = Math.max(peak, ++active);
    const start = performance.now();
    try {
      const result = yield* realProcess.run(input);
      Object.assign(call, { elapsedMs: performance.now() - start, stdoutBytes: Buffer.byteLength(result.stdout),
        stdoutTruncated: result.stdoutTruncated, stderrTruncated: result.stderrTruncated, exitCode: result.exitCode });
      assert(!result.stdoutTruncated && !result.stderrTruncated);
      assert(result.exitCode === 0 || result.exitCode === 1);
      return result;
    } finally { active--; }
  }) });
  const services = {};
  for (const [variant, module] of Object.entries(modules)) services[variant] = yield* module.make.pipe(
    Effect.provide(Paths.layer), Effect.provide(Search.WorkspaceSearchIndexMap.layer),
    Effect.provideService(Process.VcsProcess, recorder),
    Effect.provide(Config.layerTest(cwd, path.join(out, "runtime", variant))));
  for (const scenario of scenarios) {
    const run = (variant, pair) => Effect.gen(function* () {
      spawns = []; calls = []; peak = 0;
      const start = performance.now();
      const result = yield* services[variant].list({ cwd, directoryPath: scenario.directoryPath });
      const elapsedMs = performance.now() - start;
      assert.equal(result.entries.length, scenario.count);
      assert.equal(spawns.length, calls.length);
      if (variant === "pr") for (const call of calls) assert(call.stdinBytes <= 1024 * 1024);
      if (pair >= 0) {
        const row = { scenario: scenario.name, variant, pair, elapsedMs, spawns, calls, peak,
          ignored: result.entries.filter(entry => entry.ignored).length };
        raw.push(row);
        fs.appendFileSync(path.join(out, "raw.jsonl"), JSON.stringify(row) + "\n");
      }
      return result;
    });
    for (let i = 0; i < 2; i++) assert.deepEqual(yield* run("main", -1), yield* run("pr", -1));
    for (let pair = 0; pair < 20; pair++) {
      const order = pair % 2 ? ["pr", "main"] : ["main", "pr"];
      assert.deepEqual(yield* run(order[0], pair), yield* run(order[1], pair));
    }
    console.log(`${scenario.name} finished`);
  }
})).pipe(Effect.provide(NodeServices.layer)));
assert.deepEqual(fs.readFileSync(path.join(cwd, ".git/index")), indexBefore);
const summary = scenarios.map(scenario => {
  const rows = raw.filter(row => row.scenario === scenario.name);
  const measurements = Object.fromEntries(["main", "pr"].map(variant => {
    const selected = rows.filter(row => row.variant === variant), times = selected.map(row => row.elapsedMs);
    return [variant, { meanMs: times.reduce((a, b) => a + b) / times.length,
      minMs: Math.min(...times), maxMs: Math.max(...times),
      commands: [...new Set(selected.map(row => row.spawns.length))],
      pathsPerList: selected[0].calls.reduce((a, call) => a + call.paths, 0),
      inputBytesPerList: selected[0].calls.reduce((a, call) => a + call.stdinBytes, 0),
      outputBytesPerList: selected[0].calls.reduce((a, call) => a + call.stdoutBytes, 0),
      maxInputBytes: Math.max(...selected.flatMap(row => row.calls.map(call => call.stdinBytes))),
      maxOutputBytes: Math.max(...selected.flatMap(row => row.calls.map(call => call.stdoutBytes))),
      peakConcurrentCommands: Math.max(...selected.map(row => row.peak)) }];
  }));
  return { scenario: scenario.name, entries: scenario.count, pairs: 20, ...measurements,
    meanChangePercent: (measurements.pr.meanMs / measurements.main.meanMs - 1) * 100 };
});
const metadata = { base, sources: Object.fromEntries(Object.entries(sources).map(([key, source]) => [key, createHash("sha256").update(source).digest("hex")])),
  node: process.version, git: git(root, ["--version"]).trim(), platform: `${process.platform} ${os.release()} ${os.arch()}`,
  cpu: os.cpus()[0]?.model, totalMemory: os.totalmem(),
  scope: "Full production WorkspaceEntries.list with real Git, isolated fixture, source loader for fixed main vs exact working source",
  completeResultsIdentical: true, originalIndexUnchanged: true, allOutputsUntruncated: true, summary };
fs.writeFileSync(path.join(out, "summary.json"), JSON.stringify(metadata, null, 2));
console.log(JSON.stringify(metadata));

Implemented and reviewed with gpt-6.1-sol through the Codex harness in T3 Code.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Nov 2, 2026
@gchen0002 gchen0002 closed this Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant