@@ -5,6 +5,7 @@ import (
55 "context"
66 "log/slog"
77 "os/exec"
8+ "regexp"
89 "strings"
910 "sync"
1011 "time"
@@ -12,25 +13,63 @@ import (
1213 "github.com/charmbracelet/crush/internal/config"
1314)
1415
16+// compiledHook pairs a HookConfig with its compiled matcher regex. A nil
17+// matcher means "match every tool".
18+type compiledHook struct {
19+ cfg config.HookConfig
20+ matcher *regexp.Regexp
21+}
22+
1523// Runner executes hook commands and aggregates their results.
1624type Runner struct {
17− hooks []config.HookConfig
25+ hooks []compiledHook
1826 cwd string
1927 projectDir string
2028}
2129
22−// NewRunner creates a Runner from the given hook configs.
30+// NewRunner creates a Runner from the given hook configs. Each hook's
31+// Matcher is compiled here so the Runner is self-sufficient; callers do
32+// not have to pre-compile matchers on the config, and reloads or merges
33+// that rebuild HookConfig values can't silently strip compiled state.
34+//
35+// Hooks whose matcher fails to compile are skipped with a warning rather
36+// than treated as match-everything. ValidateHooks is expected to have
37+// caught syntax errors earlier, so this is defense in depth.
2338func NewRunner(hooks []config.HookConfig, cwd, projectDir string) *Runner {
39+ compiled := make([]compiledHook, 0, len(hooks))
40+ for _, h := range hooks {
41+ ch := compiledHook{cfg: h}
42+ if h.Matcher != "" {
43+ re, err := regexp.Compile(h.Matcher)
44+ if err != nil {
45+ slog.Warn("Hook matcher failed to compile; skipping hook",
46+ "matcher", h.Matcher,
47+ "command", h.Command,
48+ "error", err,
49+ )
50+ continue
51+ }
52+ ch.matcher = re
53+ }
54+ compiled = append(compiled, ch)
55+ }
2456 return &Runner{
25− hooks: hooks,
57+ hooks: compiled,
2658 cwd: cwd,
2759 projectDir: projectDir,
2860 }
2961}
3062
31−// Hooks returns the hook configs the runner was created with.
63+// Hooks returns the hook configs the runner was created with, in config
64+// order. Hooks whose matcher failed to compile at construction are
65+// omitted. Intended for diagnostics; callers should not rely on ordering
66+// or identity beyond that.
3267func (r *Runner) Hooks() []config.HookConfig {
33− return r.hooks
68+ out := make([]config.HookConfig, len(r.hooks))
69+ for i, h := range r.hooks {
70+ out[i] = h.cfg
71+ }
72+ return out
3473}
3574
3675// Run executes all matching hooks for the given event and tool, returning
@@ -93,9 +132,8 @@ func (r *Runner) Run(ctx context.Context, eventName, sessionID, toolName, toolIn
93132func (r *Runner) matchingHooks(toolName string) []config.HookConfig {
94133 var matched []config.HookConfig
95134 for _, h := range r.hooks {
96− re := h.MatcherRegex()
97− if re == nil || re.MatchString(toolName) {
98− matched = append(matched, h)
135+ if h.matcher == nil || h.matcher.MatchString(toolName) {
136+ matched = append(matched, h.cfg)
99137 }
100138 }
101139 return matched