ajhahn.de
← eeco
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)
	}
}