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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
Reference in New Issue
Block a user