Compare commits

..
10 Commits
Author SHA1 Message Date
mroberts 33b1b2370b ci(release): make release idempotent; bump to 0.6.1
CI / build (push) Successful in 1m34s
tea releases create fails if a release already exists, leaving stale
binaries on tag re-push. Delete any existing release for the tag first
so re-runs self-heal.
2026-07-17 12:54:00 -05:00
mroberts bb963169dd 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.
2026-07-17 12:42:41 -05:00
mroberts e5536f9cce chore: bump version to 0.5.0
Also point make push at origin; upstream remote does not exist.
2026-07-08 13:24:29 -05:00
mroberts 59c48a36cb feat(config): preserve doc-style comments by default
Strip pass was eating JSDoc/Javadoc blocks. Add default preserve
patterns for /** blocks, /*! banners, /// and //! line docs.
Opt out by overriding preserve in .shush.toml.
2026-07-08 13:24:29 -05:00
mroberts 272ce89ec5 fix(git): bypass external diff driver; preserve version pins and zizmor
- git: pass --no-ext-diff so difftastic/other diff.external drivers don't
  break --unified=0 parsing (was yielding zero ranges -> whole-file strip)
- config: add /regex/ preserve patterns; keep version pins (# v2.1.6) and
  zizmor: directives by default
- bump version to 0.4.2
2026-07-03 12:49:46 -05:00
mroberts 80e93e9e15 ci: make tea login idempotent (delete stale login before add) 2026-07-03 09:23:37 -05:00
mroberts f8d3199bde chore: bump version to 0.4.1 2026-07-03 09:16:03 -05:00
mroberts 480aebfcd0 feat(config): preserve lint/tool directives by default across supported languages 2026-07-03 09:00:21 -05:00
mroberts 57bb1c4b62 chore: bump version to 0.4.0 2026-07-02 09:54:51 -05:00
mroberts a7a561fbbd ci: add Gitea release workflow using tea CLI 2026-07-02 09:20:06 -05:00
20 changed files with 528 additions and 35 deletions
+65
View File
@@ -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
+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
+2 -2
View File
@@ -85,8 +85,8 @@ tag: ## Create and push git tag for current version
@echo "Run 'make push' to push tag and trigger release" @echo "Run 'make push' to push tag and trigger release"
push: ## Push current branch and tags push: ## Push current branch and tags
git push upstream git push origin
git push upstream --tags git push origin --tags
# Utility commands # Utility commands
clean: ## Clean build artifacts clean: ## Clean build artifacts
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/carlosarraes/shush/internal/types" "github.com/carlosarraes/shush/internal/types"
) )
var version = "0.3.3" var version = "0.6.1"
func main() { func main() {
var cliArgs types.CLI var cliArgs types.CLI
+89 -22
View File
@@ -1,8 +1,10 @@
package config package config
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
@@ -22,11 +24,73 @@ func Default() *Config {
"XXX:", "XXX:",
"@ts-ignore", "@ts-ignore",
"@ts-expect-error", "@ts-expect-error",
"@ts-nocheck",
"eslint-", "eslint-",
"prettier-ignore", "prettier-ignore",
"biome-ignore",
"deno-lint-ignore",
"stylelint-",
"istanbul ignore",
"c8 ignore",
"v8 ignore",
"type: ignore",
"pylint:", "pylint:",
"mypy:", "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, ContextLines: 3,
} }
@@ -142,6 +206,14 @@ func (c *Config) ShouldPreserveComment(comment string) bool {
func matchesPattern(text, pattern 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, "*") { if strings.Contains(pattern, "*") {
return matchesWildcard(text, pattern) return matchesWildcard(text, pattern)
} }
@@ -189,27 +261,22 @@ func matchesWildcard(text, pattern string) bool {
} }
func CreateExampleConfig() error { func CreateExampleConfig() error {
content := `# Shush Configuration def := Default()
# 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
]
# Number of context lines to show around changes in preview mode (default: 3) var b strings.Builder
context_lines = 3 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)
} }
+116
View File
@@ -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
View File
@@ -109,9 +109,9 @@ func GetUnstagedChanges() ([]FileChange, error) {
func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) { func getLineRangesFromDiff(file string, staged bool) ([]LineRange, error) {
var cmd *exec.Cmd var cmd *exec.Cmd
if staged { if staged {
cmd = exec.Command("git", "diff", "--cached", "--unified=0", file) cmd = exec.Command("git", "diff", "--no-ext-diff", "--cached", "--unified=0", file)
} else { } else {
cmd = exec.Command("git", "diff", "--unified=0", file) cmd = exec.Command("git", "diff", "--no-ext-diff", "--unified=0", file)
} }
output, err := cmd.Output() output, err := cmd.Output()
+50
View File
@@ -1,9 +1,59 @@
package git package git
import ( import (
"os"
"os/exec"
"path/filepath"
"testing" "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) { func TestParseGitDiffUnified(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+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
View File
@@ -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)
}
})
}
}
+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}
+3 -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 {
@@ -87,6 +87,8 @@ func TestMultiline(t *testing.T) {
multilineCase{name: "marker in line comment", file: "marker_in_line.c"}, multilineCase{name: "marker in line comment", file: "marker_in_line.c"},
multilineCase{name: "unterminated", file: "unterminated.c"}, multilineCase{name: "unterminated", file: "unterminated.c"},
multilineCase{name: "conf fallback", file: "fallback.conf"}, 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", multilineCase{name: "git straddle kept", file: "straddle.js",
gitMode: true, ranges: []git.LineRange{{Start: 4, End: 4}}}, gitMode: true, ranges: []git.LineRange{{Start: 4, End: 4}}},
multilineCase{name: "git range removes inside block", file: "range_inside.js", multilineCase{name: "git range removes inside block", file: "range_inside.js",
+16
View File
@@ -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() {}
+13
View File
@@ -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() {}
+9
View File
@@ -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
}
+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"`