Skip to content
Back to skills

Hermetic Git Tests

ASecurity

Keep tests and tools that shell out to git from touching the enclosing repository when run from a git hook. A pre-push hook exports GIT_DIR, so a child `git init` or `git config` inside a test rewrites the real repo (core.bare=true breaks every worktree). Covers the git-env.mjs helper, a meta-test that fails the suite on raw git calls, and a pre-push guard. Use when writing or reviewing tests that create temp git repos, when a repo suddenly reports "must be run in a work tree", or when adding...

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 4, 2026
ai-agentsgoshellnodegit

Security analysis

A100/100

Scanned October 4, 2026

npx -y skills add eric-sabe/engsys --skill hermetic-git-tests --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Hermetic Git Tests?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Hermetic Git Tests
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/eric-sabe-hermetic-git-tests/badge)](https://www.skillsdirectory.com/skills/eric-sabe-hermetic-git-tests)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: hermetic-git-tests
description: Keep tests and tools that shell out to git from touching the enclosing repository when run from a git hook. A pre-push hook exports GIT_DIR, so a child `git init` or `git config` inside a test rewrites the real repo (core.bare=true breaks every worktree). Covers the git-env.mjs helper, a meta-test that fails the suite on raw git calls, and a pre-push guard. Use when writing or reviewing tests that create temp git repos, when a repo suddenly reports "must be run in a work tree", or when adding a pre-push gate.
---

# Hermetic git in tests and tools

## The failure mode

A test builds a throwaway repo by shelling out to git:

```js
execFileSync('git', ['init', '-q'], { cwd: tmp });                 // no `env`
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmp });
```

With no `env`, the child inherits the parent's environment. Run by hand, that is harmless: nothing
exports `GIT_DIR`. But `git push` runs the **pre-push hook** with `GIT_DIR` (and `GIT_WORK_TREE`,
`GIT_INDEX_FILE`, ...) pointing at the pushing checkout, and everything the hook starts (a precheck
script, the test runner, every test) inherits them. Git then honors `GIT_DIR` over the `cwd`, so
`git init` and `git config` act on the **real repository**.

Linked worktrees share the main repo's config, so one push corrupts every checkout on the machine:

- `core.bare = true`: every git command fails with `fatal: this operation must be run in a work tree`.
- an injected `[user]` identity: later commits from any worktree are silently mis-authored.

It reproduces only under the hook, so a plain local test run never shows it. Read-only git calls are
affected too (they read the wrong tree), just less destructively.

## The fix: `core/lib/git-env.mjs`

Zero dependencies, ESM, Node >= 20.

```js
import { hermeticGit, scrubbedGitEnv } from './git-env.mjs';

// Test setup: every git call goes through the helper; identity is per invocation, never `git config`.
hermeticGit(tmp, ['init', '-q'], { isolateConfig: true });
hermeticGit(tmp, ['-c', 'user.name=Test', '-c', 'user.email=t@example.invalid', '-c', 'commit.gpgsign=false',
                  'commit', '-q', '--allow-empty', '-m', 'seed'], { isolateConfig: true });

// Production code that spawns git and can run under a hook: scrub the env yourself.
execFileSync('git', ['log', '--oneline'], { cwd: dir, env: scrubbedGitEnv() });
```

- `scrubbedGitEnv(base = process.env, { isolateConfig })` returns a copy with `GIT_DIR`, `GIT_WORK_TREE`,
  `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_COMMON_DIR` and
  `GIT_PREFIX` removed. `isolateConfig` also sets `GIT_CONFIG_NOSYSTEM=1` and `GIT_CONFIG_GLOBAL=/dev/null`
  and drops `GIT_CONFIG`, so a throwaway repo neither depends on nor leaks into the machine's config.
- `hermeticGit(cwd, args, opts)` is `execFileSync('git', ...)` with that env; it returns stdout.
- **Never set identity with `git config`** in a test. It writes a config file, which is the exact damage.
  Use `-c user.name=... -c user.email=...` per invocation, or `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env.
- Shell tests: `env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE git ...`, or `unset` them at the top of
  the test script (and `export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null`).

## Prove it: a regression test that simulates the hook

Export `GIT_DIR` at a throwaway "real" repo, run the throwaway git operations through the helper, and
assert the real repo's `.git/config` is **byte-identical**. Include a control (an un-scrubbed
`git config` really does change it) so the test can't pass vacuously. `core/lib/git-env.test.mjs` is
the reference.

## Meta-test: fail the suite when a test spawns raw git

A helper only works if it is used. This test scans the test sources and fails on `git init` /
`git config` spawned directly. Save as `test/no-raw-git.test.mjs` and adjust `ROOTS`:

```js
// Meta-test: no test may spawn raw `git init` / `git config`. Every such call must go through
// the hermetic helper (git-env.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = fileURLToPath(new URL('.', import.meta.url));
const ROOTS = [HERE];                       // add other test directories here
const EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts']);
const VERBS = '(?:init|config)';            // add 'clone', 'remote', 'worktree' if you like
const SPAWN = '(?:execFileSync|spawnSync|execSync|execFile|exec|spawn)\\(';

// Array form: execFileSync("git", [... "init" | "config" ...]). The 200-char window also
// spans global options such as ["-C", dir, "config", ...].
const RAW_GIT_ARRAY = new RegExp(`${SPAWN}\\s*["']git["'][\\s\\S]{0,200}?["']${VERBS}["']`);
// Shell form: execSync("git init -q"), exec(`git -C /repo config user.name x`).
const RAW_GIT_SHELL = new RegExp(`${SPAWN}\\s*[\`"'][^\`"')]*?\\bgit\\b[^\`"')]*?\\b${VERBS}\\b`);
// Escape hatch for a deliberate raw call (e.g. a control that proves the hazard): put this marker
// on the same line or the line above. Keep such uses rare and reviewed.
const ALLOW = /raw-git-ok/;

function walk(dir) {
  return readdirSync(dir).flatMap((name) => {
    if (name === 'node_modules' || name.startsWith('.')) return [];
    const p = join(dir, name);
    return statSync(p).isDirectory() ? walk(p) : [p];
  });
}

test('the detectors fire on what they must catch and stay quiet on the sanctioned forms', () => {
  // Controls: without these the "no offenders" test below could pass vacuously.
  assert.ok(RAW_GIT_ARRAY.test('execFileSync("git", ["init", "-q"])'));
  assert.ok(RAW_GIT_ARRAY.test('spawnSync("git", ["-C", dir, "config", "u.n", "x"])'));
  assert.ok(RAW_GIT_SHELL.test('execSync("git init -q")'));
  assert.ok(RAW_GIT_SHELL.test('execSync(`git -C /repo config user.name x`)'));
  assert.ok(!RAW_GIT_ARRAY.test('hermeticGit(repo, ["init", "-q"])'));
  assert.ok(!RAW_GIT_SHELL.test('execFileSync("git", ["rev-parse", "--show-toplevel"])'));
});

test('tests reach git init/config only through the hermetic helper', () => {
  const offenders = [];
  for (const root of ROOTS) {
    for (const file of walk(root)) {
      if (!EXTS.has(extname(file)) || file === fileURLToPath(import.meta.url)) continue;
      const src = readFileSync(file, 'utf8');
      const lines = src.split('\n');
      const allowed = (idx) => ALLOW.test(lines[idx] ?? '') || ALLOW.test(lines[idx - 1] ?? '');
      for (const re of [RAW_GIT_ARRAY, RAW_GIT_SHELL]) {
        const g = new RegExp(re.source, 'g');
        for (let m; (m = g.exec(src)); ) {
          const line = src.slice(0, m.index).split('\n').length - 1;
          if (!allowed(line)) offenders.push(`${relative(root, file)}:${line + 1}`);
        }
      }
    }
  }
  assert.deepEqual([...new Set(offenders)], [], 'raw git init/config in tests; use hermeticGit (see git-env.mjs)');
});
```

Notes: it is a regex lint, not a parser. It catches the direct forms, not a git call hidden behind your own
wrapper (so keep wrappers in one helper file that scrubs the env and is itself excluded or reviewed).
Under vitest/jest swap the `node:test` import for the runner's `describe/it/expect`. Shell test
files (`*.sh`) need their own grep: fail on `git (init|config)` lines not preceded by `env -u GIT_DIR`.

## Pre-push guard: catch the corruption immediately

Run this after the test step of the pre-push gate (or in the hook itself). It is read-only and
cheap, so run it on every push. A corrupting run is then caught right there, not hours later when
another worktree fails.

```sh
#!/bin/sh
# git-config hermeticity guard: run after the test step of the pre-push gate.
# GUARD_EMAIL_GLOBS: space-separated case-globs for identities a test would inject,
# e.g. "*@example.invalid *@test.local". Empty disables the identity check.
GUARD_EMAIL_GLOBS="${GUARD_EMAIL_GLOBS:-*@example.invalid *@test.local}"

bare=$(git config --local --get core.bare 2>/dev/null || true)
email=$(git config --local --get user.email 2>/dev/null || true)

if [ "$bare" = "true" ]; then
  echo "guard: core.bare=true. A non-hermetic git test rewrote the shared repo config." >&2
  echo "guard: repair: cfg=\"\$(git rev-parse --git-common-dir)/config\"; cp \"\$cfg\" \"\$cfg.bak\"; git config --file \"\$cfg\" core.bare false" >&2
  exit 1
fi
for glob in $GUARD_EMAIL_GLOBS; do
  # shellcheck disable=SC2254  # the glob is meant to be expanded as a case pattern
  case "$email" in
    $glob)
      echo "guard: repo user.email is '$email', an identity a test injected into the shared config." >&2
      echo "guard: repair: git config --local --unset-all user.email; git config --local --unset-all user.name" >&2
      exit 1 ;;
  esac
done
exit 0
```

Set `GUARD_EMAIL_GLOBS` to the address domain your own tests use for throwaway identities (use a
reserved domain such as `example.invalid`, which no real committer has). The guard only reads the
repo-local config, so a legitimate identity in your global config never trips it.

## Remediation if it already happened

```sh
# Resolve the SHARED git dir first: worktrees keep no config at ./.git/config, and once
# core.bare=true, `git -C <worktree>` may refuse. Fall back to the main checkout's path.
CFG="$(git rev-parse --git-common-dir 2>/dev/null)/config"
cp "$CFG" "$CFG.bak.$(date +%s)"                 # back up first
git config --file "$CFG" core.bare false
git config --file "$CFG" --remove-section user    # if injected
git status                                         # confirm git works again
```

Then check `git log --all --author=<injected name>` for mis-authored commits. Repair is temporary
until the hermetic fix is on the branch you push from: the next push that runs the tests corrupts it
again. See the lesson `lessons-library/git-core-bare-flip-breaks-all-worktrees.md`.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.