package hooks import ( "bytes" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "time" "github.com/ajhahnde/eeco/internal/config" ) // commitMsgMarker is the unique identifier embedded in eeco's commit-msg // hook. It is the exact-match fallback when the ledger hash is // unavailable; an unrelated hook never carries it. const commitMsgMarker = "eeco-managed-commit-msg-v1" // Pattern fragments are assembled at runtime so this source file stays // self-clean for eeco's own comment-hygiene scan — Constraint 3, the // same discipline `internal/workflow/attribution.go` uses. The trailer // rule is line-anchored so a prose mention of the trailer's name (for // example "remove Co-Authored-By trailer" in a docs commit subject) is // not a false positive; only an actual trailer line is. var ( cmCoAuthored = "[Cc]o-" + "[Aa]uthored-" + "[Bb]y" cmGenVerb = "[Gg]enerated" cmRobotEmoji = "\\x{1F916}" // U+1F916, not written as a literal glyph here. ) // commitMsgPatterns block AI-attribution trailers. The first three // anchor on the Co-Authored-By trailer line and require a claude / // anthropic / noreply@anthropic mention on the same line; the fourth // catches the Claude Code robot-emoji "Generated with" signature. var commitMsgPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?im)^` + cmCoAuthored + `:.*claude`), regexp.MustCompile(`(?im)^` + cmCoAuthored + `:.*anthropic`), regexp.MustCompile(`(?im)^` + cmCoAuthored + `:.*noreply@anthropic`), regexp.MustCompile(cmRobotEmoji + `[^\n]{0,20}` + cmGenVerb), } // commitMsgScript renders the hook body. Git invokes commit-msg with // the path to the staged commit-message file as $1; the hook execs back // into the eeco binary to run the policy check, keeping the script body // trivially short and the pattern set inside the binary so a brew // upgrade refreshes the policy without rewriting the on-disk script. func commitMsgScript() string { var b strings.Builder b.WriteString("#!/bin/sh\n") b.WriteString("# eeco managed commit-msg hook. Reversible:\n") b.WriteString("# eeco hooks commit-msg off\n") b.WriteString("# Refresh after `brew upgrade eeco` (rewrites EECO path):\n") b.WriteString("# eeco hooks commit-msg refresh\n") b.WriteString("# Do not edit the next line; removal is exact-match.\n") b.WriteString("# " + commitMsgMarker + "\n") fmt.Fprintf(&b, "EECO=%q\n", selfPath()) b.WriteString("exec \"$EECO\" hooks commit-msg-check \"$1\"\n") return b.String() } // EnableCommitMsg installs the commit-msg hook. Unlike pre-commit and // post-merge, this hook needs no workflow-list configuration: the policy // is universal (no AI-attribution trailers) and lives inside the eeco // binary. Refuses, without modifying anything, when a non-eeco // commit-msg hook already exists. Re-enabling an already-eeco hook is a // no-op; use `refresh` to pick up a moved binary path. func EnableCommitMsg(cfg *config.Config) (string, error) { hooksDir, err := gitHooksDir(cfg) if err != nil { return "", err } path := filepath.Join(hooksDir, "commit-msg") script := commitMsgScript() if existing, rerr := os.ReadFile(path); rerr == nil { if isEecoManaged(existing, "", commitMsgMarker) { return "commit-msg already enabled", nil } return "", errors.New("a non-eeco commit-msg hook already exists — left untouched") } else if !errors.Is(rerr, os.ErrNotExist) { return "", fmt.Errorf("inspect commit-msg: %w", rerr) } if err := os.MkdirAll(hooksDir, 0o755); err != nil { return "", fmt.Errorf("create hooks dir: %w", err) } if err := os.WriteFile(path, []byte(script), 0o755); err != nil { return "", fmt.Errorf("write commit-msg: %w", err) } l, err := loadLedger(cfg) if err != nil { return "", err } l.CommitMsg = record{ Installed: true, Path: path, SHA256: sha256hex([]byte(script)), At: time.Now().UTC().Format(time.RFC3339), } if err := saveLedger(cfg, l); err != nil { return "", err } return "commit-msg enabled (" + path + ")", nil } // DisableCommitMsg removes the commit-msg hook only when the on-disk // script is byte-identical to what eeco wrote (the recorded hash, with // a marker-line fallback). A foreign or hand-edited hook is left in // place and reported. func DisableCommitMsg(cfg *config.Config) (string, error) { hooksDir, err := gitHooksDir(cfg) if err != nil { return "", err } path := filepath.Join(hooksDir, "commit-msg") l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } b, rerr := os.ReadFile(path) if errors.Is(rerr, os.ErrNotExist) { l.CommitMsg = record{} if err := saveLedger(cfg, l); err != nil { return "", err } return "commit-msg not enabled", nil } if rerr != nil { return "", fmt.Errorf("inspect commit-msg: %w", rerr) } if !isEecoManaged(b, l.CommitMsg.SHA256, commitMsgMarker) { return "", errors.New("commit-msg hook is present but not eeco's — left untouched") } if err := os.Remove(path); err != nil { return "", fmt.Errorf("remove commit-msg: %w", err) } l.CommitMsg = record{} if err := saveLedger(cfg, l); err != nil { return "", err } return "commit-msg disabled", nil } // RefreshCommitMsg rewrites the on-disk script when its embedded eeco // binary path no longer matches what selfPath() resolves today — the // self-heal for a `brew upgrade eeco` that moved the cellar directory // out from under a previously-installed hook (the stableBrewBin // path is reused). No-op when no eeco-managed commit-msg hook exists or // when the on-disk script already matches the desired bytes. func RefreshCommitMsg(cfg *config.Config) (string, error) { hooksDir, err := gitHooksDir(cfg) if err != nil { return "", err } path := filepath.Join(hooksDir, "commit-msg") l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } b, rerr := os.ReadFile(path) if errors.Is(rerr, os.ErrNotExist) { return "commit-msg not enabled", nil } if rerr != nil { return "", fmt.Errorf("inspect commit-msg: %w", rerr) } if !isEecoManaged(b, l.CommitMsg.SHA256, commitMsgMarker) { return "", errors.New("commit-msg hook is present but not eeco's — left untouched") } desired := commitMsgScript() if string(b) == desired { return "commit-msg already current", nil } if err := os.WriteFile(path, []byte(desired), 0o755); err != nil { return "", fmt.Errorf("write commit-msg: %w", err) } l.CommitMsg = record{ Installed: true, Path: path, SHA256: sha256hex([]byte(desired)), At: time.Now().UTC().Format(time.RFC3339), } if err := saveLedger(cfg, l); err != nil { return "", err } return "commit-msg refreshed (" + path + ")", nil } // CheckCommitMsg reads the commit-message file at path and returns an // error when its contents carry an AI-attribution trailer matching any // commitMsgPatterns regex. The error names the matched line and the // explicit --no-verify bypass so a conscious operator can still ship // the message; the hook stdin contract for commit-msg is exit 0 // (accept) vs non-zero (reject). func CheckCommitMsg(path string) error { b, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read commit message: %w", err) } for _, p := range commitMsgPatterns { loc := p.FindIndex(b) if loc == nil { continue } line := bytes.Count(b[:loc[0]], []byte("\n")) + 1 snippet := strings.TrimRight(string(b[loc[0]:loc[1]]), "\r\n") return fmt.Errorf( "commit-msg: AI-attribution forbidden (line %d: %s)\n"+ "remove the trailer; pass --no-verify to bypass (not recommended)", line, snippet) } return nil } // commitMsgStatus reports on/off for the commit-msg hook, reflecting // on-disk reality so a hand-removed hook reads as off and a foreign // hook of the same name reads as off-with-note. func commitMsgStatus(cfg *config.Config, l ledger) string { return managedHookStatus(cfg, "commit-msg", l.CommitMsg.SHA256, commitMsgMarker) }