package hooks import ( "os" "path/filepath" "runtime" "strings" "testing" ) func TestCommitMsg_EnableWritesExecutableMarkedScript(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("POSIX exec bit is not represented on Windows filesystems") } cfg := newCfg(t, "") if _, err := EnableCommitMsg(cfg); err != nil { t.Fatalf("EnableCommitMsg: %v", err) } p := filepath.Join(cfg.RepoRoot, ".git", "hooks", "commit-msg") info, err := os.Stat(p) if err != nil { t.Fatalf("stat commit-msg: %v", err) } if info.Mode().Perm()&0o100 == 0 { t.Errorf("commit-msg not executable: %v", info.Mode()) } b, _ := os.ReadFile(p) got := string(b) if !strings.Contains(got, commitMsgMarker) { t.Errorf("script missing marker line:\n%s", got) } if !strings.Contains(got, "hooks commit-msg-check") { t.Errorf("script does not exec the check verb:\n%s", got) } } func TestCommitMsg_EnableIsIdempotent(t *testing.T) { cfg := newCfg(t, "") if _, err := EnableCommitMsg(cfg); err != nil { t.Fatal(err) } msg, err := EnableCommitMsg(cfg) if err != nil { t.Fatalf("second EnableCommitMsg errored: %v", err) } if !strings.Contains(msg, "already enabled") { t.Errorf("msg = %q, want already-enabled", msg) } } func TestCommitMsg_EnableRefusesForeignHook(t *testing.T) { cfg := newCfg(t, "") hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks") if err := os.MkdirAll(hooksDir, 0o755); err != nil { t.Fatal(err) } foreign := "#!/bin/sh\necho someone elses commit-msg hook\n" fp := filepath.Join(hooksDir, "commit-msg") if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil { t.Fatal(err) } if _, err := EnableCommitMsg(cfg); err == nil { t.Fatal("expected EnableCommitMsg to refuse a foreign hook") } b, _ := os.ReadFile(fp) if string(b) != foreign { t.Errorf("foreign hook was modified:\n%s", b) } } func TestCommitMsg_DisableRemovesOnlyEecoHook(t *testing.T) { cfg := newCfg(t, "") if _, err := EnableCommitMsg(cfg); err != nil { t.Fatal(err) } if _, err := DisableCommitMsg(cfg); err != nil { t.Fatalf("DisableCommitMsg: %v", err) } p := filepath.Join(cfg.RepoRoot, ".git", "hooks", "commit-msg") if _, err := os.Stat(p); !os.IsNotExist(err) { t.Errorf("commit-msg still present after disable (err=%v)", err) } if msg, err := DisableCommitMsg(cfg); err != nil || !strings.Contains(msg, "not enabled") { t.Errorf("re-disable: msg=%q err=%v", msg, err) } } func TestCommitMsg_DisableLeavesForeignHook(t *testing.T) { cfg := newCfg(t, "") hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks") if err := os.MkdirAll(hooksDir, 0o755); err != nil { t.Fatal(err) } foreign := "#!/bin/sh\necho keep me\n" fp := filepath.Join(hooksDir, "commit-msg") if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil { t.Fatal(err) } if _, err := DisableCommitMsg(cfg); err == nil { t.Fatal("expected DisableCommitMsg to refuse a foreign hook") } if b, _ := os.ReadFile(fp); string(b) != foreign { t.Errorf("foreign hook was touched:\n%s", b) } } func TestCommitMsg_DisableViaMarkerWhenLedgerLost(t *testing.T) { cfg := newCfg(t, "") if _, err := EnableCommitMsg(cfg); err != nil { t.Fatal(err) } if err := os.Remove(ledgerPath(cfg)); err != nil { t.Fatal(err) } if _, err := DisableCommitMsg(cfg); err != nil { t.Fatalf("DisableCommitMsg with lost ledger: %v", err) } p := filepath.Join(cfg.RepoRoot, ".git", "hooks", "commit-msg") if _, err := os.Stat(p); !os.IsNotExist(err) { t.Error("hook not removed via marker fallback") } } func TestCommitMsg_RefreshIsNoOpWhenCurrent(t *testing.T) { cfg := newCfg(t, "") if _, err := EnableCommitMsg(cfg); err != nil { t.Fatal(err) } msg, err := RefreshCommitMsg(cfg) if err != nil { t.Fatalf("RefreshCommitMsg: %v", err) } if !strings.Contains(msg, "already current") { t.Errorf("refresh msg = %q, want already-current", msg) } } func TestCommitMsg_RefreshRewritesStaleScript(t *testing.T) { cfg := newCfg(t, "") hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks") if err := os.MkdirAll(hooksDir, 0o755); err != nil { t.Fatal(err) } // Hand-build a stale script that carries the marker but a stale EECO // path — simulates the post-`brew upgrade eeco` state the self-heal fixes. stale := "#!/bin/sh\n" + "# " + commitMsgMarker + "\n" + "EECO=\"/old/path/eeco\"\n" + "exec \"$EECO\" hooks commit-msg-check \"$1\"\n" fp := filepath.Join(hooksDir, "commit-msg") if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil { t.Fatal(err) } // Refresh must accept this as eeco-managed (marker present) and // rewrite the file with the current commitMsgScript(). msg, err := RefreshCommitMsg(cfg) if err != nil { t.Fatalf("RefreshCommitMsg: %v", err) } if !strings.Contains(msg, "refreshed") { t.Errorf("refresh msg = %q, want refreshed", msg) } b, _ := os.ReadFile(fp) if strings.Contains(string(b), "/old/path/eeco") { t.Errorf("stale path survived refresh:\n%s", b) } } func TestCheckCommitMsg_AcceptsCleanMessage(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "msg") body := "feat: add the thing\n\nResolves #123\n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } if err := CheckCommitMsg(path); err != nil { t.Errorf("CheckCommitMsg: %v (want clean)", err) } } func TestCheckCommitMsg_RejectsClaudeTrailer(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "msg") body := "feat: thing\n\nCo-Authored-By: Claude Opus 4.7 \n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } err := CheckCommitMsg(path) if err == nil { t.Fatal("expected rejection for Claude trailer") } if !strings.Contains(err.Error(), "AI-attribution") { t.Errorf("error missing AI-attribution context: %v", err) } } func TestCheckCommitMsg_RejectsAnthropicTrailer(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "msg") body := "feat: x\n\nCo-Authored-By: Bot \n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } if err := CheckCommitMsg(path); err == nil { t.Fatal("expected rejection for anthropic-domain trailer") } } func TestCheckCommitMsg_RejectsNoreplyAnthropic(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "msg") body := "fix: y\n\nCo-Authored-By: Whoever \n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } if err := CheckCommitMsg(path); err == nil { t.Fatal("expected rejection for noreply@anthropic trailer") } } func TestCheckCommitMsg_RejectsGeneratedWithEmoji(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "msg") // Assemble the emoji-attribution body from runtime fragments so this // source file stays self-clean for eeco's own comment-hygiene scan // (same discipline as internal/workflow/attribution.go). robot := string([]rune{0x1F916}) body := "feat: z\n\n" + robot + " " + "Generated" + " with [Claude Code](https://claude.com/claude-code)\n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } if err := CheckCommitMsg(path); err == nil { t.Fatal("expected rejection for Generated-with emoji signature") } } func TestCheckCommitMsg_AllowsPolicyDiscussionInSubject(t *testing.T) { // A docs commit that mentions the forbidden strings in its subject // or body — but not as an actual Co-Authored-By trailer — must pass. // This is the false-positive-resistance the trailer-anchored pattern // buys us over the broad file-scan pattern. dir := t.TempDir() path := filepath.Join(dir, "msg") body := "docs: remove the Co-Authored-By trailer from CONTRIBUTING\n\n" + "The Claude and anthropic strings used to leak via this template.\n" + "Updated CI to reject noreply@anthropic now.\n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } if err := CheckCommitMsg(path); err != nil { t.Errorf("CheckCommitMsg rejected a policy-discussion commit: %v", err) } } func TestCheckCommitMsg_ErrorMentionsNoVerifyBypass(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "msg") body := "feat: x\n\nCo-Authored-By: Claude \n" if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } err := CheckCommitMsg(path) if err == nil { t.Fatal("expected rejection") } if !strings.Contains(err.Error(), "--no-verify") { t.Errorf("error must name --no-verify bypass; got: %v", err) } }