Fixing pre-commit Hooks That Fail Only on Windows
On Windows, git commit fails with Executable 'bash' not found from a local hook, end-of-file-fixer and mixed-line-ending rewrite the same files on every commit in an endless loop, or check-executables-have-shebangs flags scripts that are fine on macOS. The same configuration passes for everyone on macOS and Linux, so Windows developers either disable hooks or commit through WSL. This page fixes each Windows-only failure and rewrites the fragile hooks so they are portable, as part of pre-commit hooks and local quality gates.
The pre-commit framework itself supports Windows well. The failures come from hooks written with POSIX assumptions and from Git's Windows line-ending defaults.
Diagnostic
On the Windows machine, in PowerShell from the repository root:
$ErrorActionPreference = 'Continue'
pre-commit --version
git config --get core.autocrlf
git config --get core.filemode
Get-Command bash, python, python3, py -ErrorAction SilentlyContinue | Format-Table Name, Source
Select-String -Path .pre-commit-config.yaml -Pattern 'language: (system|script)', 'entry: .*\.sh'
pre-commit run --all-files 2>&1 | Select-String 'Failed|not found|Executable' | Select-Object -First 6
Expected bad output:
pre-commit 3.8.0
true
false
python C:\Users\dev\AppData\Local\Microsoft\WindowsApps\python.exe
.pre-commit-config.yaml:31: language: script
.pre-commit-config.yaml:32: entry: scripts/check-env-example.sh
check-env-example........................................................Failed
Executable `bash` not found
mixed line ending........................................................Failed
core.autocrlf=true converts files to CRLF on checkout, a local hook is a Bash script, and python resolves to the Microsoft Store stub rather than a real interpreter.
Root cause
Git for Windows ships with core.autocrlf=true by default, so text files are LF in the repository and CRLF in the working tree. Hooks such as mixed-line-ending --fix=lf and formatters that write LF then change files on disk, Git converts them back to CRLF on the next checkout, and the hook "fixes" them again forever. Local hooks declared with language: script or language: system and a .sh entry need Bash, which native Windows lacks unless Git Bash is on PATH. The python command on a fresh Windows install is an App Execution Alias that opens the Microsoft Store, which breaks pre-commit's own environment creation. And core.filemode=false means Git does not track executable bits on Windows, so hooks that check executables compare against metadata the Windows checkout never set.
Resolution
- Declare line endings in the repository instead of relying on
core.autocrlf:
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
Save as .gitattributes and renormalise once, from any platform:
#!/usr/bin/env bash
set -euo pipefail
git add --renormalize .
git commit -m "chore: normalise line endings via .gitattributes"
With eol=lf, Windows checkouts keep LF, so formatters and line-ending hooks agree with Git and the loop stops. Editors on Windows handle LF files without issue.
Renormalising produces a large commit on repositories that have accumulated CRLF files over time — every affected file shows as fully changed. Make it a dedicated commit with no other changes, merge it quickly to avoid conflicts with open branches, and add its hash to .git-blame-ignore-revs so git blame skips it. Developers with open branches should rebase after it lands; Git applies the new attributes on checkout, and conflicts, if any, are limited to line endings that the renormalised base already fixed.
Existing clones on Windows also need a one-time refresh after the attributes change, because files already on disk keep their old line endings until Git rewrites them. git rm --cached -r . && git reset --hard rewrites the working tree from the index with the new rules; stash or commit local work first, since reset --hard discards uncommitted changes.
- Rewrite shell-script local hooks in a language the framework manages. The Bash check:
#!/usr/bin/env bash
set -euo pipefail
missing=$(grep -rhoE 'process\.env\.[A-Z_]+' src | sed 's/process\.env\.//' | sort -u | comm -23 - <(cut -d= -f1 .env.example | sort -u))
[ -z "$missing" ] || { echo "missing from .env.example: $missing"; exit 1; }
becomes a portable Python hook:
import pathlib
import re
import sys
used = set()
for path in pathlib.Path("src").rglob("*.ts"):
used.update(re.findall(r"process\.env\.([A-Z_][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
declared = {line.split("=", 1)[0].strip() for line in pathlib.Path(".env.example").read_text(encoding="utf-8").splitlines() if "=" in line and not line.startswith("#")}
missing = sorted(used - declared)
if missing:
print("missing from .env.example:", ", ".join(missing))
sys.exit(1)
repos:
- repo: local
hooks:
- id: check-env-example
name: .env.example covers every variable used
language: python
entry: python scripts/check_env_example.py
files: '(^src/.*\.ts$|^\.env\.example$)'
pass_filenames: false
Disable the Store alias and use a real Python: in Settings → Apps → Advanced app settings → App execution aliases, turn off
python.exeandpython3.exe, then install Python throughwinget install Python.Python.3.12or the project's toolchain manager.Skip executable-bit hooks on Windows or set bits explicitly in Git so they are tracked regardless of the filesystem:
#!/usr/bin/env bash
set -euo pipefail
git ls-files '*.sh' | xargs git update-index --chmod=+x
git commit -m "chore: mark shell scripts executable in the index"
Expected output
PS C:\src\shop> pre-commit run --all-files
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
mixed line ending........................................................Passed
check-env-example........................................................Passed
PS C:\src\shop> git commit -m "fix(cart): keep totals const"
[feat/cart 9b7c1e2] fix(cart): keep totals const
1 file changed, 1 insertion(+), 1 deletion(-)
The hooks pass on native Windows, a second run changes nothing, and the commit goes through without --no-verify.
After this change it is worth checking out a fresh clone on the Windows machine and running git status immediately: it should report a clean tree. A clean fresh clone is the proof that .gitattributes and the working tree agree, and that no hook will find work to do on files the developer has not touched.
Prevention
- Run the hooks on a Windows CI runner:
name: hooks-windows
on: [pull_request]
jobs:
pre-commit:
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pre-commit==3.8.0
- run: pre-commit run --all-files --show-diff-on-failure
Reject new
language: systemorscripthooks with.shentries in review, unless the team has decided Windows developers use WSL2 exclusively.Keep
.gitattributesauthoritative. It works regardless of each developer'score.autocrlfand is visible in review, unlike per-machine Git configuration.
Platform caveats
WSL2: hooks installed from WSL run inside Linux and have none of these issues, but only for commits made from WSL. If the editor commits with Windows Git, hooks run on Windows; standardise on one.
Git Bash: having Git Bash on
PATHmakes.shhooks work, but the order ofPATHentries then decides whetherbashmeans Git Bash or the WSL launcher. Portable Python hooks avoid the ambiguity.
macOS and Linux:
.gitattributeswitheol=lfchanges nothing for them, since they already use LF.
Rollback
Revert the .gitattributes and hook changes; Windows developers are back to their previous state:
#!/usr/bin/env bash
set -euo pipefail
git revert --no-edit "$(git log -1 --format=%H -- .gitattributes)"
git revert --no-edit "$(git log -1 --format=%H -- .pre-commit-config.yaml)"
Frequently Asked Questions
Why do my hooks keep changing the same files on Windows?
Git converts files to CRLF on checkout because of core.autocrlf=true, and the hook converts them back to LF. Add * text=auto eol=lf to .gitattributes and renormalise, so the working tree keeps LF.
Can we keep a Bash hook and still support Windows?
Only if every Windows developer has Git Bash on PATH ahead of the WSL launcher. Rewriting the hook in Python or Node, with language: python or language: node, is more reliable.
Why does pre-commit fail to create environments on a new Windows machine?
Usually because python is the Microsoft Store alias. Disable the aliases and install Python from winget, python.org or the toolchain manager.
Does eol=lf break Windows-only files?
Keep CRLF for files Windows tools require, such as .bat, .cmd and .ps1, with explicit eol=crlf lines after the default.