Skip to content

Commit c229223

Browse files
fix(scripts): give every throwaway-repo git child an environment of its own (#16644 Tier B) (#19168)
Part of #16644 Clause-②: no ## What this changes Every `git` child these files spawn against a **throwaway** repository now carries an environment of its own — `gitFreeEnv()` from `scripts/git-env.mjs`, the blanket `GIT_*` strip #16624 landed and #16753 applied in Tier A. The judgement is made **per spawn point**, not per file: a child that operates on the repository its `cwd` and arguments name gets the strip; a child that talks to a remote keeps the ambient environment and is marked as doing so. ⚠️ **This is prevention, not a repaired outage.** No failure has been measured in any of these files — the card is observation-class and says so. The mechanism was measured on #16624: 8,190 paths staged as deleted in a shared index, and `core.bare = true` written into the `.git/config` every linked worktree of that clone reads, from a self-test that printed ticks throughout. What follows is that shape, reproduced against a disposable stand-in and then closed. ### Scope, and why it is wider than the card's Tier B - **Tier B, 13 files under `scripts/**`** — the card's enumeration, re-measured on `origin/main` @ `5d0ee8f60` as still bare. - **`packages/spec/scripts/build-schemas-check-mode.test.ts` and `packages/spec/scripts/sharded-artifacts.test.ts`** — added on the **triage ruling** (comment 5578727562, acceptance point 4): 「`packages/spec` 两个文件收敛到 `gitFreeEnv()`,`LEAKED_GIT_ENV` 一并删除。⛔ 不要保留"两种都行"的过渡态」. Their hand-maintained ten-name allowlist of `GIT_*` location variables is gone; the reason it goes rather than gains an entry is the card's own: an allowlist has to be kept level with git's list, and the key it misses is the key that bites. - ⛔ **Not touched**: Tier A (`scripts/git-merge-regen.mjs`, `scripts/check-regen-pending.mjs`) — already done by **#16753**, which triage split out of this card as `priority:p1`; and Tier C, which contains `scripts/pm/**` — the `domain:skills` lane. - ⛔ **No repo-wide gate here**, per triage acceptance point 5: it would land red and get weakened. It belongs after Tier C, on its own card. ## Per spawn point: local-only or network-touching `gitFreeEnv()` is applied ONLY to the local-only column. The boundary is triage's, fixed verbatim from the card: this container really does carry `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_0=credential.interactive`, `GIT_CONFIG_KEY_1/2=url.https://github.com/.insteadOf` and `GIT_SSL_CAINFO=/root/.ccr/ca-bundle.crt` (read off the box while writing this), so a network child stripped of them loses its transport. | file | local-only spawn points, now stripped | left ambient, and why | |:--|:--|:--| | `scripts/ablation-dist-preflight.mjs` | `status --porcelain -z`; `show HEAD:PATH`; the self-test's `git()` over a mkdtemp corpus | — | | `scripts/check-adr-0087-registration.mjs` | the `git(args, cwd)` helper (every argv form); `cat-file --batch` | — (the `fetch --unshallow` texts are remedy prose, not spawns) | | `scripts/check-bash32-floor.mjs` | `listPopulation`'s `ls-files -z`; `fixtureRepo`'s `init -q` and `add -A`; the index-vs-disk control's `ls-files` | — | | `scripts/check-changeset-no-major.mjs` | the one `git()` helper: `init` / `config` / `add` / `commit` / `diff` / `show` / `rev-parse` / `merge-base`, **and** the #4690 leg's `fetch` whose remote is a local mkdtemp PATH | — no https/ssh child exists in this file | | `scripts/check-empty-changeset.mjs` | the one `git()` helper, same census, two local-path `fetch` legs | — | | `scripts/check-engine-split-ratio.mjs` | the main `git` helper; the self-test `g` helper (`init`/`config`/`add`/`log` + two `clone` from a `file://` URL under its own mkdtemp); the dated `commit`; the `--cwd` re-entry child | — | | `scripts/check-nul-bytes.mjs` | `lsFiles`; `repoRoot`; the self-test's `init`, `config`, `add -A -f`, `ls-files`, `add -A` | — | | `scripts/check-skill-frame-freshness.mjs` | `rev-parse` / `init` / `config` / `remote add` / `update-ref` / `commit`, through a `git()` helper that strips by default | ⛔ **`fetch --quiet --no-tags origin main` — NETWORK-TOUCHING.** Marked `⛔ AMBIENT ENVIRONMENT ON PURPOSE`, reached through an explicit `network: true`. Stripped, this gate would degrade to its offline rung on every run: a warning where a verdict belongs | | `scripts/check-type-check-coverage.mjs` | `check-ignore --stdin -z`; `readIgnoredPaths`' `ls-files --others --ignored`; the self-test's `g` | — (`tsc` children are not `git`) | | `scripts/docs-audit/check-drift-comment.mjs` | `GIT_ENV`'s base, so the fixture `git()` helper and the mapper child both inherit the strip | — the identity and `GIT_CONFIG_GLOBAL/SYSTEM` pins are DELIBERATE and re-applied on top of it | | `scripts/objectui-changeset-digest.mjs` | the `git(cwd, args)` helper; the `clone` from a `file://` mkdtemp source; `rev-parse HEAD`; `merge-base --is-ancestor`; and the **six** `bash bump-objectui.sh` fixture drivers, whose env BASE is now stripped because that script spawns `git` one frame down | two `bash` capability probes carrying `BASH_ENV` — they spawn **no** `git` at all, so they are not spawn points for this card | | `scripts/objectui-range.mjs` | the `git(cwd, args)` helper; the end-to-end `node` re-entry child | — | | `scripts/collect-release-notes.sh` | one `unset` of every ambient `GIT_*` name, before any child: all four report sections read checkouts by path, and the self-test's `init`/`add`/`commit`/three `clone`s/one `fetch` are all under its own `mktemp` root with a `file://` remote | — the per-command `GIT_AUTHOR_DATE=… git commit` prefixes in the fixture loop are applied per invocation, after that line, and are deliberately unaffected | | `packages/spec/scripts/build-schemas-check-mode.test.ts`, `…/sharded-artifacts.test.ts` | `HERMETIC_ENV` is now built from `gitFreeEnv()`; every fixture git and every generator run inside one operates on an `fs.mkdtemp` directory | — `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` / `GIT_CONFIG_NOSYSTEM` stay set on top: they are the file's own "read no config" pins, not inherited leakage | ## 验收备注 Triage's six acceptance points (comment 5578727562), and what was measured against each. **1. 先红后绿, every batch.** A one-time instrument built a **disposable** stand-in repository (`git init` + a tracked file + a commit), pointed the hook-exported location variables at it, ran each file's temp-repo path, and compared a fingerprint of the stand-in's `config`, index (`ls-files`), refs (`for-each-ref`) and `HEAD` before and after. ⭐ The stand-in is disposable on purpose: pointing those variables at this checkout would not test the incident, it would be the incident, for every agent on the box — the same discipline `scripts/git-merge-regen.mjs` states for its own probe. Two leak shapes were run, because they are not interchangeable and `scripts/git-env.mjs` records why: with `GIT_WORK_TREE` also set, git resolves the work tree to the stand-in and `add -A` re-adds its own files, so the index comes back unchanged and a case can pass proving nothing. `GIT_DIR` alone is the incident's shape. - **Firing control, per probe**: before any verdict, a plain `git rev-parse --absolute-git-dir` in an unrelated directory had to answer with the stand-in's git dir. It did, in all 52 probes. A control that does not fire measures nothing. - **RED, the UNFIXED files** (restored one at a time from the merge base `5d0ee8f60`, each proven on disk by comparing `git hash-object` against that blob, then restored from `HEAD` and proven restored by an empty `git diff HEAD`): **12 of 13 wrote the stand-in** under `GIT_DIR` alone — config in 10, index in 11, refs in 10, `HEAD` in 4. ⭐ Three of them (`ablation-dist-preflight`, `check-adr-0087-registration`, `docs-audit/check-drift-comment`) **exited 0 while doing it** — #16624's signature exactly: a green self-test writing another repository. The 13th, `check-nul-bytes.mjs`, did not write the stand-in; it exited **1**, because the leaked `init` created no repository in the directory it was handed and the fixture could not be built. Red in a different key, and stated as such rather than counted as a write. - **GREEN, on this branch**: all 13 leave the stand-in **byte-identical** under BOTH leak shapes (26 probes), and all 26 child runs **exit 0** — the isolation is complete enough that the self-tests still pass with a leaked git environment in the process. - Every probe also fingerprinted the real checkout (`status --porcelain` + the shared `config`) before and after. It never moved, in any of the 52 probes, and the whole-tree `git status --porcelain` after the red leg was empty. **2. Negative control.** With those variables absent — ordinary CI and hand runs — every changed file behaves as today: 19 invocations (each gate's **bare** call and its `--self-test`, both batteries, never `--self-test` alone), all exit 0, real batteries observed in the logs (`check-nul-bytes` 75 assertions, `check-bash32-floor` 177 cases, `check-type-check-coverage` 55+97+56+28+19+18 cases, `objectui-range` all checks, `collect-release-notes` all cases). The two `packages/spec` files: **2 files / 114 tests passed**, 487s. **3. Per spawn point, local-only vs network-touching.** The table above. One network-touching child in the whole surface, and it keeps the ambient environment. **4. `packages/spec` converged, allowlist deleted.** Both files now build `HERMETIC_ENV` from `gitFreeEnv()`; the allowlist constant is gone from both and is not re-spelled in either file, so a census of the retired shape does not match the paragraph that explains it. ⛔ No fourth spelling was introduced. Equivalence is structural rather than argued: the ten names the allowlist removed are a strict subset of "every `GIT_`-prefixed key", so the new fixture environment is a subset of the old one, and the 114 tests above pass on it. **5. No repo-wide gate.** None added. **6. Closing count, with the card's own classifier — and it is NOT zero.** Reported in the report and below: the classifier's spawn probe was run in the **argv-array** form (`'git'` plus a separate `init`/`add`/`ls-files` probe), never as a command string, because triage's own first measurement was a false negative for exactly that reason and 「对照不发火的零什么都没测到」. ## Acceptance notes ⚠️ **One finding, filed rather than fixed here: `packages/create-objectstack/src/template-consistency.test.ts` carries a THIRD copy of the retired `GIT_*` allowlist** (same ten names, same `for … delete env[key]` shape). Triage's census of that shape on 2026-09-08 found two carriers and named both; this one is not on its list. It is left out of this PR deliberately — converging it would add a package, and therefore a verification surface, that neither triage nor the dispatch declared — and it is reported for the Tier C lap so the "one spelling, not two" ruling can actually close. Until it lands, the allowlist shape survives in one file. Noted, not filed: nothing else. ## Local verification, and what is declared to CI - `dispatch-gates --commands` re-derived in this worktree against the real changed set (15 paths, merge base `5d0ee8f60`, three-dot) → 82 commands; run, with results in the report. `pnpm check:pm-dispatch-gates` was detached and waited on with `tail --pid`, per its recorded 430–450s prescription. - ⛔ Declared to CI, not run locally: the 50 artifact-roster families, the 11 wide-population families, the 5 workflow-valued families and the 5 path-scheduled CI jobs `dispatch-gates` names as outside its runnable list, and the repo-wide `pnpm lint`. - `skip-changeset`: nothing published moves. `packages/spec`'s `files[]` does not list `scripts/`, and repo-root `scripts/**` is inside no package. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- ## PM addendum — the declared surface, corrected (`domain:devx` 执行席, 2026-09-19T04:48Z) ⚠️ **This body undercounts its own surface.** The API reads **17 changed files**; the body says 16 in one place and "15 paths" in the verification section. The author declined to PATCH the body under its write budget and put the reading in the commit message instead, so the PM records it here rather than leaving a reviewer to reconcile it. The two files beyond the originally dispatched 13 `scripts/**` + 2 `packages/spec` fixtures, both **forced** rather than chosen: 1. `packages/spec/vitest.repo-tests.json` (+1 line) — **verified by the PM's own ablation** at 2026-09-19T04:15:38Z: with the line removed `check-cross-package-test-inputs` exits **1** naming `scripts/sharded-artifacts.test.ts`; with it, **0**. The blob was restored to its pristine hash and `git diff HEAD` was clean. 2. `scripts/git-env.d.mts` (43 lines) — the sanctioned shape, not a new invention. **PM control, read at 2026-09-19T04:47:44Z**: `packages/spec/scripts/build-schemas-check-mode.test.ts` imports `git-env.mjs` at `:57` and `check-regen-pending.mjs` at `:58` — adjacent lines, same file, same TS program, same lane — and only the second had a sibling `.d.mts`. Nine such mirrors existed before this PR; there are now ten, and `check-declaration-mirrors` **discovers** its corpus (both batteries exit 0 on this head). ⛔ The PM has **kept** the mirror rather than reverting it: the two remedies that avoid a new file are a `@ts-expect-error` suppression and duplicating the retired allowlist back into the fixtures, and both are refused — the first trades a red for a suppression, the second reverses triage's 「收敛到 `gitFreeEnv()`,退役 allowlist」 ruling. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: claude <noreply@anthropic.com>
1 parent 57cfedd commit c229223

17 files changed

Lines changed: 268 additions & 66 deletions

‎packages/spec/scripts/build-schemas-check-mode.test.ts‎

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import os from 'node:os';
5454
import path from 'node:path';
5555
import { fileURLToPath } from 'node:url';
5656

57+
import { gitFreeEnv } from '../../../scripts/git-env.mjs';
5758
import { schemaStamp } from '../../../scripts/check-regen-pending.mjs';
5859
import { RENAMED_DEFS } from './lib/renamed-defs';
5960
import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
@@ -232,25 +233,23 @@ let surfaceBaseDescription: string;
232233
// are already serial — vitest runs a file's tests one at a time, and every repo
233234
// here is an `fs.mkdtemp`, so no concurrently running test FILE can name one.
234235

235-
/** `GIT_*` variables that would point a fixture's git at a different repository
236-
* (or a different index/object store) than the directory it was handed. */
237-
const LEAKED_GIT_ENV = [
238-
'GIT_DIR',
239-
'GIT_WORK_TREE',
240-
'GIT_COMMON_DIR',
241-
'GIT_INDEX_FILE',
242-
'GIT_OBJECT_DIRECTORY',
243-
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
244-
'GIT_NAMESPACE',
245-
'GIT_CEILING_DIRECTORIES',
246-
'GIT_TEMPLATE_DIR',
247-
'GIT_CONFIG',
248-
] as const;
236+
/* #16644 -- a hand-maintained allowlist of ten GIT_* location variables used to stand
237+
* here. It is retired in favour of the blanket strip in `scripts/git-env.mjs`, and the
238+
* constant is not re-spelled anywhere in this file so that a census of the retired shape
239+
* does not match this paragraph.
240+
*
241+
* The reason the allowlist goes rather than gets one more entry: it had to be kept level
242+
* with git's own list of location variables, and its failure mode is that THE KEY IT
243+
* MISSES IS THE KEY THAT BITES. `gitFreeEnv()` removes every GIT_-prefixed key instead,
244+
* which is the shape #16624 landed and #16753 applied. ⛔ One spelling in the repo, not
245+
* two -- no "either is fine" transition state.
246+
*
247+
* Every git this file spawns is LOCAL-ONLY: it operates on the `fs.mkdtemp` fixture named
248+
* by its `cwd`, so the blanket strip takes no transport configuration away from it. */
249249

250250
/** The environment every fixture git — and every generator run inside one — gets. */
251251
const HERMETIC_ENV: NodeJS.ProcessEnv = (() => {
252-
const env = { ...process.env };
253-
for (const key of LEAKED_GIT_ENV) delete env[key];
252+
const env = gitFreeEnv();
254253
// git's own documented "read no config file" spellings. `/dev/null` parses as
255254
// an empty config, which is what makes `init.templateDir`, `core.hooksPath`
256255
// and any ambient `[gc]` block unable to reach a fixture.

‎packages/spec/scripts/sharded-artifacts.test.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import fs from 'node:fs';
3939
import os from 'node:os';
4040
import path from 'node:path';
4141

42+
import { gitFreeEnv } from '../../../scripts/git-env.mjs';
43+
4244
import {
4345
API_SURFACE_DIR_NAME,
4446
AUTHORABLE_SURFACE_DIR_NAME,
@@ -411,25 +413,23 @@ describe('sharded artifacts — entry-point shard naming (#5837)', () => {
411413
// Nothing about what these cases assert changes: the fixtures are built from the
412414
// same writes and read by the same `readShardedKeysAtRev`, only insulated.
413415

414-
/** `GIT_*` variables that would point a fixture's git at a different repository
415-
* (or a different index/object store) than the directory it was handed. */
416-
const LEAKED_GIT_ENV = [
417-
'GIT_DIR',
418-
'GIT_WORK_TREE',
419-
'GIT_COMMON_DIR',
420-
'GIT_INDEX_FILE',
421-
'GIT_OBJECT_DIRECTORY',
422-
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
423-
'GIT_NAMESPACE',
424-
'GIT_CEILING_DIRECTORIES',
425-
'GIT_TEMPLATE_DIR',
426-
'GIT_CONFIG',
427-
] as const;
416+
/* #16644 -- a hand-maintained allowlist of ten GIT_* location variables used to stand
417+
* here. It is retired in favour of the blanket strip in `scripts/git-env.mjs`, and the
418+
* constant is not re-spelled anywhere in this file so that a census of the retired shape
419+
* does not match this paragraph.
420+
*
421+
* The reason the allowlist goes rather than gets one more entry: it had to be kept level
422+
* with git's own list of location variables, and its failure mode is that THE KEY IT
423+
* MISSES IS THE KEY THAT BITES. `gitFreeEnv()` removes every GIT_-prefixed key instead,
424+
* which is the shape #16624 landed and #16753 applied. ⛔ One spelling in the repo, not
425+
* two -- no "either is fine" transition state.
426+
*
427+
* Every git this file spawns is LOCAL-ONLY: it operates on the `fs.mkdtemp` fixture named
428+
* by its `cwd`, so the blanket strip takes no transport configuration away from it. */
428429

429430
/** The environment every fixture git in this file gets. */
430431
const HERMETIC_ENV: NodeJS.ProcessEnv = (() => {
431-
const env = { ...process.env };
432-
for (const key of LEAKED_GIT_ENV) delete env[key];
432+
const env = gitFreeEnv();
433433
// git's own documented "read no config file" spellings. `/dev/null` parses as
434434
// an empty config, which is what makes `init.templateDir`, `core.hooksPath`
435435
// and any ambient `[gc]` block unable to reach a fixture.

‎packages/spec/vitest.repo-tests.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"scripts/references-banner.test.ts",
2121
"scripts/root-index.test.ts",
2222
"scripts/schema-tree-freshness.test.ts",
23+
"scripts/sharded-artifacts.test.ts",
2324
"scripts/solution-blueprint-header-row.test.ts",
2425
"scripts/strictness-ledger-doc.test.ts",
2526
"scripts/strictness-ledger.test.ts",

‎scripts/ablation-dist-preflight.mjs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ import { execFileSync } from 'node:child_process';
211211
import { tmpdir } from 'node:os';
212212
import { fileURLToPath } from 'node:url';
213213
import process from 'node:process';
214+
import { gitFreeEnv } from './git-env.mjs';
214215
import { isEntrypoint } from './invoked-as.mjs';
215216
import { WORKSPACE_FILE, parseWorkspaceGlobs, workspacePackageDirs } from './workspace-enumerator.mjs';
216217

@@ -450,6 +451,11 @@ function readTreeStatus(repoRoot) {
450451
try {
451452
const out = execFileSync('git', ['status', '--porcelain', '-z'], {
452453
cwd: repoRoot,
454+
// The tree under test is the one `repoRoot` names and nothing else (#16644).
455+
// An inherited GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE outranks `cwd`, so under a
456+
// hook this would certify SOME OTHER tree as restored -- the one direction this
457+
// preflight exists to make impossible.
458+
env: gitFreeEnv(),
453459
encoding: 'utf8',
454460
maxBuffer: 64 * 1024 * 1024,
455461
stdio: ['ignore', 'pipe', 'pipe'],
@@ -475,6 +481,8 @@ function markerPresence(repoRoot, entries, marker) {
475481
try {
476482
head = execFileSync('git', ['show', `HEAD:${e.path}`], {
477483
cwd: repoRoot,
484+
env: gitFreeEnv(), // #16644: the HEAD blob of THIS tree, never a hook's
485+
478486
maxBuffer: 64 * 1024 * 1024,
479487
stdio: ['ignore', 'pipe', 'ignore'],
480488
});
@@ -714,7 +722,9 @@ function selfTest() {
714722
// verdict mean anything. This leg replays the measured incident end to end.
715723
const repo = mkdtempSync(join(tmpdir(), 'ablation-preflight-git-'));
716724
try {
717-
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
725+
// #16644: a throwaway corpus, so every child is spawned with GIT_* stripped --
726+
// `git init` here under an inherited GIT_DIR writes core.bare into the SHARED config.
727+
const git = (...args) => execFileSync('git', args, { cwd: repo, env: gitFreeEnv(), stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
718728
const srcPath = join(repo, 'source.ts');
719729
const genPath = join(repo, 'generated-baseline.json');
720730
const MARK = 'OS_ABLATION_LEAK_MARK';

‎scripts/check-adr-0087-registration.mjs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod
365365
import { tmpdir } from 'node:os';
366366
import { dirname, join, resolve } from 'node:path';
367367
import { fileURLToPath } from 'node:url';
368+
import { gitFreeEnv } from './git-env.mjs';
368369
import { isEntrypoint } from './invoked-as.mjs';
369370
import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs';
370371
// #16421 — the DIRECTION ARM, read through the fleet's one declaration reader.
@@ -2002,11 +2003,16 @@ export function projectedMigrationIds(specChangesJson) {
20022003
// ---------------------------------------------------------------------------
20032004

20042005
function git(args, cwd) {
2006+
// `env: gitFreeEnv()` (#16644): every caller of this helper -- the real checkout on a
2007+
// gate run, a mkdtemp fixture in the self-test -- names its repository by `cwd`. An
2008+
// inherited GIT_DIR outranks `cwd`, so without the strip the fixture legs read and
2009+
// write THE REAL REPOSITORY under a hook, silently and with `ok` printed throughout.
2010+
//
20052011
// stderr is PIPED, not inherited: `showOrNull` probes paths that legitimately do
20062012
// not exist at a rev (a ledger file added mid-history, a changeset deleted), and
20072013
// git's "fatal: path ... does not exist" would otherwise print as though the gate
20082014
// had failed while it is in fact answering the question it asked.
2009-
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
2015+
return execFileSync('git', args, { cwd, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
20102016
}
20112017

20122018
/** File contents at a rev, or `null` when the path does not exist there. */
@@ -2035,7 +2041,7 @@ function showManyOrNull(rev, paths, cwd) {
20352041
let out;
20362042
try {
20372043
out = execFileSync('git', ['cat-file', '--batch'], {
2038-
cwd, input, maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'],
2044+
cwd, env: gitFreeEnv(), input, maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'],
20392045
});
20402046
} catch { return found; }
20412047

‎scripts/check-bash32-floor.mjs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ import { tmpdir } from 'node:os';
212212
import { fileURLToPath } from 'node:url';
213213
import process from 'node:process';
214214

215+
import { gitFreeEnv } from './git-env.mjs';
215216
import { isEntrypoint } from './invoked-as.mjs';
216217

217218
const REPO_ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
@@ -847,6 +848,11 @@ function unreadableReason(err) {
847848
*/
848849
export function listPopulation(root) {
849850
const out = spawnSync('git', ['-C', root, 'ls-files', '-z', '--', ...WALK_ROOTS], {
851+
// #16644: `-C root` is the ONLY thing that may decide which index is read. An
852+
// inherited GIT_DIR outranks it, and this function is called with a mkdtemp fixture
853+
// as `root` in every end-to-end leg below -- under a hook those legs would census
854+
// the real repository and report a number about the wrong tree.
855+
env: gitFreeEnv(),
850856
encoding: 'utf8',
851857
maxBuffer: 64 * 1024 * 1024,
852858
});
@@ -953,12 +959,15 @@ function reportUnreadable(unreadable, population, findings) {
953959
*/
954960
function fixtureRepo(files) {
955961
const dir = mkdtempSync(join(tmpdir(), 'bash32-floor-'));
956-
spawnSync('git', ['-C', dir, 'init', '-q'], { encoding: 'utf8' });
962+
// #16644: `init` and `add -A` are the two commands the measured incident ran. With an
963+
// inherited GIT_DIR the `init` writes core.bare into the SHARED .git/config and the
964+
// `add -A` stages the real tree as deleted, both silently.
965+
spawnSync('git', ['-C', dir, 'init', '-q'], { encoding: 'utf8', env: gitFreeEnv() });
957966
for (const [rel, body] of Object.entries(files)) {
958967
mkdirSync(join(dir, dirname(rel)), { recursive: true });
959968
writeFileSync(join(dir, rel), body);
960969
}
961-
spawnSync('git', ['-C', dir, 'add', '-A'], { encoding: 'utf8' });
970+
spawnSync('git', ['-C', dir, 'add', '-A'], { encoding: 'utf8', env: gitFreeEnv() });
962971
return dir;
963972
}
964973

@@ -1402,7 +1411,7 @@ function selfTest() {
14021411
// a fixture whose index also lost the path would make every case below pass
14031412
// by testing nothing.
14041413
rmSync(join(partialRepo, 'scripts/absent.sh'));
1405-
const stillIndexed = spawnSync('git', ['-C', partialRepo, 'ls-files', '--', ...WALK_ROOTS], { encoding: 'utf8' });
1414+
const stillIndexed = spawnSync('git', ['-C', partialRepo, 'ls-files', '--', ...WALK_ROOTS], { encoding: 'utf8', env: gitFreeEnv() });
14061415
t(
14071416
'the fixture really is INDEX-vs-DISK: the index still lists the removed path',
14081417
stillIndexed.stdout.includes('scripts/absent.sh'),

‎scripts/check-changeset-no-major.mjs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ import { tmpdir } from 'node:os';
383383
import { dirname, join, resolve } from 'node:path';
384384
import { fileURLToPath } from 'node:url';
385385

386+
import { gitFreeEnv } from './git-env.mjs';
386387
import { isEntrypoint } from './invoked-as.mjs';
387388
// #16055, the level axis below. Both are IMPORTED rather than restated: the
388389
// clause-② declaration has exactly one legal spelling and exactly one label
@@ -469,6 +470,13 @@ const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md')
469470
function git(args, cwd, { quiet = false } = {}) {
470471
return execFileSync('git', args, {
471472
cwd,
473+
// #16644: `cwd` is the only thing that may name the repository here, and the
474+
// self-test hands it mkdtemp fixtures. GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE
475+
// outrank `cwd`, so an inherited one redirects `init`, `add -A` and `commit`
476+
// onto the real checkout. ⭐ This helper is also the one that runs `fetch` in the
477+
// #4690 leg -- its remote there is another LOCAL mkdtemp repository passed by
478+
// path, so no transport configuration is in play and the strip is safe.
479+
env: gitFreeEnv(),
472480
encoding: 'utf8',
473481
maxBuffer: 64 * 1024 * 1024,
474482
// `execFileSync` inherits the child's stderr by default. That is right for

‎scripts/check-empty-changeset.mjs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ import { tmpdir } from 'node:os';
231231
import { dirname, join, resolve } from 'node:path';
232232
import { fileURLToPath } from 'node:url';
233233

234+
import { gitFreeEnv } from './git-env.mjs';
234235
import { isEntrypoint } from './invoked-as.mjs';
235236

236237
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -294,8 +295,13 @@ const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md')
294295

295296
// ── git helpers ──────────────────────────────────────────────────────────────
296297

298+
// #16644: `cwd` is the only thing that may name the repository, and the self-test
299+
// hands this helper mkdtemp fixtures -- `init`, `add -A`, `commit`, `fetch` from a
300+
// sibling temp repo. An inherited GIT_DIR outranks `cwd` and redirects all of them
301+
// onto the real checkout. The `fetch` legs name their remote by local PATH, so the
302+
// strip costs them no transport configuration.
297303
function git(args, cwd) {
298-
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
304+
return execFileSync('git', args, { cwd, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
299305
}
300306

301307
/** File contents at a rev, or `null` when the path does not exist there. */

‎scripts/check-engine-split-ratio.mjs‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { tmpdir } from 'node:os';
7373
import { dirname, join, resolve } from 'node:path';
7474
import { fileURLToPath } from 'node:url';
7575

76+
import { gitFreeEnv, withoutGitEnv } from './git-env.mjs';
7677
import { historyHorizon } from './pm/git-history.mjs';
7778
import { isEntrypoint } from './invoked-as.mjs';
7879

@@ -166,14 +167,22 @@ function main(argv) {
166167
return EXIT_CANNOT_COMPUTE;
167168
}
168169

170+
// LOCAL-ONLY (#16644). Every `git` below is `log` / `rev-list` against the checkout
171+
// `--cwd` names -- the self-test drives this same entry point with a mkdtemp fixture as
172+
// `--cwd`, so an inherited GIT_DIR would have it measure the real repository and report
173+
// a ratio about the wrong tree. No child here reaches a remote.
169174
const git = (...args) =>
170-
execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
175+
execFileSync('git', args, { cwd: repoRoot, env: gitFreeEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
171176

172177
// ── the horizon comes FIRST: nothing below may run on a window this
173178
// checkout cannot see all of.
174179
const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
175180
const sinceIso = new Date(sinceMs).toISOString();
176-
const horizon = historyHorizon({ cwd: repoRoot, ref: 'HEAD', sinceMs });
181+
// `withoutGitEnv`, not `gitFreeEnv`: the git child is spawned one frame down inside
182+
// `scripts/pm/git-history.mjs`, which passes no environment of its own, so the only way
183+
// to reach it is to detach the PROCESS for the call (#16644, the second half of the
184+
// rule in scripts/git-env.mjs). Restored in a `finally` by that helper.
185+
const horizon = withoutGitEnv(() => historyHorizon({ cwd: repoRoot, ref: 'HEAD', sinceMs }));
177186
if (!horizon.covered) {
178187
console.error(renderRefusal({ horizon, days, sinceIso }));
179188
return EXIT_CANNOT_COMPUTE;
@@ -298,14 +307,22 @@ function selfTest() {
298307
// ── real repos: the defect, then both legs of the guard ───────────────────
299308
battery('real repos: the defect, then both legs of the guard');
300309
const root = mkdtempSync(join(tmpdir(), 'engine-split-selftest-'));
301-
const g = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
310+
// LOCAL-ONLY (#16644): `init`, `config`, `add`, `log`, and two `clone`s whose source is
311+
// a `file://` URL under this battery's own mkdtemp root -- a local object transfer that
312+
// needs none of the GIT_CONFIG_* / GIT_SSL_* transport configuration this container
313+
// carries, so the blanket strip costs them nothing and keeps `init` off the shared repo.
314+
const g = (args, cwd) => execFileSync('git', args, { cwd, env: gitFreeEnv(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
302315
const self = fileURLToPath(import.meta.url);
303316
// spawnSync, not execFileSync: this script writes to stderr on SUCCESS too
304317
// (the zero-scan warning), and execFileSync surfaces stderr only when it
305318
// throws -- a self-test that reads stderr only on failure is half blind.
306319
const runAllowFail = (args, cwd) => {
307320
const r = spawnSync(process.execPath, [self, ...args, '--cwd', cwd], {
308321
cwd,
322+
// #16644: the child re-enters this file against a FIXTURE. Its own helpers strip,
323+
// but the strip is applied here too so nothing the child spawns -- including the
324+
// shared `historyHorizon` -- can be redirected by a variable this process inherited.
325+
env: gitFreeEnv(),
309326
encoding: 'utf8',
310327
stdio: ['ignore', 'pipe', 'pipe'],
311328
});
@@ -330,7 +347,11 @@ function selfTest() {
330347
execFileSync('git', ['commit', '--quiet', '-m', `c${i}`], {
331348
cwd: up,
332349
encoding: 'utf8',
333-
env: { ...process.env, GIT_AUTHOR_DATE: d, GIT_COMMITTER_DATE: d },
350+
// ⭐ NOT a blanket replace: the two date variables are set DELIBERATELY and are
351+
// what dates the fixture, so they are re-applied ON TOP of the strip. Spreading
352+
// `process.env` here instead would carry an inherited GIT_DIR straight into a
353+
// `commit` -- the one command in this battery that writes refs (#16644).
354+
env: { ...gitFreeEnv(), GIT_AUTHOR_DATE: d, GIT_COMMITTER_DATE: d },
334355
});
335356
}
336357
// `--days` is relative to now, so re-date the whole fixture to end today.

0 commit comments

Comments
 (0)