Diff
1diff --git a/cmd/soft/hook/hook.go b/cmd/soft/hook/hook.go
2index a16cad6d6728886df7de26be436170b28b6ec350..d56ebc90ab02333933fc972538d4ce0acbf529ad 100644
3--- a/cmd/soft/hook/hook.go
4+++ b/cmd/soft/hook/hook.go
5@@ -117,11 +117,9 @@ var (
6 }
7
8 // Custom hooks
9- if stat, err := os.Stat(customHookPath); err == nil && !stat.IsDir() && stat.Mode()&0o111 != 0 {
10- // If the custom hook is executable, run it
11- if err := runCommand(ctx, &buf, stdout, stderr, customHookPath, args...); err != nil {
12- logger.Error("failed to run custom hook", "err", err)
13- }
14+ if err := runCustomHook(ctx, customHookPath, &buf, stdout, stderr, args...); err != nil {
15+ logger.Error("failed to run custom hook", "err", err)
16+ return err
17 }
18
19 return nil
20@@ -170,3 +168,12 @@ func runCommand(ctx context.Context, in io.Reader, out io.Writer, err io.Writer,
21 cmd.Stderr = err
22 return cmd.Run()
23 }
24+
25+func runCustomHook(ctx context.Context, path string, in io.Reader, out io.Writer, err io.Writer, args ...string) error {
26+ stat, statErr := os.Stat(path)
27+ if statErr != nil || stat.IsDir() || stat.Mode()&0o111 == 0 {
28+ return nil
29+ }
30+
31+ return runCommand(ctx, in, out, err, path, args...)
32+}
33diff --git a/cmd/soft/hook/hook_test.go b/cmd/soft/hook/hook_test.go
34new file mode 100644
35index 0000000000000000000000000000000000000000..77c427c3350abf66ea254862141dabe4c4c87095
36--- /dev/null
37+++ b/cmd/soft/hook/hook_test.go
38@@ -0,0 +1,69 @@
39+package hook
40+
41+import (
42+ "context"
43+ "errors"
44+ "io"
45+ "os"
46+ "os/exec"
47+ "path/filepath"
48+ "testing"
49+)
50+
51+func TestRunCustomHookPropagatesExitError(t *testing.T) {
52+ executable, err := os.Executable()
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ info, err := os.Stat(executable)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if info.Mode()&0o111 == 0 {
61+ t.Skip("test binary is not executable on this platform")
62+ }
63+
64+ t.Setenv("SOFT_SERVE_TEST_HELPER", "1")
65+ t.Setenv("SOFT_SERVE_TEST_HELPER_EXIT", "1")
66+ err = runCustomHook(
67+ context.Background(),
68+ executable,
69+ io.Reader(nil),
70+ io.Discard,
71+ io.Discard,
72+ "-test.run=TestCustomHookHelperProcess",
73+ )
74+ if err == nil {
75+ t.Fatal("runCustomHook returned nil for a failing custom hook")
76+ }
77+
78+ var exitErr *exec.ExitError
79+ if !errors.As(err, &exitErr) {
80+ t.Fatalf("runCustomHook error = %T %v, want exec.ExitError", err, err)
81+ }
82+}
83+
84+func TestRunCustomHookIgnoresMissingOrNonExecutableHook(t *testing.T) {
85+ missing := filepath.Join(t.TempDir(), "missing-hook")
86+ if err := runCustomHook(context.Background(), missing, nil, io.Discard, io.Discard); err != nil {
87+ t.Fatalf("runCustomHook(missing) error = %v", err)
88+ }
89+
90+ nonExecutable := filepath.Join(t.TempDir(), "non-executable-hook")
91+ if err := os.WriteFile(nonExecutable, []byte("exit 1\n"), 0o600); err != nil {
92+ t.Fatal(err)
93+ }
94+ if err := runCustomHook(context.Background(), nonExecutable, nil, io.Discard, io.Discard); err != nil {
95+ t.Fatalf("runCustomHook(non-executable) error = %v", err)
96+ }
97+}
98+
99+func TestCustomHookHelperProcess(t *testing.T) {
100+ if os.Getenv("SOFT_SERVE_TEST_HELPER") != "1" {
101+ return
102+ }
103+ if os.Getenv("SOFT_SERVE_TEST_HELPER_EXIT") == "1" {
104+ os.Exit(1)
105+ }
106+ os.Exit(0)
107+}