Go 270 lines
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 <[email protected]>\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 <[email protected]>\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 <[email protected]>\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 <[email protected]>\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)
}
}