Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ CGO_ENABLED=0 go build -o coauthor-cleaner ./cmd/coauthor-cleaner
## Quick start

```bash
coauthor-cleaner # same as: coauthor-cleaner fix
coauthor-cleaner fix --push # clean + push when safe
coauthor-cleaner # opens the TUI — scan, review, clean, and push
```

Already pushed to GitHub? `coauthor-cleaner fix --force --force-push`
Lazygit-style split-panel TUI: findings list (left), live preview (right), status bar, contextual footer. Full workflow: toggle, clean, push (`--force-with-lease` when needed). Press `?` for help.

Non-interactive: `coauthor-cleaner fix --push`

```bash
coauthor-cleaner config init
Expand All @@ -35,12 +36,12 @@ coauthor-cleaner hook install

| Command | Description |
|---------|-------------|
| `fix` | **Default** — auto-clean safe findings (`--push`, `--force`, `--check`) |
| *(default)* | **TUI** — scan, preview, clean, push (`review` / `tui` alias) |
| `fix` | Non-interactive auto-clean (`--push`, `--force`, `--check`) |
| `status` | Repo check + findings + next steps |
| `doctor` | Verify hooks, config, gh, git repo |
| `scan` | Report markers (`--file` / `--dir` work outside git) |
| `clean` | Remove markers (`--yes`; `--force` if commit was pushed) |
| `review` | Interactive Bubble Tea TUI |
| `ci` | PR range scan for GitHub Actions |
| `pr scan` / `pr clean` | Scan/clean current PR via `gh` |
| `hook install` | Install pre-commit + commit-msg hooks |
Expand Down
2 changes: 1 addition & 1 deletion cmd/coauthor-cleaner/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func handleHookFindings(g git.Runner, mode, msgFile string, findings []detect.Fi

fmt.Fprint(os.Stderr, scan.FormatText(findings))
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Run: coauthor-cleaner fix")
fmt.Fprintln(os.Stderr, "Run: coauthor-cleaner")
fmt.Fprintln(os.Stderr, "Or bypass with: git commit --no-verify")

if mode == "block" {
Expand Down
33 changes: 10 additions & 23 deletions cmd/coauthor-cleaner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"github.com/Skeyelab/coauthor-cleaner/internal/detect"
"github.com/Skeyelab/coauthor-cleaner/internal/git"
"github.com/Skeyelab/coauthor-cleaner/internal/scan"
"github.com/Skeyelab/coauthor-cleaner/internal/tui"
"github.com/spf13/cobra"
)

Expand All @@ -33,13 +32,13 @@ func main() {
Short: "Remove unwanted AI attribution from Git commits and staged changes",
Long: `Coauthor Cleaner finds and removes AI attribution markers in git repos.

Default (just run with no subcommand):
coauthor-cleaner fix # auto-clean safe findings
Default — interactive TUI (scan, review, clean, push):
coauthor-cleaner

Full pipeline with push:
Non-interactive automation:
coauthor-cleaner fix --push
coauthor-cleaner fix --force --force-push # if HEAD was already on GitHub`,
RunE: runFix,
coauthor-cleaner fix --force --force-push`,
RunE: runTUI,
}

root.AddCommand(fixCmd())
Expand Down Expand Up @@ -122,7 +121,7 @@ func runScan(cmd *cobra.Command, args []string) error {
return err
}
if len(result.Findings) > 0 {
fmt.Println("Tip: run coauthor-cleaner review to fix, or coauthor-cleaner status for full guidance.")
fmt.Println("Tip: run coauthor-cleaner to fix in the TUI, or coauthor-cleaner status for guidance.")
}
if flagFailFindings && len(result.Findings) > 0 {
return fmt.Errorf("found %d AI attribution marker(s)", len(result.Findings))
Expand Down Expand Up @@ -172,22 +171,10 @@ func cleanCmd() *cobra.Command {

func reviewCmd() *cobra.Command {
return &cobra.Command{
Use: "review",
Short: "Review and clean AI attribution markers interactively",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := gitRunner()
if err != nil {
return err
}
opts := scanOptsFromGit(g)
opts.Staged = true
opts.Commit = "HEAD"
result, err := scan.Run(g, opts)
if err != nil {
return err
}
return tui.Run(g, result.Findings)
},
Use: "review",
Aliases: []string{"tui"},
Short: "Interactive TUI (same as running coauthor-cleaner with no args)",
RunE: runTUI,
}
}

Expand Down
14 changes: 14 additions & 0 deletions cmd/coauthor-cleaner/tui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package main

import (
"github.com/Skeyelab/coauthor-cleaner/internal/tui"
"github.com/spf13/cobra"
)

func runTUI(cmd *cobra.Command, args []string) error {
g, err := gitRunner()
if err != nil {
return err
}
return tui.Run(g, scanOptsFromGit(g))
}
99 changes: 67 additions & 32 deletions internal/clean/clean_test.go
Original file line number Diff line number Diff line change
@@ -1,47 +1,82 @@
package clean

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/Skeyelab/coauthor-cleaner/internal/config"
"github.com/Skeyelab/coauthor-cleaner/internal/detect"
"github.com/Skeyelab/coauthor-cleaner/internal/git"
"github.com/Skeyelab/coauthor-cleaner/internal/scan"
"github.com/Skeyelab/coauthor-cleaner/internal/testutil"
)

func TestApply_BlocksAmendOfPushedCommit(t *testing.T) {
dir := t.TempDir()
run := func(args ...string) {
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)
}
}
run("init")
run("config", "user.email", "t@example.com")
run("config", "user.name", "T")
readme := filepath.Join(dir, "README.md")
os.WriteFile(readme, []byte("hi\n"), 0644)
run("add", "README.md")
run("commit", "-m", "init\n\nCo-authored-by: Claude <noreply@anthropic.com>")

func TestApply_StagedUnpushedCommit(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
// simulate pushed: set upstream to same commit
run("branch", "-M", "main")
run("remote", "add", "origin", dir) // local remote
run("push", "-u", "origin", "main")

findings := []detect.Finding{
{
Source: detect.SourceCommitTrailer, Selected: true,
Match: "Co-authored-by: Claude <noreply@anthropic.com>",
LineNumber: 3, RuleName: "ai-coauthor-trailer",
},
testutil.Write(t, dir, "README.md", "hi\n")
testutil.Git(t, dir, "add", "README.md")
testutil.Git(t, dir, "commit", "-m", "init\n\nCo-authored-by: Claude <noreply@anthropic.com>")

result, err := scan.Run(g, scan.Options{Commit: "HEAD", Config: config.Default()})
if err != nil {
t.Fatal(err)
}
r, err := Apply(g, Options{Findings: result.Findings})
if err != nil {
t.Fatal(err)
}
if r.Applied != 1 || !r.Actions.AmendedCommit {
t.Fatalf("%+v", r)
}
if !strings.Contains(r.Summary, "What to do next") {
t.Fatal(r.Summary)
}
msg, _ := g.CommitMessage("HEAD")
if strings.Contains(msg, "Claude") {
t.Fatal(msg)
}
}

func TestApply_BlocksAmendOfPushedCommit(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
testutil.Write(t, dir, "README.md", "hi\n")
testutil.Git(t, dir, "add", "README.md")
testutil.Git(t, dir, "commit", "-m", "init\n\nCo-authored-by: Claude <noreply@anthropic.com>")
testutil.Git(t, dir, "branch", "-M", "main")
testutil.Git(t, dir, "remote", "add", "origin", dir)
testutil.Git(t, dir, "push", "-u", "origin", "main")

findings := []detect.Finding{{
Source: detect.SourceCommitTrailer, Selected: true, LineNumber: 3,
Match: "Co-authored-by: Claude <noreply@anthropic.com>", RuleName: "ai-coauthor-trailer",
}}
_, err := Apply(g, Options{Findings: findings})
if err == nil {
t.Fatal("expected error when amending pushed commit without --force")
t.Fatal("want error")
}
}

func TestApply_ForceAmendPushed(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
testutil.Write(t, dir, "README.md", "hi\n")
testutil.Git(t, dir, "add", "README.md")
testutil.Git(t, dir, "commit", "-m", "init\n\nCo-authored-by: Claude <noreply@anthropic.com>")
testutil.Git(t, dir, "branch", "-M", "main")
testutil.Git(t, dir, "remote", "add", "origin", dir)
testutil.Git(t, dir, "push", "-u", "origin", "main")

findings := []detect.Finding{{
Source: detect.SourceCommitTrailer, Selected: true, LineNumber: 3,
Match: "Co-authored-by: Claude <noreply@anthropic.com>", RuleName: "ai-coauthor-trailer",
}}
r, err := Apply(g, Options{Findings: findings, ForceAmend: true})
if err != nil {
t.Fatal(err)
}
if r.Applied != 1 {
t.Fatal(r)
}
}
26 changes: 26 additions & 0 deletions internal/detect/rules_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package detect

import (
"testing"

"github.com/Skeyelab/coauthor-cleaner/internal/config"
)

func TestSelectRules_RespectsProviders(t *testing.T) {
cfg := config.Default()
cfg.Providers.Claude = false
cfg.Providers.ChatGPT = false
cfg.Providers.Copilot = false
cfg.Providers.Cursor = false
cfg.Providers.GenericAI = false
rules := SelectRules(cfg, false, false)
if len(rules) != 0 {
t.Fatalf("got %d rules", len(rules))
}
}

func TestListRules(t *testing.T) {
if len(ListRules(config.Default())) < 5 {
t.Fatal()
}
}
115 changes: 115 additions & 0 deletions internal/fix/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package fix

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/Skeyelab/coauthor-cleaner/internal/config"
"github.com/Skeyelab/coauthor-cleaner/internal/detect"
"github.com/Skeyelab/coauthor-cleaner/internal/git"
"github.com/Skeyelab/coauthor-cleaner/internal/scan"
"github.com/Skeyelab/coauthor-cleaner/internal/testutil"
)

func initCommit(t *testing.T, dir string) {
t.Helper()
testutil.Write(t, dir, "README.md", "init\n")
testutil.Git(t, dir, "add", "README.md")
testutil.Git(t, dir, "commit", "-m", "init")
}

func TestRun_CleansStaged(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
initCommit(t, dir)
testutil.Write(t, dir, "a.go", "// Generated with Claude Code\n")
testutil.Git(t, dir, "add", "a.go")

r, err := Run(g, scan.Options{Config: config.Default()}, Options{})
if err != nil {
t.Fatal(err)
}
if r.Applied != 1 {
t.Fatalf("applied=%d", r.Applied)
}
if !strings.Contains(r.Summary, "Cleaned") {
t.Fatal(r.Summary)
}
}

func TestRun_CheckOnly(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
initCommit(t, dir)
testutil.Write(t, dir, "a.go", "// Generated by ChatGPT\n")
testutil.Git(t, dir, "add", "a.go")

r, err := Run(g, scan.Options{Config: config.Default()}, Options{CheckOnly: true})
if err != nil {
t.Fatal(err)
}
if r.Applied != 0 || !strings.Contains(r.Summary, "Dry run") {
t.Fatal(r.Summary)
}
}

func TestRun_NoFindings(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
initCommit(t, dir)
r, err := Run(g, scan.Options{Config: config.Default()}, Options{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(r.Summary, "No AI attribution") {
t.Fatal(r.Summary)
}
}

func TestRun_SkipsPushedCommitWithoutForce(t *testing.T) {
dir := testutil.GitRepo(t)
g := git.Runner{Dir: dir}
testutil.Write(t, dir, "README.md", "x\n")
testutil.Git(t, dir, "add", "README.md")
testutil.Git(t, dir, "commit", "-m", "init\n\nCo-authored-by: Claude <noreply@anthropic.com>")
testutil.Git(t, dir, "branch", "-M", "main")
testutil.Git(t, dir, "remote", "add", "origin", dir)
testutil.Git(t, dir, "push", "-u", "origin", "main")

r, err := Run(g, scan.Options{Config: config.Default()}, Options{})
if err != nil {
t.Fatal(err)
}
if r.SkippedCommit == 0 {
t.Fatal("expected skipped commit markers")
}
}

func TestCleanCommitMsgFile(t *testing.T) {
dir := testutil.GitRepo(t)
path := filepath.Join(dir, "msg")
os.WriteFile(path, []byte("feat\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"), 0644)

findings := []detect.Finding{{
Source: detect.SourceCommitTrailer, Selected: true, LineNumber: 3,
Match: "Co-authored-by: Claude <noreply@anthropic.com>",
}}
if err := CleanCommitMsgFile(path, findings); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "Claude") {
t.Fatalf("%s", data)
}
}

func TestPlanSummary(t *testing.T) {
s := planSummary(git.RepoState{}, []detect.Finding{
{Match: "x", Source: detect.SourceStagedDiff},
}, 1, Options{Push: true, ForcePush: true})
if !strings.Contains(s, "force-with-lease") || !strings.Contains(s, "skip 1") {
t.Fatal(s)
}
}
Loading
Loading