-
Notifications
You must be signed in to change notification settings - Fork 19
Fix hooks and filters on Windows #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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
AI
Apr 7, 2026
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
| } | ||
|
Comment on lines
+114
to
+116
|
||
| 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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
| } | ||
|
Comment on lines
+122
to
+124
|
||
| dir := testutil.SetupTestRepo(t) | ||
| defer testutil.CleanupTestRepo(t, dir) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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 thatshmust 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 ensureshis available (or update the implementation to locate it automatically).