Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/gitflow-hooks.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hooks-dir>/<script-name>`
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.

### Tips

- Always start scripts with a shebang (`#!/bin/sh` or `#!/bin/bash`)
Expand Down
26 changes: 23 additions & 3 deletions internal/hooks/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
}

Comment on lines +13 to +21
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.
// 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.
Expand Down Expand Up @@ -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()
}
Comment on lines +98 to +100
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.
return info.Mode()&0111 != 0
}

Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 3 additions & 6 deletions internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,15 @@ 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}
}
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}
}

Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions test/internal/hooks/filters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hooks_test
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -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")
}
Comment on lines +114 to +116
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 on lines 112 to +116
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.
dir := testutil.SetupTestRepo(t)
defer testutil.CleanupTestRepo(t, dir)

Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions test/internal/hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hooks_test
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -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")
}
Comment on lines +122 to +124
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.
dir := testutil.SetupTestRepo(t)
defer testutil.CleanupTestRepo(t, dir)

Expand Down