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]
}
}