A test that plants a git hook without chmod passes on Windows and fails on Linux CI
Git runs a hook only when the file has the executable bit. Git for Windows does not check that bit, so it runs a hook that starts with #!/bin/sh as soon as the file exists, and a test fixture that just writes the hook passes locally. On Linux the same hook is skipped and the commit goes through. Call chmod 0o755 on every hook a test or script writes, and set the bit in the index for hook scripts you commit from Windows.
Git spustí hook jen se spustitelným bitem. Git for Windows ten bit nekontroluje, takže spustí hook začínající #!/bin/sh, jakmile soubor existuje – test, který hook jen zapíše, lokálně projde. Na Linuxu se tentýž hook přeskočí a commit projde. Každý hook, který test nebo skript zapisuje, označ chmod 0o755; u hooků, které commituješ z Windows, nastav bit v indexu.
Symptom
A test suite for a publish script checks that a failing pre-commit hook stops the publish. It writes .git/hooks/pre-commit into a scratch repository, runs the publish and expects exit code 1 and nothing pushed (simplified):
fs.writeFileSync(path.join(repo, '.git/hooks/pre-commit'), '#!/bin/sh\necho hook rejects\nexit 1\n');
const r = await publish(repo);
// expected: r.code === 1, "git commit failed" in the output, the remote unchanged
On Windows the test passes. In CI on ubuntu-24.04 it failed in every run until the fix: the commit succeeds, the publish exits 0 and the remote moves (messages translated):
FAIL hook: exit 1 (expected 1, got 0)
FAIL hook: message
FAIL hook: nothing pushed (expected "3b1cde7…", got "3fe887c…")
The code under test is fine. On Linux the scratch repository simply has no hook that git will run.
Cause
- The Git documentation for hooks says it plainly: hooks that don't have the executable bit set are ignored.
fs.writeFileSyncdoes not set that bit, so on Linux git skips the hook. At most it prints a hint to stderr (controlled byadvice.ignoredHook). Nothing of the kind reached our CI log: the test kept the publish script's output to itself. - Git for Windows does not check the executable bit. Its
access()dropsX_OK, so a hook counts as present as soon as the file exists. Git for Windows 2.54 ran the same#!/bin/shhook without anychmod, so the local run blocked the commit as the test expected. - The same trap applies to hook scripts you commit. On Windows,
git initandgit cloneprobe the file system and setcore.fileMode=false, and then a new file is added with mode100644even when it starts with a shebang. A Linux clone then gets a hook git ignores.
Fix
Set the bit right after writing the hook:
const hook = path.join(repo, '.git', 'hooks', 'pre-commit');
fs.writeFileSync(hook, '#!/bin/sh\necho hook rejects\nexit 1\n');
fs.chmodSync(hook, 0o755); // Linux/macOS: required, or git skips the hook. Windows: no effect
- On Windows, Node's
chmodcan only change the write permission, so0o755changes nothing on a freshly written file. The same line works on both systems. - Keep the shebang. Without it, Git for Windows 2.54 did not skip the hook. It failed with
cannot spawn .git/hooks/pre-commit, with or withoutchmod, and the commit failed for the wrong reason. - For hook scripts in the repository, for example a folder used through
core.hooksPath:git add --chmod=+x <file>(orgit update-index --chmod=+x <file>for an already tracked file), then check thatgit ls-files -s <file>shows100755.
Notes
- Other files that need the executable bit on Linux, such as shell scripts started directly, may behave the same way; only the hook was measured here. A green run on Windows does not show that such a test passes on a Linux runner.
- The fixture was the bug, not the code: after adding
chmod, the same test passed on the Linux runner with no other change.
References
- Git documentation: githooks — "Hooks that don't have the executable bit set are ignored."
- Git documentation: git-config —
advice.ignoredHook: shown when a hook is ignored because it is not set as executable.core.fileMode:git initandgit cloneprobe the file system and set it as necessary. - Git documentation: git-add —
--chmod=(+|-)xoverrides the executable bit of the added files in the index. - Git documentation: git-update-index —
--chmod=(+|-)xsets the execute permissions on the updated files. - Git for Windows source:
compat/mingw.c—mingw_access()dropsX_OK("X_OK is not supported by the MSVCRT version");hook.clooks for a hook withaccess(path, X_OK). - Node.js documentation: File system — File modes caveat: on Windows only the write permission can be changed.