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.

Classifying a Windows-Only Hook Failure Decision diagram mapping three Windows hook symptoms to their causes. Classifying a Windows-Only Hook Failure What does the hook report? bash not found shell-script local hook files fixed every commit autocrlf vs LF hooks python not found or stub Store alias on PATH
Most Windows hook failures are one of these three and have a one-line fix.

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

  1. 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.

  1. 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
  1. Disable the Store alias and use a real Python: in Settings → Apps → Advanced app settings → App execution aliases, turn off python.exe and python3.exe, then install Python through winget install Python.Python.3.12 or the project's toolchain manager.

  2. 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"
The CRLF Fix Loop and Its Exit Flow showing how autocrlf and an LF-fixing hook fight until .gitattributes sets eol=lf. The CRLF Fix Loop and Its Exit checkout autocrlf gives CRLF hook rewrites to LF next checkout CRLF again gitattributes eol=lf ends loop
Declaring eol=lf in the repository makes Git and the hook agree, ending the loop.

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

  1. 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
  1. Reject new language: system or script hooks with .sh entries in review, unless the team has decided Windows developers use WSL2 exclusively.

  2. Keep .gitattributes authoritative. It works regardless of each developer's core.autocrlf and is visible in review, unlike per-machine Git configuration.

Windows Failure Causes and Fixes Table mapping each Windows-only hook failure to its cause and fix. Windows Failure Causes and Fixes Symptom Cause Fix bash not found .sh local hook language: python endless LF fixes core.autocrlf .gitattributes eol=lf python stub Store alias real Python install exec-bit flags filemode false update-index --chmod
Each fix is made once in the repository rather than on every Windows machine.

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 PATH makes .sh hooks work, but the order of PATH entries then decides whether bash means Git Bash or the WSL launcher. Portable Python hooks avoid the ambiguity.

macOS and Linux: .gitattributes with eol=lf changes 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.