Skip to content

Commit 96c5897

Browse files
steveyeggebaumgold
andcommitted
fix: merge PRs #3246+#3243+#3242 — hooks fixes (gt-91g)
Fix-merge three baumgold PRs with conflict resolution: - PR #3246: JSON-escape GT_BIN on Windows — use json.MarshalIndent in test reformatter to avoid corrupting Windows drive letters - PR #3243: doctor hooks-sync uses ResolveRoleAgentName + preset directly instead of ResolveAgentConfigByName, preventing false negatives when non-Claude binary not in PATH - PR #3242: hooks-sync uses same preset-based approach for installing hooks for configured agent even when binary not found - Both #3243/#3242: remove t.Parallel() from tests that modify package-level globals, eliminating data races Co-authored-by: baumgold <baumgold@users.noreply.github.com>
1 parent 9b3baca commit 96c5897

File tree

4 files changed

+70
-51
lines changed

4 files changed

+70
-51
lines changed

internal/cmd/hooks_sync.go

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -127,23 +127,33 @@ func runHooksSync(cmd *cobra.Command, args []string) error {
127127
if loc.Rig != "" {
128128
rigPath = filepath.Join(townRoot, loc.Rig)
129129
}
130-
// Use ResolveRoleAgentName + ResolveAgentConfigByName so hooks sync works
131-
// even when the agent binary is not installed on this machine.
130+
131+
// Use ResolveRoleAgentName (not ResolveRoleAgentConfig) so that hooks are
132+
// installed based on the *configured* agent, not the *resolved* one.
133+
// ResolveRoleAgentConfig falls back to claude when the agent binary is not
134+
// found in PATH (e.g., in CI or on a fresh machine), which would silently
135+
// skip creating opencode/gemini/etc. plugin files.
132136
agentName, _ := config.ResolveRoleAgentName(loc.Role, townRoot, rigPath)
133-
if agentName == "" || agentName == "claude" {
137+
if agentName == "" {
134138
continue
135139
}
136-
rc := config.ResolveAgentConfigByName(agentName, townRoot, rigPath)
137-
if rc == nil || rc.Hooks == nil || rc.Hooks.Provider == "" {
140+
141+
preset := config.GetAgentPresetByName(agentName)
142+
if preset == nil || preset.HooksDir == "" || preset.HooksSettingsFile == "" {
138143
continue
139144
}
145+
146+
hooksProvider := preset.HooksProvider
147+
if hooksProvider == "" {
148+
hooksProvider = agentName
149+
}
150+
140151
// Claude targets are already handled by DiscoverTargets + syncTarget above.
141-
if rc.Hooks.Provider == "claude" {
152+
if hooksProvider == "claude" {
142153
continue
143154
}
144155

145-
preset := config.GetAgentPresetByName(rc.Hooks.Provider)
146-
useSettingsDir := preset != nil && preset.HooksUseSettingsDir
156+
useSettingsDir := preset.HooksUseSettingsDir
147157

148158
// Determine sync targets.
149159
// - Town-level roles (mayor, deacon): the role dir IS the working directory.
@@ -158,40 +168,40 @@ func runHooksSync(cmd *cobra.Command, args []string) error {
158168
}
159169

160170
for _, dir := range syncDirs {
161-
targetPath := filepath.Join(dir, rc.Hooks.Dir, rc.Hooks.SettingsFile)
171+
targetPath := filepath.Join(dir, preset.HooksDir, preset.HooksSettingsFile)
162172
relPath, pathErr := filepath.Rel(townRoot, targetPath)
163173
if pathErr != nil {
164174
relPath = targetPath
165175
}
166176

167177
if hooksSyncDryRun {
168178
if _, statErr := os.Stat(targetPath); statErr == nil {
169-
fmt.Printf(" %s %s %s\n", style.Warning.Render("~"), relPath, style.Dim.Render("(would check "+rc.Hooks.Provider+")"))
179+
fmt.Printf(" %s %s %s\n", style.Warning.Render("~"), relPath, style.Dim.Render("(would check "+hooksProvider+")"))
170180
} else {
171-
fmt.Printf(" %s %s %s\n", style.Warning.Render("~"), relPath, style.Dim.Render("(would create "+rc.Hooks.Provider+")"))
181+
fmt.Printf(" %s %s %s\n", style.Warning.Render("~"), relPath, style.Dim.Render("(would create "+hooksProvider+")"))
172182
created++
173183
}
174184
continue
175185
}
176186

177-
result, syncErr := hooks.SyncForRole(rc.Hooks.Provider, dir, dir, loc.Role,
178-
rc.Hooks.Dir, rc.Hooks.SettingsFile, useSettingsDir)
187+
result, syncErr := hooks.SyncForRole(hooksProvider, dir, dir, loc.Role,
188+
preset.HooksDir, preset.HooksSettingsFile, useSettingsDir)
179189
if syncErr != nil {
180-
fmt.Printf(" %s %s (%s): %v\n", style.Error.Render("✖"), relPath, rc.Hooks.Provider, syncErr)
190+
fmt.Printf(" %s %s (%s): %v\n", style.Error.Render("✖"), relPath, hooksProvider, syncErr)
181191
errors++
182192
failedTargets = append(failedTargets, relPath)
183193
continue
184194
}
185195

186196
switch result {
187197
case hooks.SyncCreated:
188-
fmt.Printf(" %s %s %s\n", style.Success.Render("✓"), relPath, style.Dim.Render("(created "+rc.Hooks.Provider+")"))
198+
fmt.Printf(" %s %s %s\n", style.Success.Render("✓"), relPath, style.Dim.Render("(created "+hooksProvider+")"))
189199
created++
190200
case hooks.SyncUpdated:
191-
fmt.Printf(" %s %s %s\n", style.Success.Render("✓"), relPath, style.Dim.Render("(updated "+rc.Hooks.Provider+")"))
201+
fmt.Printf(" %s %s %s\n", style.Success.Render("✓"), relPath, style.Dim.Render("(updated "+hooksProvider+")"))
192202
updated++
193203
case hooks.SyncUnchanged:
194-
fmt.Printf(" %s %s %s\n", style.Dim.Render("·"), relPath, style.Dim.Render("(unchanged "+rc.Hooks.Provider+")"))
204+
fmt.Printf(" %s %s %s\n", style.Dim.Render("·"), relPath, style.Dim.Render("(unchanged "+hooksProvider+")"))
195205
unchanged++
196206
}
197207
}

internal/cmd/wl_stamp_loop_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ func TestStampLoop_EndToEnd(t *testing.T) {
128128
// TestStampLoop_SelfStampFails verifies the yearbook rule (author != subject).
129129
// Not parallel: mutates package-level wlStamp* globals.
130130
func TestStampLoop_SelfStampFails(t *testing.T) {
131-
132131
// Save/restore globals
133132
origQ, origR, origC := wlStampQuality, wlStampReliability, wlStampCreativity
134133
origSev, origType, origCtx := wlStampSeverity, wlStampType, wlStampContextType

internal/doctor/hooks_sync_check.go

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -99,23 +99,28 @@ func (c *HooksSyncCheck) Run(ctx *CheckContext) *CheckResult {
9999
if loc.Rig != "" {
100100
rigPath = filepath.Join(ctx.TownRoot, loc.Rig)
101101
}
102-
// Use ResolveRoleAgentName + ResolveAgentConfigByName so the check works
103-
// even when the agent binary is not installed on this machine.
102+
// Use ResolveRoleAgentName (not ResolveRoleAgentConfig) so that checks are
103+
// based on the *configured* agent, not the *resolved* one.
104+
// ResolveRoleAgentConfig falls back to claude when the agent binary is not
105+
// found in PATH (e.g., in CI), which would silently skip non-Claude targets.
104106
agentName, _ := config.ResolveRoleAgentName(loc.Role, ctx.TownRoot, rigPath)
105-
if agentName == "" || agentName == "claude" {
107+
if agentName == "" {
106108
continue
107109
}
108-
rc := config.ResolveAgentConfigByName(agentName, ctx.TownRoot, rigPath)
109-
if rc == nil || rc.Hooks == nil || rc.Hooks.Provider == "" {
110+
preset := config.GetAgentPresetByName(agentName)
111+
if preset == nil || preset.HooksDir == "" || preset.HooksSettingsFile == "" {
110112
continue
111113
}
114+
hooksProvider := preset.HooksProvider
115+
if hooksProvider == "" {
116+
hooksProvider = agentName
117+
}
112118
// Claude targets are handled by Loop 1.
113-
if rc.Hooks.Provider == "claude" {
119+
if hooksProvider == "claude" {
114120
continue
115121
}
116122

117-
preset := config.GetAgentPresetByName(rc.Hooks.Provider)
118-
useSettingsDir := preset != nil && preset.HooksUseSettingsDir
123+
useSettingsDir := preset.HooksUseSettingsDir
119124

120125
var checkDirs []string
121126
if loc.Rig == "" || useSettingsDir {
@@ -126,41 +131,41 @@ func (c *HooksSyncCheck) Run(ctx *CheckContext) *CheckResult {
126131

127132
for _, dir := range checkDirs {
128133
totalTargets++
129-
targetPath := filepath.Join(dir, rc.Hooks.Dir, rc.Hooks.SettingsFile)
134+
targetPath := filepath.Join(dir, preset.HooksDir, preset.HooksSettingsFile)
130135

131-
expected, err := hooks.ComputeExpectedTemplate(rc.Hooks.Provider, rc.Hooks.SettingsFile, loc.Role)
136+
expected, err := hooks.ComputeExpectedTemplate(hooksProvider, preset.HooksSettingsFile, loc.Role)
132137
if err != nil {
133-
details = append(details, fmt.Sprintf("%s (%s): error computing template: %v", targetPath, rc.Hooks.Provider, err))
138+
details = append(details, fmt.Sprintf("%s (%s): error computing template: %v", targetPath, hooksProvider, err))
134139
continue
135140
}
136141

137142
actual, readErr := os.ReadFile(targetPath)
138143
if readErr != nil {
139144
// File missing
140145
c.templateOutOfSync = append(c.templateOutOfSync, templateTarget{
141-
path: targetPath, dir: dir, provider: rc.Hooks.Provider,
142-
role: loc.Role, hooksDir: rc.Hooks.Dir,
143-
settingsFile: rc.Hooks.SettingsFile, useSettingsDir: useSettingsDir,
146+
path: targetPath, dir: dir, provider: hooksProvider,
147+
role: loc.Role, hooksDir: preset.HooksDir,
148+
settingsFile: preset.HooksSettingsFile, useSettingsDir: useSettingsDir,
144149
})
145-
details = append(details, fmt.Sprintf("%s (%s): missing", targetPath, rc.Hooks.Provider))
150+
details = append(details, fmt.Sprintf("%s (%s): missing", targetPath, hooksProvider))
146151
continue
147152
}
148153

149154
// Compare: structural for JSON, byte-exact for other files.
150155
inSync := false
151-
if filepath.Ext(rc.Hooks.SettingsFile) == ".json" {
156+
if filepath.Ext(preset.HooksSettingsFile) == ".json" {
152157
inSync = hooks.TemplateContentEqual(expected, actual)
153158
} else {
154159
inSync = bytes.Equal(expected, actual)
155160
}
156161

157162
if !inSync {
158163
c.templateOutOfSync = append(c.templateOutOfSync, templateTarget{
159-
path: targetPath, dir: dir, provider: rc.Hooks.Provider,
160-
role: loc.Role, hooksDir: rc.Hooks.Dir,
161-
settingsFile: rc.Hooks.SettingsFile, useSettingsDir: useSettingsDir,
164+
path: targetPath, dir: dir, provider: hooksProvider,
165+
role: loc.Role, hooksDir: preset.HooksDir,
166+
settingsFile: preset.HooksSettingsFile, useSettingsDir: useSettingsDir,
162167
})
163-
details = append(details, fmt.Sprintf("%s (%s): out of sync", targetPath, rc.Hooks.Provider))
168+
details = append(details, fmt.Sprintf("%s (%s): out of sync", targetPath, hooksProvider))
164169
}
165170
}
166171
}

internal/hooks/installer_test.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package hooks
22

33
import (
4+
"encoding/json"
45
"os"
56
"path/filepath"
67
"runtime"
@@ -230,14 +231,21 @@ func TestSyncForRole_JSONWhitespaceInsensitive(t *testing.T) {
230231
t.Fatalf("reading created file: %v", err)
231232
}
232233

233-
// Add extra whitespace — structurally identical JSON, different bytes.
234-
// Use commas (not colons) since Windows paths contain "D:" which would
235-
// corrupt the JSON value when colons are naively replaced.
236-
reformatted := strings.ReplaceAll(string(original), ",", " , ")
237-
if string(original) == reformatted {
234+
// Reformat with different whitespace by round-tripping through json.MarshalIndent.
235+
// This changes indentation structure without corrupting string values (safe on Windows
236+
// where strings.ReplaceAll(":", " : ") would corrupt drive letters like C: → C :).
237+
var parsed interface{}
238+
if err := json.Unmarshal(original, &parsed); err != nil {
239+
t.Fatalf("parsing original JSON: %v", err)
240+
}
241+
reformatted, err := json.MarshalIndent(parsed, "", " ")
242+
if err != nil {
243+
t.Fatalf("reformatting JSON: %v", err)
244+
}
245+
if string(original) == string(reformatted) {
238246
t.Fatal("reformatted content should differ from original bytes")
239247
}
240-
if err := os.WriteFile(targetPath, []byte(reformatted), 0600); err != nil {
248+
if err := os.WriteFile(targetPath, reformatted, 0600); err != nil {
241249
t.Fatalf("writing reformatted file: %v", err)
242250
}
243251

@@ -267,12 +275,10 @@ func TestSyncForRole_GeminiWithGTBinSubstitution(t *testing.T) {
267275
if strings.Contains(string(got), "{{GT_BIN}}") {
268276
t.Error("{{GT_BIN}} placeholder was not substituted")
269277
}
270-
// Verify the resolved binary path is present.
271-
// JSON settings files have backslashes escaped (Windows paths), so check
272-
// for the JSON-encoded form of the path rather than the raw path.
278+
// Verify the resolved binary path is present (JSON-escaped for Windows compatibility).
273279
gtBin := resolveGTBinary()
274-
gtBinInFile := strings.ReplaceAll(gtBin, `\`, `\\`)
275-
if !strings.Contains(string(got), gtBinInFile) {
280+
gtBinJSON := strings.ReplaceAll(gtBin, `\`, `\\`)
281+
if !strings.Contains(string(got), gtBinJSON) {
276282
t.Errorf("expected resolved gt binary %q in output", gtBin)
277283
}
278284
}
@@ -377,8 +383,7 @@ func TestInstallForRole_GeminiRoleAware(t *testing.T) {
377383
got, _ := os.ReadFile(filepath.Join(dir, ".gemini", "settings.json"))
378384
want, _ := templateFS.ReadFile("templates/gemini/settings-autonomous.json")
379385
// Gemini templates contain {{GT_BIN}} which gets resolved at install time.
380-
// JSON settings files have backslashes escaped (Windows paths), so apply
381-
// the same JSON-escaping used by resolveAndSubstitute for comparison.
386+
// Apply the same substitution (with JSON escaping) to the expected content for comparison.
382387
gtBin := resolveGTBinary()
383388
gtBinJSON := strings.ReplaceAll(gtBin, `\`, `\\`)
384389
wantResolved := strings.ReplaceAll(string(want), "{{GT_BIN}}", gtBinJSON)

0 commit comments

Comments
 (0)