ajhahn.de
← eeco
Go 88 lines
package hooks

import (
	"path/filepath"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
	"github.com/ajhahnde/eeco/internal/gitx"
)

// stopNudgeThrottle is the minimum gap between handover nudges — long enough
// that the Stop hook nudges at most once per working session.
const stopNudgeThrottle = 6 * time.Hour

// stopNudgeStampName is the throttle stamp under <workspace>/state.
const stopNudgeStampName = "handover-nudge.last"

// StopNudge decides whether the Stop hook should surface a one-time handover
// reminder. It fires when the working tree carries undocumented work — a dirty
// tree, OR a commit newer than the newest handover note — and the throttle has
// elapsed. On a fire it writes the throttle stamp first (so a continuation turn
// cannot re-trigger) and returns the advisory reason with fire=true; otherwise
// it returns fire=false and writes nothing. It never returns an error: any
// uncertainty (no git, unreadable stamp) degrades to "no nudge" so a session is
// never wedged. The caller must honor stop_hook_active before calling.
func StopNudge(cfg *config.Config, now time.Time) (reason string, fire bool) {
	stamp := filepath.Join(cfg.Workspace, "state", stopNudgeStampName)
	if !throttleElapsed(stamp, now, stopNudgeThrottle) {
		return "", false
	}
	reasons := undocumentedWork(cfg)
	if len(reasons) == 0 {
		return "", false
	}
	// Stamp first so a continuation turn can't re-trigger, then advise.
	writeStamp(stamp, now)
	return "Session housekeeping (handover-nudge): undocumented work in the tree (" +
		joinReasons(reasons) + "). Do NOT auto-run a handover. Tell the user once: " +
		`"Heads-up — there is undocumented work; want me to capture a handover before we stop?" ` +
		"then stop normally. (Fires at most once per 6h.)", true
}

// undocumentedWork returns human reasons the tree looks undocumented: a dirty
// working tree, and/or commits newer than the newest handover note. An empty
// result means nothing to nudge about. Every check degrades to "no signal" on
// error, so a repo without git or without notes simply yields fewer reasons.
func undocumentedWork(cfg *config.Config) []string {
	var reasons []string
	if dirty, err := gitx.IsDirty(cfg.RepoRoot); err == nil && dirty {
		reasons = append(reasons, "a dirty working tree")
	}
	if commitNewerThanHandover(cfg) {
		reasons = append(reasons, "commits newer than the last handover note")
	}
	return reasons
}

// commitNewerThanHandover reports whether HEAD's commit time is later than the
// newest handover note's mtime. It returns false (no signal) when there is no
// commit yet or git is unavailable; when a commit exists but there is no
// handover note at all, it reports true (the commit is by definition
// undocumented).
func commitNewerThanHandover(cfg *config.Config) bool {
	commitTime, ok, err := gitx.LastCommitTime(cfg.RepoRoot)
	if err != nil || !ok {
		return false
	}
	noteTime, ok := newestHandoverMtime(cfg)
	if !ok {
		return true
	}
	return commitTime.After(noteTime)
}

// joinReasons renders a reason list as "a, b and c" (Oxford-free) for the
// nudge text.
func joinReasons(reasons []string) string {
	switch len(reasons) {
	case 0:
		return ""
	case 1:
		return reasons[0]
	default:
		return strings.Join(reasons[:len(reasons)-1], ", ") + " and " + reasons[len(reasons)-1]
	}
}