feat(hooks): add --hook-output flag reporting removed comments

Emits Claude Code PostToolUse JSON so automated comment removal is
announced in-context instead of appearing as a silent mutation.
--install-hook now writes 'shush --changes-only --hook-output'.

Bump version to 0.6.0.
This commit is contained in:
2026-07-17 12:42:41 -05:00
parent e5536f9cce
commit bb963169dd
9 changed files with 115 additions and 9 deletions
+2
View File
@@ -136,6 +136,8 @@ Smart comment preservation and seamless Claude Code integration:
- String-aware parsing preserves URLs and code in strings ✅ - String-aware parsing preserves URLs and code in strings ✅
- Automatic comment cleanup with configurable preservation ✅ - Automatic comment cleanup with configurable preservation ✅
**Note for AI agents (Claude Code):** When the shush hook is installed it runs `shush --changes-only --hook-output` after every `Write`/`Edit`/`MultiEdit`, so comments you just wrote may be stripped. This is intentional automated cleanup, not lost work or an error — do not re-add the removed comments. Keep important comments by adding preserve patterns to `.shush.toml`. The `--hook-output` flag makes shush emit a Claude Code PostToolUse JSON message reporting how many comments were removed, so the removal is announced in-context rather than appearing as a silent mutation.
## Development Notes ## Development Notes
### Common Development Workflow ### Common Development Workflow
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/carlosarraes/shush/internal/types" "github.com/carlosarraes/shush/internal/types"
) )
var version = "0.5.0" var version = "0.6.0"
func main() { func main() {
var cliArgs types.CLI var cliArgs types.CLI
+2 -2
View File
@@ -125,7 +125,7 @@ func SaveSettings(path string, settings *ClaudeSettings) error {
func CreateShushHookEntry() HookEntry { func CreateShushHookEntry() HookEntry {
return HookEntry{ return HookEntry{
Type: "command", Type: "command",
Command: "shush --changes-only", Command: "shush --changes-only --hook-output",
Timeout: 5, Timeout: 5,
} }
} }
@@ -208,7 +208,7 @@ func AddShushHook(settings *ClaudeSettings) error {
shushHook := map[string]interface{}{ shushHook := map[string]interface{}{
"type": "command", "type": "command",
"command": "shush --changes-only", "command": "shush --changes-only --hook-output",
"timeout": 5, "timeout": 5,
} }
+10
View File
@@ -226,6 +226,16 @@ func changed(changes []LineChange) bool {
return false return false
} }
func countRemoved(changes []LineChange) int {
n := 0
for _, c := range changes {
if c.Action == "modified" || c.Action == "removed" {
n++
}
}
return n
}
func changesToInfos(changes []LineChange) ([]changeInfo, int, int, int) { func changesToInfos(changes []LineChange) ([]changeInfo, int, int, int) {
var infos []changeInfo var infos []changeInfo
changedCount, keptCount, preservedCount := 0, 0, 0 changedCount, keptCount, preservedCount := 0, 0, 0
+42 -2
View File
@@ -1,6 +1,7 @@
package processor package processor
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -53,7 +54,9 @@ func (p *Processor) processGitChanges() error {
} }
if len(changes) == 0 { if len(changes) == 0 {
if !p.cli.HookOutput {
fmt.Println("No changes found to process") fmt.Println("No changes found to process")
}
return nil return nil
} }
@@ -71,7 +74,9 @@ func (p *Processor) processGitChanges() error {
} }
if len(supportedChanges) == 0 { if len(supportedChanges) == 0 {
if !p.cli.HookOutput {
fmt.Println("No supported files found to process") fmt.Println("No supported files found to process")
}
return nil return nil
} }
@@ -101,7 +106,7 @@ func (p *Processor) processGitChanges() error {
continue continue
} }
} else { } else {
if err := p.processFileWithLineRanges(change.Path, change.LineRanges, cfg); err != nil { if err := p.processFileWithLineRanges(change.Path, change.LineRanges, cfg, totals); err != nil {
fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", change.Path, err) fmt.Fprintf(os.Stderr, "Error processing %s: %v\n", change.Path, err)
continue continue
} }
@@ -113,10 +118,44 @@ func (p *Processor) processGitChanges() error {
p.showGitTotals(totals) p.showGitTotals(totals)
} }
if p.cli.HookOutput {
if out, ok := buildHookOutput(totals.TotalChanged); ok {
fmt.Println(out)
}
}
return nil return nil
} }
func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.LineRange, cfg *config.Config) error { type hookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
}
type hookOutput struct {
HookSpecificOutput hookSpecificOutput `json:"hookSpecificOutput"`
}
func buildHookOutput(removed int) (string, bool) {
if removed <= 0 {
return "", false
}
plural := ""
if removed != 1 {
plural = "s"
}
msg := fmt.Sprintf("shush removed %d comment%s from files just edited, per this project's comment policy. This is expected automated cleanup, not an error or lost work. Do not re-add the removed comments; to keep specific comments, add preserve patterns to .shush.toml.", removed, plural)
b, err := json.Marshal(hookOutput{hookSpecificOutput{
HookEventName: "PostToolUse",
AdditionalContext: msg,
}})
if err != nil {
return "", false
}
return string(b), true
}
func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.LineRange, cfg *config.Config, totals *GitTotals) error {
src, err := os.ReadFile(filename) src, err := os.ReadFile(filename)
if err != nil { if err != nil {
return err return err
@@ -150,6 +189,7 @@ func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.
if err := os.WriteFile(filename, out, 0644); err != nil { if err := os.WriteFile(filename, out, 0644); err != nil {
return err return err
} }
totals.TotalChanged += countRemoved(changes)
if p.cli.Verbose { if p.cli.Verbose {
fmt.Printf("✓ Comments removed from %s\n", filename) fmt.Printf("✓ Comments removed from %s\n", filename)
} }
+53
View File
@@ -1,12 +1,65 @@
package processor package processor
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/carlosarraes/shush/internal/config" "github.com/carlosarraes/shush/internal/config"
"github.com/carlosarraes/shush/internal/types" "github.com/carlosarraes/shush/internal/types"
) )
func TestCountRemoved(t *testing.T) {
changes := []LineChange{
{Action: "removed"},
{Action: "modified"},
{Action: "preserved"},
{Action: "kept"},
{Action: ""},
}
if got := countRemoved(changes); got != 2 {
t.Errorf("countRemoved() = %d, want 2", got)
}
if got := countRemoved(nil); got != 0 {
t.Errorf("countRemoved(nil) = %d, want 0", got)
}
}
func TestBuildHookOutput(t *testing.T) {
if _, ok := buildHookOutput(0); ok {
t.Error("buildHookOutput(0) should be silent (ok=false)")
}
if _, ok := buildHookOutput(-1); ok {
t.Error("buildHookOutput(-1) should be silent (ok=false)")
}
out, ok := buildHookOutput(3)
if !ok {
t.Fatal("buildHookOutput(3) should emit (ok=true)")
}
var parsed struct {
HookSpecificOutput struct {
HookEventName string `json:"hookEventName"`
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed.HookSpecificOutput.HookEventName != "PostToolUse" {
t.Errorf("hookEventName = %q, want PostToolUse", parsed.HookSpecificOutput.HookEventName)
}
if !strings.Contains(parsed.HookSpecificOutput.AdditionalContext, "3 comments") {
t.Errorf("context missing count: %q", parsed.HookSpecificOutput.AdditionalContext)
}
single, _ := buildHookOutput(1)
if !strings.Contains(single, "1 comment ") {
t.Errorf("singular form wrong: %q", single)
}
}
func TestRemoveCommentsFromLine(t *testing.T) { func TestRemoveCommentsFromLine(t *testing.T) {
cli := types.CLI{} cli := types.CLI{}
p := &Processor{cli: cli} p := &Processor{cli: cli}
+1 -1
View File
@@ -38,7 +38,7 @@ func runMultilineCase(t *testing.T, tc multilineCase) {
} }
p := &Processor{cli: tc.cli} p := &Processor{cli: tc.cli}
if tc.gitMode { if tc.gitMode {
err = p.processFileWithLineRanges(work, tc.ranges, cfg) err = p.processFileWithLineRanges(work, tc.ranges, cfg, &GitTotals{})
} else { } else {
lang, derr := DetectLanguage(work) lang, derr := DetectLanguage(work)
if derr != nil { if derr != nil {
+1 -1
View File
@@ -46,7 +46,7 @@ func runWholeFileCase(t *testing.T, tc wholeFileCase) {
p := &Processor{cli: tc.cli} p := &Processor{cli: tc.cli}
if tc.gitMode { if tc.gitMode {
err = p.processFileWithLineRanges(work, tc.ranges, cfg) err = p.processFileWithLineRanges(work, tc.ranges, cfg, &GitTotals{})
} else { } else {
lang, derr := DetectLanguage(work) lang, derr := DetectLanguage(work)
if derr != nil { if derr != nil {
+1
View File
@@ -14,6 +14,7 @@ type CLI struct {
ContextLines int `short:"c" help:"Number of context lines to show in preview mode (default: from config)" default:"-1"` ContextLines int `short:"c" help:"Number of context lines to show in preview mode (default: from config)" default:"-1"`
LLM bool `help:"Show LLM-friendly usage guide"` LLM bool `help:"Show LLM-friendly usage guide"`
ChangesOnly bool `help:"Remove comments only from git changes (staged + unstaged + untracked)"` ChangesOnly bool `help:"Remove comments only from git changes (staged + unstaged + untracked)"`
HookOutput bool `help:"Emit Claude Code PostToolUse JSON reporting removed comments (for use in hooks)"`
Staged bool `help:"Remove comments only from staged git changes"` Staged bool `help:"Remove comments only from staged git changes"`
Unstaged bool `help:"Remove comments only from unstaged git changes"` Unstaged bool `help:"Remove comments only from unstaged git changes"`
InstallHook bool `help:"Install Claude Code hooks for automatic comment cleanup"` InstallHook bool `help:"Install Claude Code hooks for automatic comment cleanup"`