From f516d513cba47599356217eca07087a0b6250360 Mon Sep 17 00:00:00 2001 From: Alexander Rinass Date: Tue, 7 Apr 2026 11:46:00 +0200 Subject: [PATCH 1/2] fix(hooks): Support hooks and filters on Windows 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 --- docs/gitflow-hooks.7.md | 4 +++- internal/hooks/filters.go | 7 +++++++ internal/hooks/hooks.go | 5 ++--- test/internal/hooks/filters_test.go | 7 +++++++ test/internal/hooks/hooks_test.go | 4 ++++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/gitflow-hooks.7.md b/docs/gitflow-hooks.7.md index 2a65253..4daa0aa 100644 --- a/docs/gitflow-hooks.7.md +++ b/docs/gitflow-hooks.7.md @@ -294,9 +294,11 @@ fi ## CREATING HOOK SCRIPTS 1. Create the script in the hooks directory (default: `.git/hooks/`) with the appropriate name -2. Make the script executable: `chmod +x /` +2. Make the script executable: `chmod +x /` (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. 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`) diff --git a/internal/hooks/filters.go b/internal/hooks/filters.go index 464613e..cf88375 100644 --- a/internal/hooks/filters.go +++ b/internal/hooks/filters.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" ) @@ -71,12 +72,18 @@ func RunTagMessageFilter(gitDir string, branchType string, ctx FilterContext) (s } // isExecutable checks if a file exists and is executable. +// On Windows, NTFS doesn't store Unix permission bits, so any existing +// non-directory file is considered executable (matching Git for Windows behavior). func isExecutable(path string) bool { info, err := os.Stat(path) if err != nil { return false } + if runtime.GOOS == "windows" { + return !info.IsDir() + } + // Check if file is executable (any execute bit set) return info.Mode()&0111 != 0 } diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index ecab9c7..dae7d55 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -120,7 +120,7 @@ func runHook(gitDir string, phase HookPhase, branchType string, action HookActio hookPath := filepath.Join(hooksDir, hookName) // Check if hook exists - info, err := os.Stat(hookPath) + _, err := os.Stat(hookPath) if os.IsNotExist(err) { return HookResult{Executed: false} } @@ -129,8 +129,7 @@ func runHook(gitDir string, phase HookPhase, branchType string, action HookActio } // Check if executable - if info.Mode()&0111 == 0 { - // Not executable, skip silently + if !isExecutable(hookPath) { return HookResult{Executed: false} } diff --git a/test/internal/hooks/filters_test.go b/test/internal/hooks/filters_test.go index d0855cb..ecd17fa 100644 --- a/test/internal/hooks/filters_test.go +++ b/test/internal/hooks/filters_test.go @@ -3,6 +3,7 @@ package hooks_test import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -110,6 +111,9 @@ func TestVersionFilterNonExistentReturnsOriginal(t *testing.T) { // 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") + } dir := testutil.SetupTestRepo(t) defer testutil.CleanupTestRepo(t, dir) @@ -278,6 +282,9 @@ func TestTagMessageFilterNonExistentReturnsOriginal(t *testing.T) { // TestTagMessageFilterNonExecutableSkipped tests that non-executable filter is skipped. func TestTagMessageFilterNonExecutableSkipped(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not distinguish executable permissions via file mode bits") + } dir := testutil.SetupTestRepo(t) defer testutil.CleanupTestRepo(t, dir) diff --git a/test/internal/hooks/hooks_test.go b/test/internal/hooks/hooks_test.go index 0a39e2d..59cb43f 100644 --- a/test/internal/hooks/hooks_test.go +++ b/test/internal/hooks/hooks_test.go @@ -3,6 +3,7 @@ package hooks_test import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -118,6 +119,9 @@ func TestPreHookNonExistent(t *testing.T) { // TestPreHookNonExecutable tests that non-executable pre-hook is skipped. func TestPreHookNonExecutable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not distinguish executable permissions via file mode bits") + } dir := testutil.SetupTestRepo(t) defer testutil.CleanupTestRepo(t, dir) From 9ab85b33be86b4c46ec679ed87eaf8e01d2f4d5b Mon Sep 17 00:00:00 2001 From: Alexander Rinass Date: Wed, 8 Apr 2026 11:30:29 +0200 Subject: [PATCH 2/2] fix(hooks): Execute scripts via sh on Windows 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 --- docs/gitflow-hooks.7.md | 2 +- internal/hooks/filters.go | 21 +++++++++++++++++---- internal/hooks/hooks.go | 10 ++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/gitflow-hooks.7.md b/docs/gitflow-hooks.7.md index 4daa0aa..087421e 100644 --- a/docs/gitflow-hooks.7.md +++ b/docs/gitflow-hooks.7.md @@ -297,7 +297,7 @@ fi 2. Make the script executable: `chmod +x /` (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. Git-flow considers any existing hook file as executable, matching Git for Windows behavior. No `chmod` step is needed. +**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. ### Tips diff --git a/internal/hooks/filters.go b/internal/hooks/filters.go index cf88375..40378b0 100644 --- a/internal/hooks/filters.go +++ b/internal/hooks/filters.go @@ -9,6 +9,16 @@ import ( "strings" ) +// scriptCommand creates an exec.Cmd for running a hook/filter script. +// 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...) +} + // RunVersionFilter executes a version filter for the given branch type and returns the modified version. // The filter script name is: filter-flow-{branchType}-start-version // If the filter does not exist or is not executable, the original version is returned. @@ -79,12 +89,15 @@ func isExecutable(path string) bool { if err != nil { return false } + return isExecutableFileInfo(info) +} +// isExecutableFileInfo checks if a file is executable given its FileInfo. +// On Windows, any non-directory file is considered executable. +func isExecutableFileInfo(info os.FileInfo) bool { if runtime.GOOS == "windows" { return !info.IsDir() } - - // Check if file is executable (any execute bit set) return info.Mode()&0111 != 0 } @@ -108,7 +121,7 @@ func buildFilterEnv(ctx FilterContext) []string { // runFilter executes a filter script with input as argument. func runFilter(scriptPath string, input string, env []string, repoRoot string) (string, error) { - cmd := exec.Command(scriptPath, input) + cmd := scriptCommand(scriptPath, input) if env != nil { cmd.Env = env @@ -130,7 +143,7 @@ func runFilter(scriptPath string, input string, env []string, repoRoot string) ( // runFilterWithArgs executes a filter script with arguments. func runFilterWithArgs(scriptPath string, args []string, env []string, repoRoot string) (string, error) { - cmd := exec.Command(scriptPath, args...) + cmd := scriptCommand(scriptPath, args...) if env != nil { cmd.Env = env diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index dae7d55..76a8ad2 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -119,17 +119,15 @@ func runHook(gitDir string, phase HookPhase, branchType string, action HookActio hooksDir := getHooksDir(gitDir) hookPath := filepath.Join(hooksDir, hookName) - // Check if hook exists - _, err := os.Stat(hookPath) + // Check if hook exists and is executable + info, err := os.Stat(hookPath) if os.IsNotExist(err) { return HookResult{Executed: false} } if err != nil { return HookResult{Executed: false, Error: err} } - - // Check if executable - if !isExecutable(hookPath) { + if !isExecutableFileInfo(info) { return HookResult{Executed: false} } @@ -140,7 +138,7 @@ func runHook(gitDir string, phase HookPhase, branchType string, action HookActio args := BuildHookArgs(action, ctx) // Execute hook with arguments - cmd := exec.Command(hookPath, args...) + cmd := scriptCommand(hookPath, args...) cmd.Env = env cmd.Dir = filepath.Dir(gitDir) // Repository root