diff --git a/.changeset/devkit-scrub-git-hook-env.md b/.changeset/devkit-scrub-git-hook-env.md new file mode 100644 index 000000000..ee7874550 --- /dev/null +++ b/.changeset/devkit-scrub-git-hook-env.md @@ -0,0 +1,13 @@ +--- +'@xnetjs/devkit': patch +--- + +`NodeCommandRunner` now scrubs git repo-location env vars (`GIT_DIR`, +`GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, +`GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_COMMON_DIR`, `GIT_PREFIX`) from the +inherited environment before spawning subprocesses. Previously, running devkit +inside a git hook (where `git commit` exports `GIT_INDEX_FILE`) silently +redirected every spawned `git` at the hook's repository instead of the caller's +`cwd`, breaking temp-repo workflows and worktree creation with errors like +".git/index: index file open failed: Not a directory". An explicit value passed +via `options.env` still wins. diff --git a/.husky/pre-commit b/.husky/pre-commit index 64fc3e1ac..50c7e7551 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -119,4 +119,7 @@ fi pnpm lint-staged pnpm turbo run typecheck --affected -pnpm vitest run --changed HEAD --passWithNoTests +# Scrub the repo-location env vars `git commit` exports into hooks: tests that +# create their own temp repos (e.g. packages/devkit) would otherwise have their +# spawned git processes redirected at THIS repo's index and fail. +env -u GIT_INDEX_FILE -u GIT_DIR -u GIT_WORK_TREE pnpm vitest run --changed HEAD --passWithNoTests diff --git a/packages/devkit/src/command-runner.test.ts b/packages/devkit/src/command-runner.test.ts index 482b0d309..b95e62a4d 100644 --- a/packages/devkit/src/command-runner.test.ts +++ b/packages/devkit/src/command-runner.test.ts @@ -58,4 +58,25 @@ describe('NodeCommandRunner (real subprocess)', () => { expect(r.ok).toBe(false) expect(r.code).toBe(-1) }) + + it('scrubs inherited git repo-location vars but honours explicit env overrides', async () => { + // Simulate running inside a git hook (`git commit` exports GIT_INDEX_FILE). + process.env.GIT_INDEX_FILE = '/parent/repo/.git/index' + process.env.GIT_DIR = '/parent/repo/.git' + try { + const print = + "process.stdout.write(String(process.env.GIT_INDEX_FILE ?? '') + '|' + String(process.env.GIT_DIR ?? ''))" + const scrubbed = await runner.run('node', ['-e', print], { cwd: process.cwd() }) + expect(scrubbed.stdout).toBe('|') + + const overridden = await runner.run('node', ['-e', print], { + cwd: process.cwd(), + env: { GIT_INDEX_FILE: '/explicit/index' } + }) + expect(overridden.stdout).toBe('/explicit/index|') + } finally { + delete process.env.GIT_INDEX_FILE + delete process.env.GIT_DIR + } + }) }) diff --git a/packages/devkit/src/command-runner.ts b/packages/devkit/src/command-runner.ts index 6064552d8..45a7fed7f 100644 --- a/packages/devkit/src/command-runner.ts +++ b/packages/devkit/src/command-runner.ts @@ -10,6 +10,32 @@ import { spawn } from 'node:child_process' +/** + * Repo-location env vars that `git` exports into hook subprocesses (e.g. + * `git commit` sets GIT_INDEX_FILE for pre-commit hooks). If a devkit process + * runs inside a hook and inherits these, every spawned `git` is silently + * redirected at the hook's repo instead of the `cwd` the caller asked for — + * temp-repo tests and agent worktrees break with errors like + * ".git/index: index file open failed: Not a directory". The runner drops them + * from the inherited env; a caller that really wants one can still set it + * explicitly via `options.env`. + */ +const GIT_REPO_LOCATION_VARS = [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_COMMON_DIR', + 'GIT_PREFIX' +] as const + +function sanitizedEnv(): NodeJS.ProcessEnv { + const env = { ...process.env } + for (const key of GIT_REPO_LOCATION_VARS) delete env[key] + return env +} + export interface CommandResult { /** `true` when the process exited 0. */ ok: boolean @@ -40,7 +66,7 @@ export class NodeCommandRunner implements CommandRunner { return new Promise((resolve) => { const child = spawn(command, args, { cwd: options.cwd, - env: { ...process.env, ...options.env }, + env: { ...sanitizedEnv(), ...options.env }, shell: false // never interpret the command through a shell }) let stdout = ''