Fix hooks and filters on Windows#104
Conversation
The executable permission check (info.Mode()&0111) relies on Unix file mode bits that don't exist on NTFS, causing all hooks and filters to be silently skipped on Windows. Make isExecutable() platform-aware: on Windows, treat any existing non-directory file as executable, matching Git for Windows behavior. Unix behavior is unchanged. - Update isExecutable() with runtime.GOOS check for Windows - Use isExecutable() in runHook() instead of inline permission check - Skip non-executable tests on Windows (not distinguishable on NTFS) - Document Windows hook behavior in gitflow-hooks manpage Closes #85
There was a problem hiding this comment.
Pull request overview
Fixes hook/filter execution on Windows by replacing Unix-only executable permission bit checks with a platform-aware isExecutable() helper, aiming to prevent hooks/filters from being skipped on NTFS.
Changes:
- Make
isExecutable()Windows-aware and reuse it for both hooks and filters. - Update hook execution to use
isExecutable()instead of Unix mode bits. - Skip non-executable hook/filter tests on Windows and update the hooks manpage with Windows guidance.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
internal/hooks/hooks.go |
Switch hook executability checks to the shared isExecutable() helper. |
internal/hooks/filters.go |
Introduce platform-aware isExecutable() (Windows vs Unix mode bits). |
test/internal/hooks/hooks_test.go |
Skip the “non-executable hook” test on Windows. |
test/internal/hooks/filters_test.go |
Skip the “non-executable filter” tests on Windows. |
docs/gitflow-hooks.7.md |
Document Windows behavior around executability and chmod. |
| if runtime.GOOS == "windows" { | ||
| return !info.IsDir() | ||
| } |
There was a problem hiding this comment.
On Windows, returning true for any existing non-directory file means we will attempt to execute plain text hook/filter scripts (e.g., a #!/bin/sh file with no extension). exec.Command(path) on Windows generally cannot run such files directly, so this can turn a previously-silent skip into a hard failure (especially for pre-hooks). Consider matching Git for Windows more closely by (a) only treating files as executable when they are actually runnable on Windows (e.g., by checking PATHEXT / known extensions and/or a shebang), and/or (b) invoking the appropriate interpreter (sh, cmd.exe, etc.) when the hook/filter is a script rather than a native executable.
| if runtime.GOOS == "windows" { | ||
| t.Skip("Windows does not distinguish executable permissions via file mode bits") | ||
| } |
There was a problem hiding this comment.
This Windows-specific skip only covers the non-executable case. With the updated Windows executability logic, other tests in this file that create #!/bin/sh hooks will now try to execute those scripts on Windows, which typically fails when invoked via exec.Command directly. Either adjust the hook execution implementation to run shell scripts on Windows, or gate/adapt the hook tests on Windows to use a runnable hook type there.
| if runtime.GOOS == "windows" { | ||
| t.Skip("Windows does not distinguish executable permissions via file mode bits") | ||
| } |
There was a problem hiding this comment.
These Windows skips only cover the non-executable filter scenarios. With isExecutable() now returning true for existing files on Windows, the filter tests that create #!/bin/sh scripts will attempt to execute them via exec.Command(scriptPath, ...), which typically cannot run such scripts directly on Windows. Either implement Windows-compatible script execution (interpreter/shebang handling) or adapt/skip the executable-script filter tests on Windows to use a runnable filter type.
| // Check if executable | ||
| if info.Mode()&0111 == 0 { | ||
| // Not executable, skip silently | ||
| if !isExecutable(hookPath) { |
There was a problem hiding this comment.
runHook() already calls os.Stat(hookPath) to check existence, but isExecutable(hookPath) stats the file again. Consider refactoring to avoid the duplicate filesystem call (and the small TOCTOU window), e.g., by having isExecutable accept os.FileInfo or returning the os.FileInfo from the initial stat and reusing it.
| **Windows note:** On Windows, NTFS does not track Unix permission bits. Git-flow considers any existing hook file as executable, matching Git for Windows behavior. No `chmod` step is needed. | ||
|
|
||
| ### Tips | ||
|
|
||
| - Always start scripts with a shebang (`#!/bin/sh` or `#!/bin/bash`) |
There was a problem hiding this comment.
The docs now state that on Windows “any existing hook file” is considered executable. Given the current implementation executes hooks/filters via exec.Command(path, ...), it would be more accurate to document what formats are actually runnable on Windows (e.g., native executables and/or scripts executed through an interpreter) and any requirements (Git Bash/sh availability, supported extensions, etc.). Otherwise users may create a hook file that exists but still cannot be executed by git-flow-next on Windows.
| **Windows note:** On Windows, NTFS does not track Unix permission bits. Git-flow considers any existing hook file as executable, matching Git for Windows behavior. No `chmod` step is needed. | |
| ### Tips | |
| - Always start scripts with a shebang (`#!/bin/sh` or `#!/bin/bash`) | |
| **Windows note:** On Windows, NTFS does not track Unix permission bits, so no `chmod` step is needed. However, the hook file must still be runnable by Windows when git-flow executes it directly by path. In practice, this usually means a native executable or a script format Windows can launch, such as `.exe`, `.bat`, or `.cmd`. Shell scripts such as `.sh` are not automatically runnable just because the file exists; they require an installed interpreter and a launch mechanism such as a wrapper script or file association. | |
| ### Tips | |
| - On Unix/macOS, always start scripts with a shebang (`#!/bin/sh` or `#!/bin/bash`) | |
| - On Windows, prefer hook files that are directly launchable by Windows (`.exe`, `.bat`, `.cmd`), or use a wrapper that invokes `sh`, `bash`, or another interpreter you have installed |
On Windows, exec.Command cannot run shell scripts directly since CreateProcess doesn't handle shebangs. Scripts are now executed via "sh" (shipped with Git for Windows), matching how Git itself runs hooks. Also eliminates a duplicate os.Stat call in runHook by splitting isExecutable into path and FileInfo variants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed the review feedback in 9ab85b3: Script execution on Windows (comments 1–3): Hook and filter scripts are now executed via Duplicate Documentation (comment 5): Updated the Windows note to mention that scripts are executed via |
| // On Windows, scripts are executed via "sh" (shipped with Git for Windows), | ||
| // since exec.Command cannot run shell scripts directly on Windows. | ||
| func scriptCommand(path string, args ...string) *exec.Cmd { | ||
| if runtime.GOOS == "windows" { | ||
| return exec.Command("sh", append([]string{path}, args...)...) | ||
| } | ||
| return exec.Command(path, args...) | ||
| } | ||
|
|
There was a problem hiding this comment.
On Windows this relies on exec.Command("sh", ...) resolving sh from the current PATH. In common Git for Windows setups, git.exe is on PATH but sh.exe may not be, which would make hooks/filters fail with “executable file not found”. Consider resolving the shell explicitly (e.g., exec.LookPath("sh") with a fallback derived from the Git installation path / core.shell) and returning an actionable error when no shell can be found.
| // On Windows, scripts are executed via "sh" (shipped with Git for Windows), | |
| // since exec.Command cannot run shell scripts directly on Windows. | |
| func scriptCommand(path string, args ...string) *exec.Cmd { | |
| if runtime.GOOS == "windows" { | |
| return exec.Command("sh", append([]string{path}, args...)...) | |
| } | |
| return exec.Command(path, args...) | |
| } | |
| // On Windows, scripts are executed via a POSIX shell (typically shipped with | |
| // Git for Windows), since exec.Command cannot run shell scripts directly on | |
| // Windows. | |
| func scriptCommand(path string, args ...string) *exec.Cmd { | |
| if runtime.GOOS == "windows" { | |
| shellPath, err := resolveWindowsShell() | |
| if err != nil { | |
| return windowsMissingShellCommand(err) | |
| } | |
| return exec.Command(shellPath, append([]string{path}, args...)...) | |
| } | |
| return exec.Command(path, args...) | |
| } | |
| func resolveWindowsShell() (string, error) { | |
| if shellPath, err := exec.LookPath("sh"); err == nil { | |
| return shellPath, nil | |
| } | |
| gitCandidates := []string{"git", "git.exe"} | |
| for _, gitName := range gitCandidates { | |
| gitPath, err := exec.LookPath(gitName) | |
| if err != nil { | |
| continue | |
| } | |
| for _, candidate := range candidateWindowsShellsFromGitPath(gitPath) { | |
| info, statErr := os.Stat(candidate) | |
| if statErr == nil && !info.IsDir() { | |
| return candidate, nil | |
| } | |
| } | |
| } | |
| return "", fmt.Errorf("could not find a POSIX shell for running hooks/filters on Windows; ensure Git for Windows is installed and either add sh.exe to PATH or install it alongside git.exe") | |
| } | |
| func candidateWindowsShellsFromGitPath(gitPath string) []string { | |
| gitPath = filepath.Clean(gitPath) | |
| gitDir := filepath.Dir(gitPath) | |
| return []string{ | |
| filepath.Join(gitDir, "sh.exe"), | |
| filepath.Join(gitDir, "..", "bin", "sh.exe"), | |
| filepath.Join(gitDir, "..", "usr", "bin", "sh.exe"), | |
| filepath.Join(gitDir, "bin", "sh.exe"), | |
| filepath.Join(gitDir, "usr", "bin", "sh.exe"), | |
| } | |
| } | |
| func windowsMissingShellCommand(err error) *exec.Cmd { | |
| message := err.Error() | |
| return exec.Command("cmd.exe", "/d", "/c", "echo "+message+" 1>&2 && exit /b 1") | |
| } |
| 2. Make the script executable: `chmod +x <hooks-dir>/<script-name>` (Unix/macOS only — not needed on Windows) | ||
| 3. Test the script manually before relying on it | ||
|
|
||
| **Windows note:** On Windows, NTFS does not track Unix permission bits, so no `chmod` step is needed. Hook scripts are executed via `sh` (shipped with Git for Windows), so shell scripts with a shebang work the same as on Unix/macOS. |
There was a problem hiding this comment.
This Windows note says hooks are executed via sh, but doesn’t mention that sh must be discoverable (usually via PATH) for non-Git-Bash shells (PowerShell/cmd). If the implementation keeps relying on PATH lookup, please document that requirement and how to ensure sh is available (or update the implementation to locate it automatically).
| **Windows note:** On Windows, NTFS does not track Unix permission bits, so no `chmod` step is needed. Hook scripts are executed via `sh` (shipped with Git for Windows), so shell scripts with a shebang work the same as on Unix/macOS. | |
| **Windows note:** On Windows, NTFS does not track Unix permission bits, so no `chmod` step is needed. Hook scripts are executed via `sh` (shipped with Git for Windows), so shell scripts with a shebang work the same as on Unix/macOS. When running git-flow from PowerShell or `cmd.exe`, `sh` must still be discoverable by the process, usually via `PATH`. Git Bash normally sets this up automatically. If `sh` is not found, shell-based hooks cannot run. To ensure `sh` is available, run git-flow from Git Bash or add your Git for Windows installation's `bin` or `usr/bin` directory to `PATH`. |
| // TestVersionFilterNonExecutableSkipped tests that non-executable filter is skipped. | ||
| func TestVersionFilterNonExecutableSkipped(t *testing.T) { | ||
| if runtime.GOOS == "windows" { | ||
| t.Skip("Windows does not distinguish executable permissions via file mode bits") | ||
| } |
There was a problem hiding this comment.
These skips cover the non-executable mode-bit tests, but the other filter tests will now execute scripts via sh on Windows and may fail if sh isn’t discoverable (often when running from PowerShell/cmd). Consider a shared helper that skips Windows filter tests when sh can’t be found (or make the production code locate sh reliably so tests don’t depend on PATH).
Fixes hooks and filters silently failing on Windows due to Unix-only executable permission checks.
The executable check (
info.Mode()&0111) relies on Unix file mode bits that don't exist on NTFS, causing all hooks and filters to be skipped every time on Windows. The fix makesisExecutable()platform-aware: on Windows, any existing non-directory file is treated as executable, matching Git for Windows behavior. Unix behavior is unchanged.Additionally, hook and filter scripts are now executed via
shon Windows (shipped with Git for Windows), sinceexec.Commandcannot run shell scripts directly on Windows — the same approach Git itself uses.Changes touch
internal/hooks/filters.go,internal/hooks/hooks.go, tests, anddocs/gitflow-hooks.7.md.Closes #85
Remarks
isExecutable()helper infilters.gois now shared by both hooks and filtersscriptCommand()helper wrapsexec.Commandto invoke scripts viashon Windowsos.Statcall inrunHookeliminated by splitting intoisExecutable(path) andisExecutableFileInfo(FileInfo) variants