Skip to content

Fix hooks and filters on Windows#104

Open
alexrinass wants to merge 2 commits intomainfrom
feature/85-hooks-windows-support
Open

Fix hooks and filters on Windows#104
alexrinass wants to merge 2 commits intomainfrom
feature/85-hooks-windows-support

Conversation

@alexrinass
Copy link
Copy Markdown
Contributor

@alexrinass alexrinass commented Apr 7, 2026

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 makes isExecutable() 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 sh on Windows (shipped with Git for Windows), since exec.Command cannot run shell scripts directly on Windows — the same approach Git itself uses.

Changes touch internal/hooks/filters.go, internal/hooks/hooks.go, tests, and docs/gitflow-hooks.7.md.

Closes #85

Remarks

  • The isExecutable() helper in filters.go is now shared by both hooks and filters
  • Non-executable hook/filter tests are skipped on Windows since NTFS can't distinguish permission bits
  • scriptCommand() helper wraps exec.Command to invoke scripts via sh on Windows
  • Duplicate os.Stat call in runHook eliminated by splitting into isExecutable (path) and isExecutableFileInfo (FileInfo) variants
  • Approach matches how Git for Windows itself handles hook executability and execution

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/hooks/filters.go
Comment on lines +83 to +85
if runtime.GOOS == "windows" {
return !info.IsDir()
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +124
if runtime.GOOS == "windows" {
t.Skip("Windows does not distinguish executable permissions via file mode bits")
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +116
if runtime.GOOS == "windows" {
t.Skip("Windows does not distinguish executable permissions via file mode bits")
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread internal/hooks/hooks.go Outdated
// Check if executable
if info.Mode()&0111 == 0 {
// Not executable, skip silently
if !isExecutable(hookPath) {
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread docs/gitflow-hooks.7.md Outdated
Comment on lines 300 to 304
**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`)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
**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

Copilot uses AI. Check for mistakes.
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>
@alexrinass
Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 9ab85b3:

Script execution on Windows (comments 1–3): Hook and filter scripts are now executed via sh on Windows using a scriptCommand() helper, matching how Git for Windows itself runs hooks. This prevents the scenario where exec.Command fails on shell scripts that pass the isExecutable check. Since Git for Windows ships sh, this works out of the box.

Duplicate os.Stat (comment 4): Split isExecutable into two variants — isExecutable(path) for filters (which don't have prior stat info) and isExecutableFileInfo(info) for runHook (which already has the os.FileInfo from its existence check).

Documentation (comment 5): Updated the Windows note to mention that scripts are executed via sh (shipped with Git for Windows), rather than claiming any existing file is directly runnable.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread internal/hooks/filters.go
Comment on lines +13 to +21
// 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...)
}

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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")
}

Copilot uses AI. Check for mistakes.
Comment thread docs/gitflow-hooks.7.md
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.
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
**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`.

Copilot uses AI. Check for mistakes.
Comment on lines 112 to +116
// 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")
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support requested for hooks

2 participants