Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33b1b2370b | ||
|
|
bb963169dd | ||
|
|
e5536f9cce | ||
|
|
59c48a36cb | ||
|
|
272ce89ec5 | ||
|
|
80e93e9e15 | ||
|
|
f8d3199bde | ||
|
|
480aebfcd0 | ||
|
|
57bb1c4b62 | ||
|
|
a7a561fbbd |
@@ -0,0 +1,65 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: cpu
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Build cross-platform binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
build() {
|
||||
os=$1
|
||||
label=$2
|
||||
goarch=$3
|
||||
echo "building dist/shush-${os}-${label}"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$goarch" go build -ldflags="-w -s" -o "dist/shush-${os}-${label}" ./cmd/shush
|
||||
}
|
||||
build linux x86_64 amd64
|
||||
build linux aarch64 arm64
|
||||
build darwin x86_64 amd64
|
||||
build darwin aarch64 arm64
|
||||
ls -la dist
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v tea >/dev/null 2>&1; then
|
||||
echo "tea CLI not installed on runner; install from https://dl.gitea.com/tea/"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TAG:-}" ]; then
|
||||
echo "no tag ref; run this workflow from a v* tag"
|
||||
exit 1
|
||||
fi
|
||||
tea login delete ci >/dev/null 2>&1 || true
|
||||
tea login add --name ci --url https://git.mroberts.dev --token "$GITEA_TOKEN"
|
||||
# Idempotent: drop any existing release for this tag so re-runs replace
|
||||
# stale assets instead of failing with "already a release for this tag".
|
||||
tea releases delete -y --login ci --repo mroberts/sush "$TAG" >/dev/null 2>&1 || true
|
||||
tea releases create \
|
||||
--login ci \
|
||||
--repo mroberts/sush \
|
||||
--tag "$TAG" \
|
||||
--title "$TAG" \
|
||||
--asset dist/shush-linux-x86_64 \
|
||||
--asset dist/shush-linux-aarch64 \
|
||||
--asset dist/shush-darwin-x86_64 \
|
||||
--asset dist/shush-darwin-aarch64
|
||||
@@ -136,6 +136,8 @@ Smart comment preservation and seamless Claude Code integration:
|
||||
- String-aware parsing preserves URLs and code in strings ✅
|
||||
- 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
|
||||
|
||||
### Common Development Workflow
|
||||
|
||||
@@ -85,8 +85,8 @@ tag: ## Create and push git tag for current version
|
||||
@echo "Run 'make push' to push tag and trigger release"
|
||||
|
||||
push: ## Push current branch and tags
|
||||
git push upstream
|
||||
git push upstream --tags
|
||||
git push origin
|
||||
git push origin --tags
|
||||
|
||||
# Utility commands
|
||||
clean: ## Clean build artifacts
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/carlosarraes/shush/internal/types"
|
||||
)
|
||||
|
||||
var version = "0.3.3"
|
||||
var version = "0.6.1"
|
||||
|
||||
func main() {
|
||||
var cliArgs types.CLI
|
||||
|
||||
+89
-22
@@ -1,8 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
@@ -22,11 +24,73 @@ func Default() *Config {
|
||||
"XXX:",
|
||||
"@ts-ignore",
|
||||
"@ts-expect-error",
|
||||
"@ts-nocheck",
|
||||
"eslint-",
|
||||
"prettier-ignore",
|
||||
"biome-ignore",
|
||||
"deno-lint-ignore",
|
||||
"stylelint-",
|
||||
"istanbul ignore",
|
||||
"c8 ignore",
|
||||
"v8 ignore",
|
||||
"type: ignore",
|
||||
"pylint:",
|
||||
"mypy:",
|
||||
"type: ignore",
|
||||
"pyright:",
|
||||
"ruff:",
|
||||
"flake8:",
|
||||
"noqa",
|
||||
"nosec",
|
||||
"noinspection",
|
||||
"pragma: no cover",
|
||||
"fmt: off",
|
||||
"fmt: on",
|
||||
"pyre-ignore",
|
||||
"pyre-fixme",
|
||||
"isort:",
|
||||
"nolint",
|
||||
"go:",
|
||||
"lint:ignore",
|
||||
"rubocop:",
|
||||
"frozen_string_literal",
|
||||
"NOLINT",
|
||||
"clang-format",
|
||||
"cppcheck-suppress",
|
||||
"IWYU pragma",
|
||||
"ReSharper",
|
||||
"CHECKSTYLE",
|
||||
"NOPMD",
|
||||
"ktlint-disable",
|
||||
"swiftlint:",
|
||||
"ignore_for_file",
|
||||
"phpstan-",
|
||||
"psalm-",
|
||||
"phpcs:",
|
||||
"@phan",
|
||||
"scalafix:",
|
||||
"scalastyle:",
|
||||
"luacheck:",
|
||||
"selene:",
|
||||
"shellcheck",
|
||||
"hadolint",
|
||||
"tflint",
|
||||
"tfsec:",
|
||||
"zizmor:",
|
||||
"checkov:",
|
||||
"yamllint",
|
||||
"{-#",
|
||||
"HLINT",
|
||||
"credo:",
|
||||
"solhint-",
|
||||
"slither-disable",
|
||||
"buf:lint",
|
||||
"@generated",
|
||||
"DO NOT EDIT",
|
||||
`/^#\s*v\d+(\.\d+)*$/`,
|
||||
`/^\/\*\*/`,
|
||||
`/^\/\*!/`,
|
||||
`/^\/\/\//`,
|
||||
`/^\/\/!/`,
|
||||
},
|
||||
ContextLines: 3,
|
||||
}
|
||||
@@ -142,6 +206,14 @@ func (c *Config) ShouldPreserveComment(comment string) bool {
|
||||
|
||||
func matchesPattern(text, pattern string) bool {
|
||||
|
||||
if len(pattern) >= 2 && strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
|
||||
re, err := regexp.Compile(pattern[1 : len(pattern)-1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return re.MatchString(text)
|
||||
}
|
||||
|
||||
if strings.Contains(pattern, "*") {
|
||||
return matchesWildcard(text, pattern)
|
||||
}
|
||||
@@ -189,27 +261,22 @@ func matchesWildcard(text, pattern string) bool {
|
||||
}
|
||||
|
||||
func CreateExampleConfig() error {
|
||||
content := `# Shush Configuration
|
||||
# Patterns to preserve in comments (supports wildcards with *)
|
||||
preserve = [
|
||||
"TODO:",
|
||||
"FIXME:",
|
||||
"HACK:",
|
||||
"XXX:",
|
||||
"@ts-ignore",
|
||||
"@ts-expect-error",
|
||||
"eslint-",
|
||||
"prettier-ignore",
|
||||
"pylint:",
|
||||
"mypy:",
|
||||
"type: ignore",
|
||||
"*IMPORTANT*", # Example wildcard: preserves any comment containing IMPORTANT
|
||||
"*DEBUG*", # Example wildcard: preserves any comment containing DEBUG
|
||||
]
|
||||
def := Default()
|
||||
|
||||
# Number of context lines to show around changes in preview mode (default: 3)
|
||||
context_lines = 3
|
||||
`
|
||||
var b strings.Builder
|
||||
b.WriteString("# Shush Configuration\n")
|
||||
b.WriteString("# Patterns to preserve in comments (substring, * wildcards, or /regex/)\n")
|
||||
b.WriteString("# Defaults below cover common lint/tool directives for supported languages.\n")
|
||||
b.WriteString("preserve = [\n")
|
||||
for _, p := range def.Preserve {
|
||||
fmt.Fprintf(&b, " %q,\n", p)
|
||||
}
|
||||
b.WriteString(" # Wildcards (*) preserve any comment containing the pattern:\n")
|
||||
b.WriteString(" \"*IMPORTANT*\",\n")
|
||||
b.WriteString(" \"*DEBUG*\",\n")
|
||||
b.WriteString("]\n\n")
|
||||
b.WriteString("# Number of context lines to show around changes in preview mode\n")
|
||||
fmt.Fprintf(&b, "context_lines = %d\n", def.ContextLines)
|
||||
|
||||
return os.WriteFile(".shush.toml", []byte(content), 0644)
|
||||
return os.WriteFile(".shush.toml", []byte(b.String()), 0644)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreateExampleConfigRoundTrips(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
if err := CreateExampleConfig(); err != nil {
|
||||
t.Fatalf("create example config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := loadFromFile(".shush.toml")
|
||||
if err != nil {
|
||||
t.Fatalf("parse generated config: %v", err)
|
||||
}
|
||||
|
||||
def := Default()
|
||||
have := make(map[string]bool, len(cfg.Preserve))
|
||||
for _, p := range cfg.Preserve {
|
||||
have[p] = true
|
||||
}
|
||||
for _, p := range def.Preserve {
|
||||
if !have[p] {
|
||||
t.Errorf("generated config missing default preserve pattern %q", p)
|
||||
}
|
||||
}
|
||||
if cfg.ContextLines != def.ContextLines {
|
||||
t.Errorf("context_lines = %d, want %d", cfg.ContextLines, def.ContextLines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPreserveDirectives(t *testing.T) {
|
||||
cfg := Default()
|
||||
directives := []string{
|
||||
"//nolint:errcheck",
|
||||
"//go:build linux",
|
||||
"# noqa: E501",
|
||||
"# type: ignore",
|
||||
"# rubocop:disable Style/Foo",
|
||||
"# frozen_string_literal: true",
|
||||
"{-# LANGUAGE OverloadedStrings #-}",
|
||||
"// @phpstan-ignore-line",
|
||||
"-- luacheck: ignore",
|
||||
"// NOPMD",
|
||||
"// ReSharper disable once Foo",
|
||||
"// ignore_for_file: foo",
|
||||
"# credo:disable-for-next-line",
|
||||
"// solhint-disable-next-line",
|
||||
"# yamllint disable-line",
|
||||
"# zizmor: ignore[template-injection] trusted infra-generated command",
|
||||
}
|
||||
for _, d := range directives {
|
||||
if !cfg.ShouldPreserveComment(d) {
|
||||
t.Errorf("directive not preserved by default: %q", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPreserveVersionPins(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
preserved := []string{
|
||||
"# v2.1.6",
|
||||
"# v4",
|
||||
"# v2.1",
|
||||
"#v1.0.0",
|
||||
}
|
||||
for _, c := range preserved {
|
||||
if !cfg.ShouldPreserveComment(c) {
|
||||
t.Errorf("version pin not preserved by default: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
stripped := []string{
|
||||
"# validate.this prose",
|
||||
"# a regular note comment",
|
||||
"# version bump",
|
||||
"# 2.1.6",
|
||||
"# v2 release notes",
|
||||
}
|
||||
for _, c := range stripped {
|
||||
if cfg.ShouldPreserveComment(c) {
|
||||
t.Errorf("non-version comment wrongly preserved: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPreserveDocComments(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
preserved := []string{
|
||||
"/**\n * JSDoc block\n * @param x foo\n */",
|
||||
"/** single-line doc */",
|
||||
"/*! license banner */",
|
||||
"/// Rust or C# doc comment",
|
||||
"//! Rust inner doc comment",
|
||||
}
|
||||
for _, c := range preserved {
|
||||
if !cfg.ShouldPreserveComment(c) {
|
||||
t.Errorf("doc comment not preserved by default: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
stripped := []string{
|
||||
"/* plain block comment */",
|
||||
"// plain line comment",
|
||||
"# hash comment",
|
||||
"/*\n * multi-line non-doc block\n */",
|
||||
}
|
||||
for _, c := range stripped {
|
||||
if cfg.ShouldPreserveComment(c) {
|
||||
t.Errorf("non-doc comment wrongly preserved: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -109,9 +109,9 @@ func GetUnstagedChanges() ([]FileChange, error) {
|
||||
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
|
||||
var cmd *exec.Cmd
|
||||
if staged {
|
||||
cmd = exec.Command("git", "diff", "--cached", "--unified=0", file)
|
||||
cmd = exec.Command("git", "diff", "--no-ext-diff", "--cached", "--unified=0", file)
|
||||
} else {
|
||||
cmd = exec.Command("git", "diff", "--unified=0", file)
|
||||
cmd = exec.Command("git", "diff", "--no-ext-diff", "--unified=0", file)
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runGit(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLineRangesFromDiffIgnoresExternalDiff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runGit(t, dir, "init", "-q")
|
||||
runGit(t, dir, "config", "user.email", "t@t")
|
||||
runGit(t, dir, "config", "user.name", "t")
|
||||
runGit(t, dir, "config", "diff.external", "true")
|
||||
|
||||
path := filepath.Join(dir, "f.py")
|
||||
if err := os.WriteFile(path, []byte("a = 1\nb = 2\nc = 3\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, dir, "add", "f.py")
|
||||
runGit(t, dir, "commit", "-qm", "init")
|
||||
|
||||
if err := os.WriteFile(path, []byte("a = 1\nb = 2\nc = 3 changed\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(wd)
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ranges, err := getLineRangesFromDiff("f.py", false)
|
||||
if err != nil {
|
||||
t.Fatalf("getLineRangesFromDiff: %v", err)
|
||||
}
|
||||
|
||||
want := []LineRange{{Start: 3, End: 3}}
|
||||
if len(ranges) != len(want) || ranges[0] != want[0] {
|
||||
t.Fatalf("ranges = %v, want %v (external diff not bypassed)", ranges, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGitDiffUnified(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -125,7 +125,7 @@ func SaveSettings(path string, settings *ClaudeSettings) error {
|
||||
func CreateShushHookEntry() HookEntry {
|
||||
return HookEntry{
|
||||
Type: "command",
|
||||
Command: "shush --changes-only",
|
||||
Command: "shush --changes-only --hook-output",
|
||||
Timeout: 5,
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func AddShushHook(settings *ClaudeSettings) error {
|
||||
|
||||
shushHook := map[string]interface{}{
|
||||
"type": "command",
|
||||
"command": "shush --changes-only",
|
||||
"command": "shush --changes-only --hook-output",
|
||||
"timeout": 5,
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,16 @@ func changed(changes []LineChange) bool {
|
||||
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) {
|
||||
var infos []changeInfo
|
||||
changedCount, keptCount, preservedCount := 0, 0, 0
|
||||
|
||||
@@ -115,3 +115,45 @@ func TestStripComments(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripCommentsPreservesDirectives(t *testing.T) {
|
||||
def := config.Default()
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
src string
|
||||
}{
|
||||
{"go nolint", "a.go", "x := 1 //nolint:errcheck\n"},
|
||||
{"go build directive", "a.go", "//go:build linux\npackage main\n"},
|
||||
{"python noqa", "a.py", "x = 1 # noqa: E501\n"},
|
||||
{"python type ignore", "a.py", "y = 2 # type: ignore\n"},
|
||||
{"python nosec", "a.py", "p = 1 # nosec B105\n"},
|
||||
{"js eslint disable", "a.js", "const a = 1 // eslint-disable-line\n"},
|
||||
{"ts expect error", "a.ts", "const b = 1 // @ts-expect-error\n"},
|
||||
{"c clang nolint", "a.c", "int x; // NOLINT(cert-err58-cpp)\n"},
|
||||
{"shell shellcheck", "a.sh", "y=1 # shellcheck disable=SC2086\n"},
|
||||
{"ruby rubocop", "a.rb", "x = 1 # rubocop:disable Style/Foo\n"},
|
||||
{"ruby frozen string", "a.rb", "# frozen_string_literal: true\nx = 1\n"},
|
||||
{"haskell pragma", "a.hs", "{-# LANGUAGE OverloadedStrings #-}\nmodule M where\n"},
|
||||
{"php phpstan", "a.php", "<?php\n$x = 1; // @phpstan-ignore-line\n"},
|
||||
{"lua luacheck", "a.lua", "local x = 1 -- luacheck: ignore\n"},
|
||||
{"java nopmd", "a.java", "int x = 1; // NOPMD\n"},
|
||||
{"csharp resharper", "a.cs", "int x = 1; // ReSharper disable once Foo\n"},
|
||||
{"dart ignore for file", "a.dart", "// ignore_for_file: foo\nvar x = 1;\n"},
|
||||
{"elixir credo", "a.ex", "x = 1 # credo:disable-for-next-line\n"},
|
||||
{"solidity solhint", "a.sol", "uint x; // solhint-disable-next-line\n"},
|
||||
{"kotlin ktlint", "a.kt", "val x = 1 // ktlint-disable\n"},
|
||||
{"yaml yamllint", "a.yml", "x: 1 # yamllint disable-line\n"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out, _, err := StripComments([]byte(c.src), c.file, def, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if string(out) != c.src {
|
||||
t.Errorf("directive not preserved:\ngot %q\nwant %q", out, c.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -53,7 +54,9 @@ func (p *Processor) processGitChanges() error {
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
if !p.cli.HookOutput {
|
||||
fmt.Println("No changes found to process")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,7 +74,9 @@ func (p *Processor) processGitChanges() error {
|
||||
}
|
||||
|
||||
if len(supportedChanges) == 0 {
|
||||
if !p.cli.HookOutput {
|
||||
fmt.Println("No supported files found to process")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,7 +106,7 @@ func (p *Processor) processGitChanges() error {
|
||||
continue
|
||||
}
|
||||
} 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)
|
||||
continue
|
||||
}
|
||||
@@ -113,10 +118,44 @@ func (p *Processor) processGitChanges() error {
|
||||
p.showGitTotals(totals)
|
||||
}
|
||||
|
||||
if p.cli.HookOutput {
|
||||
if out, ok := buildHookOutput(totals.TotalChanged); ok {
|
||||
fmt.Println(out)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -150,6 +189,7 @@ func (p *Processor) processFileWithLineRanges(filename string, lineRanges []git.
|
||||
if err := os.WriteFile(filename, out, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
totals.TotalChanged += countRemoved(changes)
|
||||
if p.cli.Verbose {
|
||||
fmt.Printf("✓ Comments removed from %s\n", filename)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,65 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/carlosarraes/shush/internal/config"
|
||||
"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) {
|
||||
cli := types.CLI{}
|
||||
p := &Processor{cli: cli}
|
||||
|
||||
@@ -38,7 +38,7 @@ func runMultilineCase(t *testing.T, tc multilineCase) {
|
||||
}
|
||||
p := &Processor{cli: tc.cli}
|
||||
if tc.gitMode {
|
||||
err = p.processFileWithLineRanges(work, tc.ranges, cfg)
|
||||
err = p.processFileWithLineRanges(work, tc.ranges, cfg, &GitTotals{})
|
||||
} else {
|
||||
lang, derr := DetectLanguage(work)
|
||||
if derr != nil {
|
||||
@@ -87,6 +87,8 @@ func TestMultiline(t *testing.T) {
|
||||
multilineCase{name: "marker in line comment", file: "marker_in_line.c"},
|
||||
multilineCase{name: "unterminated", file: "unterminated.c"},
|
||||
multilineCase{name: "conf fallback", file: "fallback.conf"},
|
||||
multilineCase{name: "doc comments js", file: "doc_js.js"},
|
||||
multilineCase{name: "doc comments rust", file: "doc_rs.rs"},
|
||||
multilineCase{name: "git straddle kept", file: "straddle.js",
|
||||
gitMode: true, ranges: []git.LineRange{{Start: 4, End: 4}}},
|
||||
multilineCase{name: "git range removes inside block", file: "range_inside.js",
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Adds two numbers.
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number}
|
||||
*/
|
||||
function add(a, b) {
|
||||
/*
|
||||
* plain block comment
|
||||
*/
|
||||
return a + b; // plain line comment
|
||||
}
|
||||
|
||||
/*! license banner */
|
||||
/** single-line doc */
|
||||
function noop() {}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Adds two numbers.
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number}
|
||||
*/
|
||||
function add(a, b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
/*! license banner */
|
||||
/** single-line doc */
|
||||
function noop() {}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Crate-level docs.
|
||||
|
||||
/// Adds two numbers.
|
||||
/// Returns the sum.
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
// plain line comment
|
||||
/* plain block comment */
|
||||
a + b
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Crate-level docs.
|
||||
|
||||
/// Adds two numbers.
|
||||
/// Returns the sum.
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
a + b
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func runWholeFileCase(t *testing.T, tc wholeFileCase) {
|
||||
|
||||
p := &Processor{cli: tc.cli}
|
||||
if tc.gitMode {
|
||||
err = p.processFileWithLineRanges(work, tc.ranges, cfg)
|
||||
err = p.processFileWithLineRanges(work, tc.ranges, cfg, &GitTotals{})
|
||||
} else {
|
||||
lang, derr := DetectLanguage(work)
|
||||
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"`
|
||||
LLM bool `help:"Show LLM-friendly usage guide"`
|
||||
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"`
|
||||
Unstaged bool `help:"Remove comments only from unstaged git changes"`
|
||||
InstallHook bool `help:"Install Claude Code hooks for automatic comment cleanup"`
|
||||
|
||||
Reference in New Issue
Block a user