Skip to content
5 changes: 5 additions & 0 deletions internal/settings/merge.go
Comment thread
jesseworld22 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ func Merge(existing, generated []byte) (*MergeResult, error) {
// Merge hooks.PreToolUse — replace the chunk-managed hook group by matcher.
mergeHooks(merged, generatedMap)

// Merge hooks.Stop — replace the chunk-managed group by command. Without
// this a repo that already had a settings.json keeps its commit hooks but
// never gets the Stop hook, so validation stops running at session end.
mergeStopHooks(merged, generatedMap)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think you need a new function to merge the stop hooks, merge hooks just wasn't handling the case probably. i'd extend merge hooks to also include the stop hook merging as well.

@jesseworld22 jesseworld22 Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from claude:

done in adbf765 - mergeStopHooks is gone and mergeHooks handles both hook types, so Merge and MergeCodex make one call instead of two.

kept the difference in how they merge, since it is not incidental: chunk owns the whole PreToolUse group (matched by matcher) but only a single Stop entry (matched by command), because a user may have their own entries in that same group and replacing the group would delete them. That is now documented on the one function instead of split across two, and the duplicated "create hooks if absent" block collapses into a hooksMap helper.

no behavior change — the existing tests pin both halves independently: six exercise generated settings carrying only PreToolUse, six only Stop, three both.


mergedBytes, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal merged settings: %w", err)
Expand Down
93 changes: 93 additions & 0 deletions internal/settings/merge_test.go
Comment thread
jesseworld22 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,99 @@ func TestMergePreservesOtherHookTypes(t *testing.T) {
assert.Equal(t, len(preToolUse), 1)
}

// A repo that already had a settings.json before chunk init ran gets the Stop
// hook added, not just PreToolUse. Without it the repo keeps its commit hooks
// but never validates at session end.
func TestMergeAddsStopHookToExistingSettings(t *testing.T) {
existing := []byte(`{
"permissions": {"allow": ["Bash(task *)"]},
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "if": "Bash(git commit*)", "command": "task test", "timeout": 300}]}
]
}
}`)
generated := []byte(`{
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "if": "Bash(git commit*)", "command": "cd . && task test", "timeout": 300}]}
],
"Stop": [{"hooks": [{"type": "command", "command": "chunk validate", "timeout": 330}]}]
}
}`)

result, err := Merge(existing, generated)
assert.NilError(t, err)
assert.Assert(t, result.Changed)

var merged map[string]interface{}
assert.NilError(t, json.Unmarshal(result.Merged, &merged))

hooks := merged["hooks"].(map[string]interface{})
stop, ok := hooks["Stop"].([]interface{})
assert.Assert(t, ok && len(stop) == 1, "expected a Stop hook group, got: %v", hooks["Stop"])

group := stop[0].(map[string]interface{})
entry := group["hooks"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, entry["command"], "chunk validate")
assert.Equal(t, entry["timeout"], float64(330))
}

// The chunk-managed Stop group is replaced in place so a changed timeout takes
// effect instead of stacking a second "chunk validate" entry.
func TestMergeReplacesStopHook(t *testing.T) {
existing := []byte(`{
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": "chunk validate", "timeout": 30}]}]
}
}`)
generated := []byte(`{
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": "chunk validate", "timeout": 600}]}]
}
}`)

result, err := Merge(existing, generated)
assert.NilError(t, err)

var merged map[string]interface{}
assert.NilError(t, json.Unmarshal(result.Merged, &merged))

hooks := merged["hooks"].(map[string]interface{})
stop := hooks["Stop"].([]interface{})
assert.Equal(t, len(stop), 1)

group := stop[0].(map[string]interface{})
entry := group["hooks"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, entry["timeout"], float64(600))
}

// A Stop hook the user wrote themselves is left alone; chunk only manages its own.
func TestMergePreservesUserStopHooks(t *testing.T) {
existing := []byte(`{
"hooks": {
"Stop": [
{"hooks": [{"type": "command", "command": "notify-send done", "timeout": 5}]}
]
}
}`)
generated := []byte(`{
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": "chunk validate", "timeout": 600}]}]
}
}`)

result, err := Merge(existing, generated)
assert.NilError(t, err)

var merged map[string]interface{}
assert.NilError(t, json.Unmarshal(result.Merged, &merged))

hooks := merged["hooks"].(map[string]interface{})
stop, ok := hooks["Stop"].([]interface{})
assert.Assert(t, ok && len(stop) == 2, "expected both Stop groups to be present, got: %v", len(stop))
}

func TestMergeNoChangeWhenAlreadyMerged(t *testing.T) {
settings := []byte(`{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
Expand Down