Go 168 lines
package workflow
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/queue"
)
// handoverDir is the per-note directory inside <workspace>/docs/.
const handoverDir = "handover"
// handoverRefresh writes a dated session-handover note plus a "what
// changed since the last one" summary, then queues it for the
// maintainer to review. It never overwrites: every note is a fresh
// uniquely-stamped file, so prior handovers stay intact. The note lives
// in the gitignored workspace (write-scope floor invariant). git is
// used read-only for the change summary; when git is unavailable the
// note is still written (without the summary) rather than blocking.
type handoverRefresh struct{}
func (handoverRefresh) Name() string { return "handover-refresh" }
func (handoverRefresh) Summary() string {
return "dated handover note + change-since-last summary; queued, never overwrites"
}
func (handoverRefresh) Run(env Env) (Result, error) {
cfg := env.Config
dir := filepath.Join(cfg.Workspace, "docs", handoverDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return Result{}, fmt.Errorf("handover-refresh: %w", err)
}
prevBase := lastHandoverHead(dir)
head := ""
changeSummary := "(git unavailable — no change summary)"
if sha, err := gitx.HeadSHA(cfg.RepoRoot); err == nil {
head = sha
log, stat, cerr := gitx.ChangesSince(cfg.RepoRoot, prevBase)
switch {
case cerr != nil:
changeSummary = "(change summary unavailable: " + cerr.Error() + ")"
case prevBase == "":
changeSummary = "first handover — no prior baseline.\n\n" + nonEmpty(log, "(no commits)")
default:
changeSummary = nonEmpty(log, "(no new commits)")
if stat != "" {
changeSummary += "\n\n" + stat
}
}
} else if !errors.Is(err, gitx.ErrUnavailable) {
changeSummary = "(change summary unavailable: " + err.Error() + ")"
}
stamp := time.Now().UTC()
path := uniquePath(dir, "handover-"+stamp.Format("20060102T150405.000000000Z")+".md")
note := buildHandoverNote(stamp, prevBase, head, changeSummary)
if err := os.WriteFile(path, []byte(note), 0o644); err != nil {
return Result{}, fmt.Errorf("handover-refresh: write note: %w", err)
}
rel := path
if r, err := filepath.Rel(cfg.RepoRoot, path); err == nil {
rel = filepath.ToSlash(r)
}
if err := queue.Append(filepath.Join(cfg.Workspace, "state"), queue.Item{
Kind: "handover",
Title: "Handover note ready for review",
Project: filepath.Base(cfg.RepoRoot),
Detail: "wrote " + rel + " — review and carry forward what is still relevant",
Date: stamp,
}); err != nil {
return Result{}, fmt.Errorf("handover-refresh: queue: %w", err)
}
return Result{
Code: CodeClean,
Summary: "handover note written and queued for review (" + rel + ")",
}, nil
}
// lastHandoverHead returns the head SHA recorded in the most recent
// existing note, or "" when there is none. The timestamped filenames
// sort lexically in chronological order.
func lastHandoverHead(dir string) string {
ents, err := os.ReadDir(dir)
if err != nil {
return ""
}
var names []string
for _, e := range ents {
if !e.IsDir() && strings.HasPrefix(e.Name(), "handover-") && strings.HasSuffix(e.Name(), ".md") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return ""
}
sort.Strings(names)
b, err := os.ReadFile(filepath.Join(dir, names[len(names)-1]))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "head:"); ok {
return strings.TrimSpace(v)
}
}
return ""
}
// uniquePath returns base inside dir, or base with a numeric suffix if
// it already exists, so a note is never overwritten.
func uniquePath(dir, base string) string {
path := filepath.Join(dir, base)
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
for i := 2; ; i++ {
cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
if _, err := os.Stat(cand); os.IsNotExist(err) {
return cand
}
}
}
func nonEmpty(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
func buildHandoverNote(stamp time.Time, base, head, changes string) string {
if base == "" {
base = "none"
}
if head == "" {
head = "unknown"
}
return fmt.Sprintf(`# Handover — %s
Written by eeco run handover-refresh. This note is a draft for the
maintainer; nothing here is committed.
base: %s
head: %s
## Changes since last handover
%s
## Open threads
(carry forward what is still relevant; delete the rest)
`, stamp.Format(time.RFC3339), base, head, changes)
}