Go 226 lines
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)
}