Go 1013 lines
package hooks
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// newCfg builds a config rooted at a fresh temp repo (with a .git
// directory so pre-commit wiring is supported) and a workspace beside
// it. settings, when non-empty, becomes SessionSettingsPath.
func newCfg(t *testing.T, settings string) *config.Config {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
SessionSettingsPath: settings,
PreCommitWorkflows: config.DefaultPreCommitWorkflows(),
PostMergeWorkflows: config.DefaultPostMergeWorkflows(),
}
}
func postMergePath(cfg *config.Config) string {
return filepath.Join(cfg.RepoRoot, ".git", "hooks", "post-merge")
}
func preCommitPath(cfg *config.Config) string {
return filepath.Join(cfg.RepoRoot, ".git", "hooks", "pre-commit")
}
func TestPreCommit_EnableWritesExecutableMarkedScript(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX exec bit is not represented on Windows filesystems")
}
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatalf("EnablePreCommit: %v", err)
}
p := preCommitPath(cfg)
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat pre-commit: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("pre-commit not executable: %v", info.Mode())
}
b, _ := os.ReadFile(p)
if !strings.Contains(string(b), preCommitMarker) {
t.Errorf("script missing marker line:\n%s", b)
}
if !strings.Contains(string(b), "run leak-guard") {
t.Errorf("script does not invoke leak-guard:\n%s", b)
}
if !strings.Contains(string(b), "run version-sync") {
t.Errorf("script does not invoke version-sync (default list):\n%s", b)
}
if !strings.Contains(string(b), "set -e") {
t.Errorf("script missing `set -e` for fail-fast chain:\n%s", b)
}
}
func TestPreCommit_EnableHonoursCustomWorkflows(t *testing.T) {
cfg := newCfg(t, "")
cfg.PreCommitWorkflows = []string{"comment-hygiene", "leak-guard"}
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatalf("EnablePreCommit: %v", err)
}
b, _ := os.ReadFile(preCommitPath(cfg))
got := string(b)
if !strings.Contains(got, "run comment-hygiene") {
t.Errorf("script does not invoke comment-hygiene:\n%s", got)
}
if !strings.Contains(got, "run leak-guard") {
t.Errorf("script does not invoke leak-guard:\n%s", got)
}
if strings.Contains(got, "run version-sync") {
t.Errorf("custom list must not include the default version-sync step:\n%s", got)
}
chSeen := strings.Index(got, "run comment-hygiene")
lgSeen := strings.Index(got, "run leak-guard")
if chSeen < 0 || lgSeen < 0 || chSeen > lgSeen {
t.Errorf("workflows out of declared order:\n%s", got)
}
}
func TestPreCommit_EnableRefusesEmptyWorkflowList(t *testing.T) {
cfg := newCfg(t, "")
cfg.PreCommitWorkflows = nil
if _, err := EnablePreCommit(cfg); err == nil {
t.Fatal("expected EnablePreCommit to refuse an empty workflow list")
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Errorf("hook should not exist after refused install (err=%v)", err)
}
}
func TestPreCommit_EnableIsIdempotent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
msg, err := EnablePreCommit(cfg)
if err != nil {
t.Fatalf("second EnablePreCommit errored: %v", err)
}
if !strings.Contains(msg, "already enabled") {
t.Errorf("msg = %q, want already-enabled", msg)
}
}
func TestPreCommit_EnableRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses hook\n"
if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := EnablePreCommit(cfg); err == nil {
t.Fatal("expected EnablePreCommit to refuse a foreign hook")
}
b, _ := os.ReadFile(preCommitPath(cfg))
if string(b) != foreign {
t.Errorf("foreign hook was modified:\n%s", b)
}
}
func TestPreCommit_DisableRemovesOnlyEecoHook(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisablePreCommit(cfg); err != nil {
t.Fatalf("DisablePreCommit: %v", err)
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Errorf("pre-commit still present after disable (err=%v)", err)
}
// Disabling again is a clean no-op.
if msg, err := DisablePreCommit(cfg); err != nil || !strings.Contains(msg, "not enabled") {
t.Errorf("re-disable: msg=%q err=%v", msg, err)
}
}
func TestPreCommit_DisableLeavesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\nmake lint\n"
fp := filepath.Join(hooksDir, "pre-commit")
if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := DisablePreCommit(cfg); err == nil {
t.Fatal("expected DisablePreCommit to refuse a foreign hook")
}
if b, _ := os.ReadFile(fp); string(b) != foreign {
t.Errorf("foreign hook was touched:\n%s", b)
}
}
func TestPreCommit_DisableViaMarkerWhenLedgerLost(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
// Simulate a lost ledger: removing it must not strand the hook,
// because the marker line still identifies it as eeco's.
if err := os.Remove(ledgerPath(cfg)); err != nil {
t.Fatal(err)
}
if _, err := DisablePreCommit(cfg); err != nil {
t.Fatalf("DisablePreCommit with lost ledger: %v", err)
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Error("hook not removed via marker fallback")
}
}
func TestPostMerge_EnableWritesNonBlockingMarkedScript(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX exec bit is not represented on Windows filesystems")
}
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatalf("EnablePostMerge: %v", err)
}
p := postMergePath(cfg)
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat post-merge: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("post-merge not executable: %v", info.Mode())
}
got := string(mustRead(t, p))
if !strings.Contains(got, postMergeMarker) {
t.Errorf("script missing marker line:\n%s", got)
}
if !strings.Contains(got, "run memory-drift || true") {
t.Errorf("script does not invoke memory-drift with swallowed exit:\n%s", got)
}
if !strings.Contains(got, "run doc-drift || true") {
t.Errorf("script does not invoke doc-drift with swallowed exit (default list):\n%s", got)
}
// A post-merge runs after the merge has completed, so a drift finding
// must not abort the hook: no `set -e`.
if strings.Contains(got, "set -e") {
t.Errorf("post-merge must not use `set -e`:\n%s", got)
}
}
func TestPostMerge_EnableRefusesEmptyWorkflowList(t *testing.T) {
cfg := newCfg(t, "")
cfg.PostMergeWorkflows = nil
if _, err := EnablePostMerge(cfg); err == nil {
t.Fatal("expected EnablePostMerge to refuse an empty workflow list")
}
if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
t.Errorf("hook should not exist after refused install (err=%v)", err)
}
}
func TestPostMerge_EnableRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses post-merge\n"
if err := os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := EnablePostMerge(cfg); err == nil {
t.Fatal("expected EnablePostMerge to refuse a foreign hook")
}
if b := mustRead(t, postMergePath(cfg)); string(b) != foreign {
t.Errorf("foreign hook was modified:\n%s", b)
}
}
func TestPostMerge_DisableRemovesOnlyEecoHook(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisablePostMerge(cfg); err != nil {
t.Fatalf("DisablePostMerge: %v", err)
}
if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
t.Errorf("post-merge still present after disable (err=%v)", err)
}
if msg, err := DisablePostMerge(cfg); err != nil || !strings.Contains(msg, "not enabled") {
t.Errorf("re-disable: msg=%q err=%v", msg, err)
}
}
func TestPostMerge_DisableViaMarkerWhenLedgerLost(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
if err := os.Remove(ledgerPath(cfg)); err != nil {
t.Fatal(err)
}
if _, err := DisablePostMerge(cfg); err != nil {
t.Fatalf("DisablePostMerge with lost ledger: %v", err)
}
if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
t.Error("hook not removed via marker fallback")
}
}
func TestPreCommit_RefreshIsNoOpWhenCurrent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshPreCommit(cfg)
if err != nil {
t.Fatalf("RefreshPreCommit: %v", err)
}
if !strings.Contains(msg, "already current") {
t.Errorf("refresh msg = %q, want already-current", msg)
}
}
func TestPreCommit_RefreshNotEnabledIsNoOp(t *testing.T) {
cfg := newCfg(t, "")
msg, err := RefreshPreCommit(cfg)
if err != nil {
t.Fatalf("RefreshPreCommit: %v", err)
}
if !strings.Contains(msg, "not enabled") {
t.Errorf("refresh msg = %q, want not-enabled", msg)
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Errorf("refresh created a hook where none existed (err=%v)", err)
}
}
func TestPreCommit_RefreshRewritesStaleBinaryPath(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
// A marker-carrying script with a stale absolute binary path — the
// post-`brew upgrade eeco` / post-workspace-move state the self-heal fixes.
stale := "#!/bin/sh\n" +
"# " + preCommitMarker + "\n" +
"set -e\n" +
"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n" +
"\"$EECO\" run leak-guard\n"
fp := preCommitPath(cfg)
if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
t.Fatal(err)
}
msg, err := RefreshPreCommit(cfg)
if err != nil {
t.Fatalf("RefreshPreCommit: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("refresh msg = %q, want refreshed", msg)
}
b := mustRead(t, fp)
if strings.Contains(string(b), "Cellar/eeco/2.0.0") {
t.Errorf("stale binary path survived refresh:\n%s", b)
}
if string(b) != preCommitScript(cfg.PreCommitWorkflows) {
t.Errorf("refreshed script is not the current desired script:\n%s", b)
}
}
func TestPreCommit_RefreshRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses hook\n"
fp := preCommitPath(cfg)
if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := RefreshPreCommit(cfg); err == nil {
t.Fatal("expected RefreshPreCommit to refuse a foreign hook")
}
if b := mustRead(t, fp); string(b) != foreign {
t.Errorf("foreign hook was modified by refresh:\n%s", b)
}
}
func TestPostMerge_RefreshIsNoOpWhenCurrent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshPostMerge(cfg)
if err != nil {
t.Fatalf("RefreshPostMerge: %v", err)
}
if !strings.Contains(msg, "already current") {
t.Errorf("refresh msg = %q, want already-current", msg)
}
}
func TestPostMerge_RefreshRewritesStaleBinaryPath(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
stale := "#!/bin/sh\n" +
"# " + postMergeMarker + "\n" +
"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n" +
"\"$EECO\" run memory-drift || true\n"
fp := postMergePath(cfg)
if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
t.Fatal(err)
}
msg, err := RefreshPostMerge(cfg)
if err != nil {
t.Fatalf("RefreshPostMerge: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("refresh msg = %q, want refreshed", msg)
}
b := mustRead(t, fp)
if strings.Contains(string(b), "Cellar/eeco/2.0.0") {
t.Errorf("stale binary path survived refresh:\n%s", b)
}
if string(b) != postMergeScript(cfg.PostMergeWorkflows) {
t.Errorf("refreshed script is not the current desired script:\n%s", b)
}
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return b
}
func TestSessionStart_NotConfigured(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableSessionStart(cfg); err != ErrSessionNotConfigured {
t.Errorf("err = %v, want ErrSessionNotConfigured", err)
}
if _, err := DisableSessionStart(cfg); err != ErrSessionNotConfigured {
t.Errorf("disable err = %v, want ErrSessionNotConfigured", err)
}
}
func TestSessionStart_EnableCreatesValidGroup(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
b, err := os.ReadFile(sp)
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("settings not valid JSON: %v", err)
}
if !sessionInstalled(root) {
t.Errorf("session group not present:\n%s", b)
}
// Idempotent.
msg, err := EnableSessionStart(cfg)
if err != nil || !strings.Contains(msg, "already enabled") {
t.Errorf("re-enable: msg=%q err=%v", msg, err)
}
}
func TestSessionStart_BacksUpAndPreservesForeignKeys(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
original := `{
"model": "x",
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "other-tool run" } ] }
]
}
}`
if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
msg, err := EnableSessionStart(cfg)
if err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
if !strings.Contains(msg, "backup ") {
t.Errorf("expected a backup path in msg, got %q", msg)
}
// A backup of the exact original bytes lives inside the workspace.
backups, _ := os.ReadDir(filepath.Join(cfg.Workspace, "state", backupSubdir))
if len(backups) != 1 {
t.Fatalf("want 1 backup, got %d", len(backups))
}
bb, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", backupSubdir, backups[0].Name()))
if string(bb) != original {
t.Errorf("backup is not the exact original:\n%s", bb)
}
var root map[string]any
b, _ := os.ReadFile(sp)
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
if root["model"] != "x" {
t.Errorf("foreign top-level key lost: %v", root["model"])
}
groups := sessionGroups(root)
if len(groups) != 2 {
t.Fatalf("want 2 SessionStart groups (foreign + eeco), got %d", len(groups))
}
}
func TestSessionStart_RefusesMalformedJSON(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
bad := "{ not valid json"
if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableSessionStart(cfg); err == nil {
t.Fatal("expected refusal on malformed settings")
}
if b, _ := os.ReadFile(sp); string(b) != bad {
t.Errorf("malformed settings file was modified:\n%s", b)
}
}
func TestSessionStart_DisableRemovesOnlyEecoGroup(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
original := `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep-me"}]}]}}`
if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisableSessionStart(cfg); err != nil {
t.Fatalf("DisableSessionStart: %v", err)
}
var root map[string]any
b, _ := os.ReadFile(sp)
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
if sessionInstalled(root) {
t.Error("eeco group still present after disable")
}
groups := sessionGroups(root)
if len(groups) != 1 {
t.Fatalf("foreign group not preserved, groups=%d", len(groups))
}
gm := groups[0].(map[string]any)
hs := gm["hooks"].([]any)
h0 := hs[0].(map[string]any)
if h0["command"] != "keep-me" {
t.Errorf("wrong group survived: %v", h0["command"])
}
}
func TestStatusReflectsOnDisk(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
got := strings.Join(Status(cfg), "\n")
for _, want := range []string{"pre-commit: off", "post-merge: off", "session-start: off", "commit-msg: off", "commit-guard: off"} {
if !strings.Contains(got, want) {
t.Errorf("fresh status = %q, want %q", got, want)
}
}
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitGuard(cfg); err != nil {
t.Fatal(err)
}
got = strings.Join(Status(cfg), "\n")
for _, want := range []string{"pre-commit: on", "post-merge: on", "session-start: on", "commit-msg: on", "commit-guard: on"} {
if !strings.Contains(got, want) {
t.Errorf("enabled status = %q, want %q", got, want)
}
}
if ss := ShortState(cfg); ss != "pre-commit:on post-merge:on session:on commit-msg:on commit-guard:on" {
t.Errorf("ShortState = %q", ss)
}
}
func TestSessionStart_NotConfiguredStatus(t *testing.T) {
cfg := newCfg(t, "")
if s := sessionStatus(cfg); s != "not configured" {
t.Errorf("sessionStatus = %q, want 'not configured'", s)
}
}
func TestSessionStart_FileOnlyEnablesAndDisables(t *testing.T) {
cfg := newCfg(t, "")
cfg.SessionFiles = []string{"CLAUDE.md"}
msg, err := EnableSessionStart(cfg)
if err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
if !strings.Contains(msg, "files") {
t.Errorf("msg = %q, want a files mention", msg)
}
if s := sessionStatus(cfg); s != "on" {
t.Errorf("status after enable = %q, want on", s)
}
if _, derr := DisableSessionStart(cfg); derr != nil {
t.Fatalf("DisableSessionStart: %v", derr)
}
if s := sessionStatus(cfg); s != "off" {
t.Errorf("status after disable = %q, want off", s)
}
if _, err := os.Stat(filepath.Join(cfg.RepoRoot, "CLAUDE.md")); !os.IsNotExist(err) {
t.Errorf("CLAUDE.md still present after disable (err=%v)", err)
}
}
func TestSessionStart_BothChannelsCompose(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
cfg.SessionFiles = []string{"AGENTS.md"}
msg, err := EnableSessionStart(cfg)
if err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
if !strings.Contains(msg, sp) || !strings.Contains(msg, "files") {
t.Errorf("msg = %q, want both JSON path and files mention", msg)
}
if s := sessionStatus(cfg); s != "on" {
t.Errorf("status = %q, want on", s)
}
// The JSON file got the eeco group.
jb, _ := os.ReadFile(sp)
var root map[string]any
if err := json.Unmarshal(jb, &root); err != nil {
t.Fatalf("settings not valid JSON: %v", err)
}
if !sessionInstalled(root) {
t.Errorf("session group missing in JSON channel")
}
// The file got the marker block.
fb, _ := os.ReadFile(filepath.Join(cfg.RepoRoot, "AGENTS.md"))
if !strings.Contains(string(fb), sessionStartMarker) {
t.Errorf("AGENTS.md missing marker block:\n%s", fb)
}
if _, derr := DisableSessionStart(cfg); derr != nil {
t.Fatalf("DisableSessionStart: %v", derr)
}
if s := sessionStatus(cfg); s != "off" {
t.Errorf("status after disable = %q, want off", s)
}
}
func TestSessionStart_RefreshUpdatesBlock(t *testing.T) {
cfg := newCfg(t, "")
cfg.SessionFiles = []string{"CLAUDE.md"}
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
// Adding a README.md should change the auto-detected reading routine.
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "README.md"), []byte("# x\n"), 0o644); err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed' mention", msg)
}
b, _ := os.ReadFile(path)
if !strings.Contains(string(b), "README.md") {
t.Errorf("refresh did not pick up README.md:\n%s", b)
}
}
func TestSessionStart_RefreshNoFilesIsNoOp(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' for JSON-only", msg)
}
}
func TestSessionStart_RefreshUnconfiguredErrors(t *testing.T) {
cfg := newCfg(t, "")
if _, err := RefreshSessionStart(cfg); err != ErrSessionNotConfigured {
t.Errorf("err = %v, want ErrSessionNotConfigured", err)
}
}
// fakeBrewCellar lays down a tmpdir-rooted brew layout
// (<prefix>/Cellar/eeco/<version>/bin/eeco) plus a stable bin shim
// (<prefix>/bin/eeco). Returns the prefix, the versioned cellar binary
// path, and the stable shim path.
func fakeBrewCellar(t *testing.T, version string) (prefix, cellarBin, shim string) {
t.Helper()
prefix = t.TempDir()
binDir := filepath.Join(prefix, "bin")
cellarDir := filepath.Join(prefix, "Cellar", "eeco", version, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(cellarDir, 0o755); err != nil {
t.Fatal(err)
}
cellarBin = filepath.Join(cellarDir, "eeco")
if err := os.WriteFile(cellarBin, []byte("real-bin"), 0o755); err != nil {
t.Fatal(err)
}
shim = filepath.Join(binDir, "eeco")
if err := os.WriteFile(shim, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
return prefix, cellarBin, shim
}
func TestStableBrewBin_CellarPathReturnsShim(t *testing.T) {
// Homebrew is macOS/Linux only; the cellar-path heuristic is keyed
// on the unix `/Cellar/eeco/` substring so a Windows tempdir
// (backslash-separated) cannot exercise this path.
if runtime.GOOS == "windows" {
t.Skip("stableBrewBin matches a unix /Cellar/eeco/ substring; brew is not a Windows install path")
}
_, cellarBin, shim := fakeBrewCellar(t, "2.0.0")
got := stableBrewBin(cellarBin)
if got != shim {
t.Errorf("stableBrewBin(%q) = %q, want %q", cellarBin, got, shim)
}
}
func TestStableBrewBin_MissingShimReturnsEmpty(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stableBrewBin matches a unix /Cellar/eeco/ substring; brew is not a Windows install path")
}
_, cellarBin, shim := fakeBrewCellar(t, "2.0.0")
if err := os.Remove(shim); err != nil {
t.Fatal(err)
}
if got := stableBrewBin(cellarBin); got != "" {
t.Errorf("stableBrewBin without shim = %q, want \"\"", got)
}
}
func TestStableBrewBin_NonCellarPathReturnsEmpty(t *testing.T) {
cases := []string{
"/usr/local/bin/eeco",
"/opt/homebrew/bin/eeco",
"/Users/anyone/go/bin/eeco",
"eeco",
"",
}
for _, p := range cases {
if got := stableBrewBin(p); got != "" {
t.Errorf("stableBrewBin(%q) = %q, want \"\"", p, got)
}
}
}
// staleSessionSettings writes a settings.json carrying an eeco
// SessionStart group whose command embeds a fake versioned cellar path
// that does not match the current sessionCommand() value.
func staleSessionSettings(t *testing.T, path, stale string) {
t.Helper()
body := map[string]any{
"hooks": map[string]any{
"SessionStart": []any{
map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": stale + " " + sessionToken,
},
},
},
},
},
}
b, err := json.MarshalIndent(body, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
// firstSessionCommand parses path and returns the command string of
// the first SessionStart group whose command carries the eeco
// namespace token. Empty string when nothing matches.
func firstSessionCommand(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("parse settings: %v", err)
}
for _, g := range sessionGroups(root) {
gm, ok := g.(map[string]any)
if !ok {
continue
}
hs, ok := gm["hooks"].([]any)
if !ok {
continue
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
cmd, ok := hm["command"].(string)
if !ok {
continue
}
if strings.Contains(cmd, sessionToken) {
return cmd
}
}
}
return ""
}
func TestSessionStart_RefreshRewritesStaleJSONCommand(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
stale := `"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco"`
staleSessionSettings(t, sp, stale)
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed' on stale rewrite", msg)
}
got := firstSessionCommand(t, sp)
want := sessionCommand()
if got != want {
t.Errorf("command after refresh = %q, want %q", got, want)
}
staleCmd := stale + " " + sessionToken
if got == staleCmd {
t.Errorf("stale command still present after refresh: %q", got)
}
}
func TestSessionStart_RefreshCurrentJSONIsNoOp(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(sp)
if err != nil {
t.Fatal(err)
}
beforeInfo, err := os.Stat(sp)
if err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' when JSON is current", msg)
}
after, _ := os.ReadFile(sp)
if string(before) != string(after) {
t.Errorf("settings file bytes changed on no-op refresh:\nbefore:\n%s\nafter:\n%s", before, after)
}
afterInfo, _ := os.Stat(sp)
if !beforeInfo.ModTime().Equal(afterInfo.ModTime()) {
t.Errorf("settings file mtime changed on no-op refresh: %v -> %v", beforeInfo.ModTime(), afterInfo.ModTime())
}
}
func TestSessionStart_RefreshIgnoresForeignSessionEntries(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
body := `{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "other-tool run" } ] }
]
}
}`
if err := os.WriteFile(sp, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' when no eeco group present", msg)
}
after, _ := os.ReadFile(sp)
if string(after) != body {
t.Errorf("foreign settings file modified by refresh:\n%s", after)
}
}
func TestSessionStart_RefreshMissingSettingsFileIsNoOp(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' when settings file absent", msg)
}
if _, err := os.Stat(sp); !os.IsNotExist(err) {
t.Errorf("settings file created by refresh; want absent (err=%v)", err)
}
}
func TestSessionStart_RefreshMalformedJSONErrors(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
bad := "{ not valid json"
if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
if _, err := RefreshSessionStart(cfg); err == nil {
t.Fatal("expected refusal on malformed settings")
}
if b, _ := os.ReadFile(sp); string(b) != bad {
t.Errorf("malformed settings file was modified:\n%s", b)
}
}
func TestSessionStart_RefreshHandlesBothChannels(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
cfg.SessionFiles = []string{"CLAUDE.md"}
stale := `"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco"`
staleSessionSettings(t, sp, stale)
// Pre-write a marker block so refresh has something to update.
if _, err := EnableSessionStart(cfg); err == nil {
// Already-installed JSON entry blocks Enable from writing a new
// JSON group; the file channel still wires. Either path is fine
// for this fixture — we only need both channels present.
_ = err
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed'", msg)
}
if !strings.Contains(msg, sp) {
t.Errorf("msg = %q, want JSON path mention", msg)
}
got := firstSessionCommand(t, sp)
if got != sessionCommand() {
t.Errorf("JSON command after refresh = %q, want %q", got, sessionCommand())
}
fb, _ := os.ReadFile(filepath.Join(cfg.RepoRoot, "CLAUDE.md"))
if !strings.Contains(string(fb), sessionStartMarker) {
t.Errorf("file channel not refreshed:\n%s", fb)
}
}
// TestSessionStart_InstalledCommandIsInitGated guards the install side of
// the briefer-gating fix: the command wired into the settings file must
// carry --if-initialized so the bundled hook stays silent outside an eeco
// workspace, in every repo the user opens.
func TestSessionStart_InstalledCommandIsInitGated(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
got := firstSessionCommand(t, sp)
if !strings.Contains(got, "--if-initialized") {
t.Errorf("installed session command = %q, want it to contain --if-initialized", got)
}
}