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 // (/Cellar/eeco//bin/eeco) plus a stable bin shim // (/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) } }