diff --git a/docs/gitflow-hooks.7.md b/docs/gitflow-hooks.7.md index 2a65253..087421e 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, 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 - 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..40378b0 100644 --- a/internal/hooks/filters.go +++ b/internal/hooks/filters.go @@ -5,9 +5,20 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "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. @@ -71,13 +82,22 @@ 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 } + return isExecutableFileInfo(info) +} - // Check if file is executable (any execute bit set) +// 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() + } return info.Mode()&0111 != 0 } @@ -101,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 @@ -123,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 ecab9c7..76a8ad2 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -119,7 +119,7 @@ func runHook(gitDir string, phase HookPhase, branchType string, action HookActio hooksDir := getHooksDir(gitDir) hookPath := filepath.Join(hooksDir, hookName) - // Check if hook exists + // Check if hook exists and is executable info, err := os.Stat(hookPath) if os.IsNotExist(err) { return HookResult{Executed: false} @@ -127,10 +127,7 @@ func runHook(gitDir string, phase HookPhase, branchType string, action HookActio if err != nil { return HookResult{Executed: false, Error: err} } - - // Check if executable - if info.Mode()&0111 == 0 { - // Not executable, skip silently + if !isExecutableFileInfo(info) { return HookResult{Executed: false} } @@ -141,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 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)