diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6f0d10e3f..f5942e682 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,42 +1,47 @@ #!/usr/bin/env bash set -euo pipefail -if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then - npm run check:alias-drift +# GIT_OVERRIDE / NPM_OVERRIDE: optional test-injection seams (default to bare commands). +# Hooks remain production-equivalent when these are unset. +GIT_CMD="${GIT_OVERRIDE:-git}" +NPM_CMD="${NPM_OVERRIDE:-npm}" + +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then + "$NPM_CMD" run check:alias-drift fi -if git diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^get-shit-done/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then - npm run check:state-document-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^get-shit-done/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then + "$NPM_CMD" run check:state-document-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^get-shit-done/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then - npm run check:configuration-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^get-shit-done/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then + "$NPM_CMD" run check:configuration-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^get-shit-done/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then - npm run check:workstream-inventory-builder-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^get-shit-done/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then + "$NPM_CMD" run check:workstream-inventory-builder-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then - npm run check:project-root-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then + "$NPM_CMD" run check:project-root-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then - npm run check:plan-scan-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then + "$NPM_CMD" run check:plan-scan-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then - npm run check:secrets-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then + "$NPM_CMD" run check:secrets-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then - npm run check:schema-detect-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then + "$NPM_CMD" run check:schema-detect-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then - npm run check:decisions-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then + "$NPM_CMD" run check:decisions-fresh fi -if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then - npm run check:workstream-name-policy-fresh +if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then + "$NPM_CMD" run check:workstream-name-policy-fresh fi diff --git a/.githooks/pre-push b/.githooks/pre-push index 2d9862c60..c53e867e0 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +# GIT_OVERRIDE / NPM_OVERRIDE: optional test-injection seams (default to bare commands). +# Hooks remain production-equivalent when these are unset. +GIT_CMD="${GIT_OVERRIDE:-git}" + zero_sha='0000000000000000000000000000000000000000' blocked_regex="${GSD_BLOCKED_AUTHOR_REGEX:-}" @@ -20,14 +24,14 @@ while read -r local_ref local_sha remote_ref remote_sha; do if [[ "$remote_sha" == "$zero_sha" ]]; then # New remote ref: inspect commits not already on any remote - commit_list=$(git rev-list "$local_sha" --not --remotes) + commit_list=$("$GIT_CMD" rev-list "$local_sha" --not --remotes) else - commit_list=$(git rev-list "$remote_sha..$local_sha") + commit_list=$("$GIT_CMD" rev-list "$remote_sha..$local_sha") fi while read -r commit; do [[ -z "$commit" ]] && continue - author_email=$(git show -s --format='%ae' "$commit") + author_email=$("$GIT_CMD" show -s --format='%ae' "$commit") lower_email=$(printf '%s' "$author_email" | tr '[:upper:]' '[:lower:]') if printf '%s' "$lower_email" | grep -Eq "$blocked_regex"; then violations+=("$commit <$author_email>") diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 975e02ed4..bab73581b 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -53,6 +53,9 @@ jobs: # --------------------------------------------------------------------------- smoke: runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.shell }} timeout-minutes: 12 strategy: @@ -72,7 +75,7 @@ jobs: - os: macos-latest node-version: 24 full_only: true - shell: zsh + shell: 'zsh {0}' steps: - name: Skip full-only matrix entry on PR @@ -80,8 +83,12 @@ jobs: env: EVENT: ${{ github.event_name }} FULL_ONLY: ${{ matrix.full_only }} - shell: ${{ matrix.shell }} - run: node scripts/ci-smoke-skip.cjs + run: | + if [ "$EVENT" = "pull_request" ] && [ "$FULL_ONLY" = "true" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: steps.skip.outputs.skip != 'true' @@ -100,7 +107,6 @@ jobs: # instead of a downstream build error that looks unrelated. - name: Rebase check — merge PR base branch into PR head if: steps.skip.outputs.skip != 'true' && github.event_name == 'pull_request' - shell: ${{ matrix.shell }} run: node scripts/ci-rebase-check.cjs - name: Set up Node.js ${{ matrix.node-version }} @@ -112,13 +118,11 @@ jobs: - name: Install root deps if: steps.skip.outputs.skip != 'true' - shell: ${{ matrix.shell }} run: npm ci - name: Pack root tarball if: steps.skip.outputs.skip != 'true' id: pack - shell: ${{ matrix.shell }} run: | set -euo pipefail TARBALL=$(npm pack --silent) @@ -128,7 +132,6 @@ jobs: - name: Ensure npm global bin is on PATH (CI runner default may differ) if: steps.skip.outputs.skip != 'true' - shell: ${{ matrix.shell }} run: | NPM_BIN="$(npm config get prefix)/bin" echo "$NPM_BIN" >> "$GITHUB_PATH" @@ -139,7 +142,6 @@ jobs: env: TARBALL: ${{ steps.pack.outputs.tarball }} WORKSPACE: ${{ github.workspace }} - shell: ${{ matrix.shell }} run: | set -euo pipefail TMPDIR_ROOT=$(mktemp -d) @@ -157,7 +159,6 @@ jobs: - name: Assert gsd-tools resolves on PATH if: steps.skip.outputs.skip != 'true' - shell: ${{ matrix.shell }} run: | set -euo pipefail if ! command -v gsd-tools >/dev/null 2>&1; then @@ -171,7 +172,6 @@ jobs: - name: Assert gsd-tools is executable if: steps.skip.outputs.skip != 'true' - shell: ${{ matrix.shell }} run: | set -euo pipefail gsd-tools --help @@ -180,7 +180,6 @@ jobs: - name: Lifecycle smoke if: steps.skip.outputs.skip != 'true' id: lifecycle-smoke - shell: ${{ matrix.shell }} run: | set -euo pipefail node scripts/release-tarball-smoke.cjs --json | tee /tmp/release-smoke.json @@ -245,7 +244,7 @@ jobs: if ! command -v gsd-tools >/dev/null 2>&1; then echo "::error::gsd-tools is not on PATH after unpacked install" NPM_BIN="$(npm config get prefix)/bin" - ls -la "$NPM_BIN" | grep -i gsd || true + for f in "$NPM_BIN"/*gsd* "$NPM_BIN"/*GSD*; do [ -e "$f" ] && ls -la "$f"; done || true exit 1 fi echo "✓ gsd-tools resolves at: $(command -v gsd-tools)" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 898bace69..c559e5ff8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,10 +47,12 @@ jobs: set -euo pipefail if [ "$EVENT_NAME" != "pull_request" ]; then - echo "code_changed=true" >> "$GITHUB_OUTPUT" - echo "full_matrix=true" >> "$GITHUB_OUTPUT" - echo "targeted_tests=" >> "$GITHUB_OUTPUT" - echo "windows_tests=" >> "$GITHUB_OUTPUT" + { + echo "code_changed=true" + echo "full_matrix=true" + echo "targeted_tests=" + echo "windows_tests=" + } >> "$GITHUB_OUTPUT" { echo "## Test scope" echo "" @@ -208,6 +210,9 @@ jobs: needs: changes if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.full_matrix == 'true' runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.shell }} timeout-minutes: 15 env: GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled @@ -220,10 +225,10 @@ jobs: shell: pwsh - os: macos-latest node-version: 22 - shell: zsh + shell: 'zsh {0}' - os: macos-latest node-version: 24 - shell: zsh + shell: 'zsh {0}' steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Windows) @@ -241,14 +246,12 @@ jobs: token: ${{ github.token }} - name: Guard — require GitHub-hosted runner - shell: ${{ matrix.shell }} run: node scripts/ci-guard-runner.cjs - name: Rebase check — merge PR base branch into PR head if: github.event_name == 'pull_request' env: GITHUB_TOKEN: ${{ github.token }} - shell: ${{ matrix.shell }} run: node scripts/ci-rebase-check.cjs - name: Set up Node.js ${{ matrix.node-version }} @@ -258,27 +261,21 @@ jobs: cache: 'npm' - name: Environment check - shell: ${{ matrix.shell }} run: npm run check:env - name: Install dependencies - shell: ${{ matrix.shell }} run: npm ci - name: Dependency integrity gate - shell: ${{ matrix.shell }} run: node scripts/check-npm-integrity.cjs - name: Run unit tests - shell: ${{ matrix.shell }} run: npm run test:unit - name: Run integration tests - shell: ${{ matrix.shell }} run: npm run test:integration - name: Run security tests - shell: ${{ matrix.shell }} run: npm run test:security coverage: diff --git a/scripts/check-env.cjs b/scripts/check-env.cjs index 7f52c4b66..ee20a7aa9 100644 --- a/scripts/check-env.cjs +++ b/scripts/check-env.cjs @@ -29,6 +29,10 @@ const fs = require('fs'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); +// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without +// shell:true requires the exact filename including extension. +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + // --------------------------------------------------------------------------- // Argument parsing // --------------------------------------------------------------------------- @@ -175,7 +179,7 @@ if (!currentNode) { const enginesNpm = pkgField('engines.npm'); let currentNpm = ''; try { - const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 }); + const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' }); if (res.status === 0 && res.stdout) { currentNpm = res.stdout.trim(); } @@ -208,9 +212,10 @@ if (fs.existsSync(LOCKFILE)) { // --------------------------------------------------------------------------- if (fs.existsSync(LOCKFILE)) { try { - const res = spawnSync('npm', ['ci', '--dry-run'], { + const res = spawnSync(npmCmd, ['ci', '--dry-run'], { cwd: PROJECT_ROOT, encoding: 'utf8', + shell: process.platform === 'win32', }); if (res.status === 0) { addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json'); diff --git a/scripts/ci-smoke-skip.cjs b/scripts/ci-smoke-skip.cjs deleted file mode 100644 index db434210d..000000000 --- a/scripts/ci-smoke-skip.cjs +++ /dev/null @@ -1,27 +0,0 @@ -'use strict'; -// ci-smoke-skip.cjs — Set the "skip" output for full-only matrix entries on PR events. -// Replaces the inline bash "Skip full-only matrix entry on PR" step. -// Shell-agnostic: invoked as `node scripts/ci-smoke-skip.cjs` from any shell. -// -// Required environment variables (set by the workflow step's `env:` block): -// EVENT — github.event_name value -// FULL_ONLY — matrix.full_only value ("true" | "false") -// -// Writes to GITHUB_OUTPUT: -// skip=true if EVENT == "pull_request" AND FULL_ONLY == "true" -// skip=false otherwise - -const fs = require('fs'); - -const event = process.env.EVENT || ''; -const fullOnly = process.env.FULL_ONLY || ''; -const output = process.env.GITHUB_OUTPUT || ''; - -const skip = (event === 'pull_request' && fullOnly === 'true') ? 'true' : 'false'; - -if (output) { - fs.appendFileSync(output, `skip=${skip}\n`, 'utf-8'); -} else { - // Fallback for local testing without GITHUB_OUTPUT set. - process.stdout.write(`skip=${skip}\n`); -} diff --git a/scripts/release-tarball-smoke.cjs b/scripts/release-tarball-smoke.cjs index 6f8641bd3..c65257741 100644 --- a/scripts/release-tarball-smoke.cjs +++ b/scripts/release-tarball-smoke.cjs @@ -45,7 +45,16 @@ const { execFileSync, spawnSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const CHILD_TIMEOUT_MS = 120000; +// 120 s proved too tight on Windows GitHub-hosted runners: cold-cache +// `npm install -g` with a 1499-file tarball took ~120 s exactly, causing +// spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' } +// (Node docs: status is null when subprocess terminated due to a signal). +// The INSTALL_FAILED branch checks `status !== 0`, which null satisfies, so the +// test saw empty stdout/stderr and a spurious INSTALL_FAILED. Windows runners +// are slower than Linux/macOS for filesystem-heavy operations ( +// https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories +// ). Raise to 600 s (the same ceiling the before() helper uses for pack+install). +const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000; // --------------------------------------------------------------------------- // Frozen result-code enum @@ -305,6 +314,10 @@ function runSmoke({ ...details, stderr: installResult.stderr, stdout: installResult.stdout, + // Expose signal + error so a timeout (status=null, signal='SIGTERM', + // stdout='', stderr='') is immediately diagnosable in CI logs. + signal: installResult.signal ?? null, + installError: installResult.error ? String(installResult.error) : null, }, }; } diff --git a/scripts/workflow-policy.cjs b/scripts/workflow-policy.cjs index 1ce3ba88b..4567caa38 100644 --- a/scripts/workflow-policy.cjs +++ b/scripts/workflow-policy.cjs @@ -194,7 +194,12 @@ function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShel return VIOLATION.UNKNOWN_RUNNER; } const expected = POLICY[runner]; - if (resolvedShell !== expected) { + // GHA accepts custom shells as a format string containing '{0}' (e.g. 'zsh {0}'). + // Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions + // the shell name before the space is the executable; strip the format suffix before + // comparing against the policy so 'zsh {0}' satisfies the 'zsh' requirement. + const normalizedShell = resolvedShell ? resolvedShell.replace(/\s+\{0\}$/, '') : resolvedShell; + if (normalizedShell !== expected) { // Specific subtype for macOS missing explicit zsh: // fires only when no shell is set at any level (inherited runner default). if (runner.startsWith('macos-') && !rawStepShell && !rawJobDefaultsShell && !rawWorkflowDefaultsShell) { diff --git a/tests/bug-410-install-defaults-test-mode-guard.test.cjs b/tests/bug-410-install-defaults-test-mode-guard.test.cjs index 64a2e5e96..37d6b93ce 100644 --- a/tests/bug-410-install-defaults-test-mode-guard.test.cjs +++ b/tests/bug-410-install-defaults-test-mode-guard.test.cjs @@ -20,8 +20,12 @@ const ROOT = path.join(__dirname, '..'); // Point HOME at a temp dir so the defaults.json write can't reach the real // ~/.gsd/ even if the guard is missing. +// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so +// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME +// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-')); process.env.HOME = FAKE_HOME; +process.env.USERPROFILE = FAKE_HOME; // The path that finishInstall would write to for a non-Claude runtime. const GSD_DIR = path.join(FAKE_HOME, '.gsd'); diff --git a/tests/precommit-alias-drift-hook.test.cjs b/tests/precommit-alias-drift-hook.test.cjs index 472ce9f21..202bd1bff 100644 --- a/tests/precommit-alias-drift-hook.test.cjs +++ b/tests/precommit-alias-drift-hook.test.cjs @@ -10,8 +10,15 @@ const { createTempDir, cleanup } = require('./helpers.cjs'); const ROOT = path.resolve(__dirname, '..'); const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-commit'); -function writeExec(filePath, content) { +/** + * Write a mock bash script to a .sh file in tmpDir and return its absolute path. + * The hook invokes it via GIT_OVERRIDE / NPM_OVERRIDE — bash executes the path + * directly, so no PATH manipulation or NTFS execute-ACL fight is needed. + */ +function writeMock(tmpDir, name, content) { + const filePath = path.join(tmpDir, `${name}.sh`); fs.writeFileSync(filePath, content, { mode: 0o755 }); + return filePath; } describe('.githooks/pre-commit alias drift guard', () => { @@ -19,11 +26,8 @@ describe('.githooks/pre-commit alias drift guard', () => { const tmpDir = createTempDir('gsd-precommit-hook-'); t.after(() => cleanup(tmpDir)); - const binDir = path.join(tmpDir, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); - - writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash\nprintf "%s\\n" "${'sdk/src/query/command-manifest.phase.ts'}"\n`); - writeExec(path.join(binDir, 'npm'), `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`); + const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash\nprintf "%s\\n" "${'sdk/src/query/command-manifest.phase.ts'}"\n`); + const mockNpm = writeMock(tmpDir, 'npm', `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`); const marker = path.join(tmpDir, 'npm-called.txt'); @@ -31,7 +35,8 @@ describe('.githooks/pre-commit alias drift guard', () => { cwd: ROOT, env: { ...process.env, - PATH: `${binDir}:${process.env.PATH}`, + GIT_OVERRIDE: mockGit, + NPM_OVERRIDE: mockNpm, GSD_TEST_NPM_MARKER: marker, }, stdio: 'pipe', @@ -44,11 +49,8 @@ describe('.githooks/pre-commit alias drift guard', () => { const tmpDir = createTempDir('gsd-precommit-hook-'); t.after(() => cleanup(tmpDir)); - const binDir = path.join(tmpDir, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); - - writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash\nprintf "%s\\n" "README.md"\n`); - writeExec(path.join(binDir, 'npm'), `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`); + const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash\nprintf "%s\\n" "README.md"\n`); + const mockNpm = writeMock(tmpDir, 'npm', `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`); const marker = path.join(tmpDir, 'npm-called.txt'); @@ -56,7 +58,8 @@ describe('.githooks/pre-commit alias drift guard', () => { cwd: ROOT, env: { ...process.env, - PATH: `${binDir}:${process.env.PATH}`, + GIT_OVERRIDE: mockGit, + NPM_OVERRIDE: mockNpm, GSD_TEST_NPM_MARKER: marker, }, stdio: 'pipe', diff --git a/tests/prepush-enterprise-email-hook.test.cjs b/tests/prepush-enterprise-email-hook.test.cjs index 81b3893d6..765e9fccb 100644 --- a/tests/prepush-enterprise-email-hook.test.cjs +++ b/tests/prepush-enterprise-email-hook.test.cjs @@ -10,8 +10,16 @@ const { createTempDir, cleanup } = require('./helpers.cjs'); const ROOT = path.resolve(__dirname, '..'); const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-push'); -function writeExec(filePath, content) { +/** + * Write a mock bash script to a .sh file in tmpDir and return its absolute path. + * The hook invokes it via GIT_OVERRIDE — bash executes the path directly via the + * env-var seam, bypassing PATH entirely. No NTFS execute-ACL fight, no MSYS2 + * PATH-type inheritance dance needed. + */ +function writeMock(tmpDir, name, content) { + const filePath = path.join(tmpDir, `${name}.sh`); fs.writeFileSync(filePath, content, { mode: 0o755 }); + return filePath; } describe('.githooks/pre-push enterprise email guard', () => { @@ -19,10 +27,7 @@ describe('.githooks/pre-push enterprise email guard', () => { const tmpDir = createTempDir('gsd-prepush-hook-'); t.after(() => cleanup(tmpDir)); - const binDir = path.join(tmpDir, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); - - writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash + const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash set -euo pipefail if [[ "$1" == "rev-list" ]]; then echo "c1" @@ -46,7 +51,7 @@ exit 1 cwd: ROOT, env: { ...process.env, - PATH: `${binDir}:${process.env.PATH}`, + GIT_OVERRIDE: mockGit, GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', }, input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', @@ -59,10 +64,7 @@ exit 1 const tmpDir = createTempDir('gsd-prepush-hook-'); t.after(() => cleanup(tmpDir)); - const binDir = path.join(tmpDir, 'bin'); - fs.mkdirSync(binDir, { recursive: true }); - - writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash + const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash set -euo pipefail if [[ "$1" == "rev-list" ]]; then echo "c1" @@ -80,7 +82,7 @@ exit 1 cwd: ROOT, env: { ...process.env, - PATH: `${binDir}:${process.env.PATH}`, + GIT_OVERRIDE: mockGit, GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', }, input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n',