diff --git a/cmd/claude_hook.go b/cmd/claude_hook.go deleted file mode 100644 index 45569f5..0000000 --- a/cmd/claude_hook.go +++ /dev/null @@ -1,97 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/nicksenap/grove/internal/console" - "github.com/nicksenap/grove/internal/hook" - "github.com/spf13/cobra" -) - -var claudeHookCmd = &cobra.Command{ - Use: "hook", - Short: "Manage Claude Code hooks", - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() - }, -} - -var claudeHookInstallCmd = &cobra.Command{ - Use: "install", - Short: "Register Grove hooks in Claude Code settings", - Run: func(cmd *cobra.Command, args []string) { - gwPath, err := hook.ResolveGW() - if err != nil { - exitError(err.Error()) - } - - inst := hook.NewInstaller() - - fmt.Fprintf(cmd.ErrOrStderr(), "This will register Grove hooks in ~/.claude/settings.json\n\n") - fmt.Fprintf(cmd.ErrOrStderr(), " Events: %d hook event types\n", len(hook.HookEvents)) - fmt.Fprintf(cmd.ErrOrStderr(), " Binary: %s\n\n", gwPath) - - // Backup - backupPath, err := inst.Backup() - if err != nil { - exitError("backup failed: " + err.Error()) - } - if backupPath != "" { - fmt.Fprintf(cmd.ErrOrStderr(), " Backup: %s\n\n", backupPath) - } - - if !console.Confirm("Proceed?", true) { - console.Info("Cancelled") - return - } - - count, err := inst.Install(gwPath) - if err != nil { - exitError(err.Error()) - } - console.Successf("Installed %d hook(s) in ~/.claude/settings.json", count) - }, -} - -var claudeHookUninstallCmd = &cobra.Command{ - Use: "uninstall", - Short: "Remove Grove hooks from Claude Code settings", - Run: func(cmd *cobra.Command, args []string) { - inst := hook.NewInstaller() - - if !inst.IsInstalled() { - console.Info("Grove hooks are not installed") - return - } - - if !console.Confirm("Remove Grove hooks from ~/.claude/settings.json?", false) { - console.Info("Cancelled") - return - } - - removed, err := inst.Uninstall() - if err != nil { - exitError(err.Error()) - } - console.Successf("Removed %d hook(s)", removed) - }, -} - -var claudeHookStatusCmd = &cobra.Command{ - Use: "status", - Short: "Check if Grove hooks are installed", - Run: func(cmd *cobra.Command, args []string) { - inst := hook.NewInstaller() - if inst.IsInstalled() { - console.Success("Grove hooks are installed") - } else { - console.Info("Grove hooks are not installed. Run: gw hook install") - } - }, -} - -func init() { - claudeHookCmd.AddCommand(claudeHookInstallCmd) - claudeHookCmd.AddCommand(claudeHookUninstallCmd) - claudeHookCmd.AddCommand(claudeHookStatusCmd) -} diff --git a/cmd/create.go b/cmd/create.go index dc9aa13..5332d6d 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -147,10 +147,7 @@ var createCmd = &cobra.Command{ // Fire post_create hook if configured wsPath := filepath.Join(cfg.WorkspaceDir, name) vars := lifecycle.Vars{Name: name, Path: wsPath, Branch: branch} - if err := lifecycle.Run("post_create", vars); errors.Is(err, lifecycle.ErrNoHook) { - // TODO: remove when matured — legacy fallback for users without [hooks] config. - copyParentCLAUDEmd(wsPath) - } else if err != nil { + if err := lifecycle.Run("post_create", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { console.Warningf("post_create hook failed: %s", err) } }, @@ -166,18 +163,6 @@ func init() { createCmd.RegisterFlagCompletionFunc("preset", completePresetNames) } -// TODO: remove when matured — legacy fallback for users without [hooks] config. -func copyParentCLAUDEmd(wsPath string) { - src := filepath.Join(wsPath, "..", "CLAUDE.md") - data, err := os.ReadFile(src) - if err != nil { - return - } - if err := os.WriteFile(filepath.Join(wsPath, "CLAUDE.md"), data, 0o644); err != nil { - console.Warningf("legacy CLAUDE.md copy failed: %s", err) - } -} - func deriveName(branch string) string { name := strings.ReplaceAll(branch, "/", "-") name = strings.ReplaceAll(name, " ", "-") diff --git a/cmd/delete.go b/cmd/delete.go index 7f4d2eb..1792b7c 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -1,10 +1,12 @@ package cmd import ( + "errors" "fmt" "strings" "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/lifecycle" "github.com/nicksenap/grove/internal/picker" "github.com/nicksenap/grove/internal/state" "github.com/nicksenap/grove/internal/workspace" @@ -68,6 +70,15 @@ func doDelete(args []string, force bool) { } for _, name := range names { + // Fire pre_delete hook before teardown (e.g. harvest Claude memory) + ws, _ := state.GetWorkspace(name) + if ws != nil { + vars := lifecycle.Vars{Name: name, Path: ws.Path, Branch: ws.Branch} + if err := lifecycle.Run("pre_delete", vars); err != nil && !errors.Is(err, lifecycle.ErrNoHook) { + console.Warningf("pre_delete hook failed: %s", err) + } + } + if err := workspace.NewService().Delete(name); err != nil { exitError(err.Error()) } diff --git a/cmd/doctor.go b/cmd/doctor.go index a7cfde6..0f8ae7c 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -4,11 +4,8 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" - "github.com/nicksenap/grove/internal/config" "github.com/nicksenap/grove/internal/console" - "github.com/nicksenap/grove/internal/models" "github.com/nicksenap/grove/internal/workspace" "github.com/spf13/cobra" ) @@ -27,8 +24,6 @@ var doctorCmd = &cobra.Command{ exitError(err.Error()) } - // TODO: remove when matured — nudge users to migrate to [hooks] config. - issues = append(issues, checkMissingHooks()...) if doctorJSON { data, _ := json.MarshalIndent(issues, "", " ") @@ -57,40 +52,6 @@ var doctorCmd = &cobra.Command{ }, } -// TODO: remove when matured — nudge users to migrate to [hooks] config. -func checkMissingHooks() []models.DoctorIssue { - cfg, err := config.Load() - if err != nil || cfg == nil { - return nil - } - - var issues []models.DoctorIssue - - if _, ok := cfg.Hooks["on_close"]; !ok { - // Only flag if Zellij is present — that's what the old hardcoded behavior supported. - if os.Getenv("ZELLIJ_SESSION_NAME") != "" { - issues = append(issues, models.DoctorIssue{ - Workspace: "—", - Issue: "no on_close hook configured (using legacy Zellij fallback)", - SuggestedAction: "add [hooks] on_close to ~/.grove/config.toml", - }) - } - } - - if _, ok := cfg.Hooks["post_create"]; !ok { - // Only flag if ~/.claude exists — user is a Claude Code user. - home, _ := os.UserHomeDir() - if _, err := os.Stat(filepath.Join(home, ".claude")); err == nil { - issues = append(issues, models.DoctorIssue{ - Workspace: "—", - Issue: "no post_create hook configured (using legacy CLAUDE.md copy)", - SuggestedAction: `add [hooks] post_create = "cp {path}/../CLAUDE.md {path}/CLAUDE.md 2>/dev/null || true"`, - }) - } - } - - return issues -} func init() { doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Auto-fix issues") diff --git a/cmd/go_cmd.go b/cmd/go_cmd.go index 247c23e..cfaac43 100644 --- a/cmd/go_cmd.go +++ b/cmd/go_cmd.go @@ -1,13 +1,12 @@ package cmd import ( + "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" - - "errors" - "os/exec" "syscall" "github.com/nicksenap/grove/internal/config" @@ -43,9 +42,7 @@ var goCmd = &cobra.Command{ } } if err := lifecycle.Run("on_close", lifecycle.Vars{}); errors.Is(err, lifecycle.ErrNoHook) { - // TODO: remove when matured — legacy Zellij fallback for users who - // upgraded before the [hooks] system existed. - zellijCloseFallback() + exitError("No on_close hook configured. Set one in ~/.grove/config.toml:\n\n [hooks]\n on_close = \"gw zellij close-pane\"") } else if err != nil { exitError(fmt.Sprintf("on_close hook failed: %s", err)) } @@ -205,14 +202,6 @@ func deleteAsync(name string) { cmd.Start() // fire and forget } -// TODO: remove when matured — legacy Zellij fallback for users without [hooks] config. -func zellijCloseFallback() { - if os.Getenv("ZELLIJ_SESSION_NAME") == "" { - exitError("No on_close hook configured. Set one in ~/.grove/config.toml:\n\n [hooks]\n on_close = \"zellij action close-pane\"") - } - exec.Command("zellij", "action", "close-pane").Run() -} - func init() { goCmd.Flags().BoolVarP(&goBack, "back", "b", false, "Go back to source repo") goCmd.Flags().BoolVarP(&goDelete, "delete", "d", false, "Delete workspace after navigating away") diff --git a/cmd/hook.go b/cmd/hook.go deleted file mode 100644 index 3c68d10..0000000 --- a/cmd/hook.go +++ /dev/null @@ -1,41 +0,0 @@ -package cmd - -import ( - "encoding/json" - "io" - "os" - - "github.com/nicksenap/grove/internal/config" - "github.com/nicksenap/grove/internal/hook" - "github.com/spf13/cobra" -) - -var hookEvent string - -var hookCmd = &cobra.Command{ - Use: "_hook", - Short: "Internal hook handler for Claude Code", - Hidden: true, - Run: func(cmd *cobra.Command, args []string) { - if hookEvent == "" { - return - } - - // Read JSON from stdin - data, err := io.ReadAll(os.Stdin) - if err != nil || len(data) == 0 { - return - } - - var payload map[string]any - if err := json.Unmarshal(data, &payload); err != nil { - return // silently ignore malformed JSON - } - - hook.NewHandler(config.GroveDir).HandleEvent(hookEvent, payload) - }, -} - -func init() { - hookCmd.Flags().StringVar(&hookEvent, "event", "", "Event type") -} diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go index d233457..4f9f839 100644 --- a/cmd/init_cmd.go +++ b/cmd/init_cmd.go @@ -23,5 +23,7 @@ var initCmd = &cobra.Command{ if len(repos) > 0 { console.Infof("Found %d repo(s) in configured directories", len(repos)) } + + console.Infof("Run 'gw wizard' to set up plugins and hooks") }, } diff --git a/cmd/root.go b/cmd/root.go index 7f3d294..6c8d045 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,11 +62,10 @@ func init() { addDirCmd, removeDirCmd, runCmd, - hookCmd, - claudeHookCmd, exploreCmd, mcpServeCmd, pluginCmd, + wizardCmd, ) } diff --git a/cmd/wizard.go b/cmd/wizard.go new file mode 100644 index 0000000..70f4adf --- /dev/null +++ b/cmd/wizard.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/nicksenap/grove/internal/config" + "github.com/nicksenap/grove/internal/console" + "github.com/nicksenap/grove/internal/plugin" + "github.com/spf13/cobra" +) + +var wizardCmd = &cobra.Command{ + Use: "wizard", + Short: "Interactive setup for plugins and hooks", + Run: func(cmd *cobra.Command, args []string) { + cfg, err := config.Load() + if err != nil || cfg == nil { + exitError("Grove not initialized. Run: gw init ") + } + + fmt.Fprintln(os.Stderr, "Grove plugin wizard") + fmt.Fprintln(os.Stderr, "") + + changed := false + + // --- Claude Code --- + home, _ := os.UserHomeDir() + claudeExists := false + if _, err := os.Stat(filepath.Join(home, ".claude")); err == nil { + claudeExists = true + } + + if claudeExists { + _, claudeErr := plugin.Find("claude") + claudeInstalled := claudeErr == nil + + if !claudeInstalled { + if console.Confirm("Claude Code detected. Install gw-claude plugin?", true) { + if err := plugin.Install("nicksenap/gw-claude"); err != nil { + console.Warningf("install failed: %s", err) + } else { + claudeInstalled = true + } + } + } else { + console.Infof("gw-claude plugin already installed") + } + + if claudeInstalled { + // Offer to configure hooks + if _, ok := cfg.Hooks["post_create"]; !ok { + if console.Confirm("Configure Claude memory sync hooks?", true) { + if cfg.Hooks == nil { + cfg.Hooks = make(map[string]string) + } + cfg.Hooks["post_create"] = "gw claude sync rehydrate {path} && gw claude copy-md {path}" + cfg.Hooks["pre_delete"] = "gw claude sync harvest {path}" + changed = true + console.Success("Added post_create and pre_delete hooks") + } + } else { + console.Infof("Claude hooks already configured") + } + + // Offer to register Claude Code event hooks + if console.Confirm("Register Claude Code session tracking hooks?", true) { + pluginPath, findErr := plugin.Find("claude") + if findErr != nil { + console.Warningf("cannot find gw-claude: %s", findErr) + } else { + hookCmd := exec.Command(pluginPath, "hook", "install") + hookCmd.Stdout = os.Stdout + hookCmd.Stderr = os.Stderr + if err := hookCmd.Run(); err != nil { + console.Warningf("hook install failed: %s", err) + } + } + } + } + } + + fmt.Fprintln(os.Stderr, "") + + // --- Zellij --- + zellijSession := os.Getenv("ZELLIJ_SESSION_NAME") != "" + + if zellijSession { + _, zellijErr := plugin.Find("zellij") + zellijInstalled := zellijErr == nil + + if !zellijInstalled { + if console.Confirm("Zellij detected. Install gw-zellij plugin?", true) { + if err := plugin.Install("nicksenap/gw-zellij"); err != nil { + console.Warningf("install failed: %s", err) + } else { + zellijInstalled = true + } + } + } else { + console.Infof("gw-zellij plugin already installed") + } + + if zellijInstalled { + if _, ok := cfg.Hooks["on_close"]; !ok { + if console.Confirm("Configure on_close hook for Zellij?", true) { + if cfg.Hooks == nil { + cfg.Hooks = make(map[string]string) + } + cfg.Hooks["on_close"] = "gw zellij close-pane" + changed = true + console.Success("Added on_close hook") + } + } else { + console.Infof("on_close hook already configured") + } + } + } + + // Save config if hooks were added + if changed { + if err := config.Save(cfg); err != nil { + exitError("saving config: " + err.Error()) + } + fmt.Fprintln(os.Stderr, "") + console.Success("Config updated") + } + + fmt.Fprintln(os.Stderr, "") + console.Success("Done! Run 'gw create' to get started.") + }, +} diff --git a/docs/plugin-extraction.md b/docs/plugin-extraction.md index 1838e62..692e154 100644 --- a/docs/plugin-extraction.md +++ b/docs/plugin-extraction.md @@ -135,13 +135,16 @@ on_close = "gw zellij close-pane" `post_rename` needs a new `{old_path}` var added to `lifecycle.Vars`. +> **Note:** `post_rename` is deferred — rename is rare and the only consumer +> (Claude memory migration) is an edge case. A TODO in `workspace.Rename()` +> marks the spot. Add the hook + `{old_path}` var if a real need surfaces. + ## Migration plan ### Phase 1: Add missing hooks + vars -1. Add `pre_delete` hook call in `workspace.Delete()` before teardown -2. Add `post_rename` hook call in `workspace.Rename()` after rename -3. Add `OldPath` to `lifecycle.Vars` and expand as `{old_path}` +1. ~~Add `pre_delete` hook call before teardown~~ ✅ (`cmd/delete.go`) +2. ~~`post_rename` + `{old_path}`~~ deferred (TODO in `workspace.Rename()`) ### Phase 2: Create `gw-claude` plugin repo diff --git a/e2e/run.sh b/e2e/run.sh index 8dacc92..cfb9a4e 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -28,6 +28,7 @@ section "Setup" export GROVE_HOME=$(mktemp -d /tmp/grove-e2e.XXXXXX) export HOME="${GROVE_HOME}" +unset ZELLIJ_SESSION_NAME # prevent host env from leaking into doctor checks trap 'rm -rf "${GROVE_HOME}"' EXIT REPOS_DIR="${GROVE_HOME}/repos" @@ -412,6 +413,45 @@ else fail "branch feat/other still present in source repo" fi +# --------------------------------------------------------------------------- +# Test: lifecycle hooks (post_create + pre_delete) +# --------------------------------------------------------------------------- +section "Lifecycle hooks" + +# Configure lifecycle hooks that write marker files +POST_CREATE_MARKER="${GROVE_HOME}/post_create_fired" +PRE_DELETE_MARKER="${GROVE_HOME}/pre_delete_fired" +cat > "${GROVE_HOME}/.grove/config.toml" <&1 +if [ -f "${POST_CREATE_MARKER}" ]; then + pass "post_create hook fired after workspace creation" +else + fail "post_create hook did not fire" +fi + +gw delete hook-ws --force 2>&1 +if [ -f "${PRE_DELETE_MARKER}" ]; then + pass "pre_delete hook fired before workspace deletion" +else + fail "pre_delete hook did not fire" +fi + +rm -f "${POST_CREATE_MARKER}" "${PRE_DELETE_MARKER}" + +# Restore config without hooks for subsequent tests +cat > "${GROVE_HOME}/.grove/config.toml" <&1 -# --------------------------------------------------------------------------- -# Test: _hook (Claude Code event handler) -# --------------------------------------------------------------------------- -section "Hook" - -echo '{"session_id": "test-session-123", "cwd": "/tmp"}' | gw _hook --event SessionStart 2>/dev/null -if [ -f "${GROVE_HOME}/.grove/status/test-session-123.json" ]; then - status_val=$(jq -r '.status' "${GROVE_HOME}/.grove/status/test-session-123.json") - if [ "${status_val}" = "IDLE" ]; then - pass "_hook SessionStart creates status file" - else - fail "_hook status should be IDLE, got ${status_val}" - fi -else - fail "_hook did not create status file" -fi - -echo '{"session_id": "test-session-123", "tool_name": "Read"}' | gw _hook --event PreToolUse 2>/dev/null -tool_val=$(jq -r '.last_tool' "${GROVE_HOME}/.grove/status/test-session-123.json") -if [ "${tool_val}" = "Read" ]; then - pass "_hook PreToolUse updates tool name" -else - fail "_hook tool should be Read, got ${tool_val}" -fi - -# Verify project_name was set from cwd -proj_val=$(jq -r '.project_name' "${GROVE_HOME}/.grove/status/test-session-123.json") -if [ "${proj_val}" = "tmp" ]; then - pass "_hook SessionStart sets project_name from cwd" -else - fail "_hook project_name should be 'tmp', got ${proj_val}" -fi - -# Test PermissionRequest with tool_request_summary -echo '{"session_id": "test-session-123", "tool_name": "Bash", "tool_input": {"command": "echo hello"}}' | gw _hook --event PermissionRequest 2>/dev/null -perm_status=$(jq -r '.status' "${GROVE_HOME}/.grove/status/test-session-123.json") -summary_val=$(jq -r '.tool_request_summary' "${GROVE_HOME}/.grove/status/test-session-123.json") -if [ "${perm_status}" = "WAITING_PERMISSION" ] && [ "${summary_val}" = '$ echo hello' ]; then - pass "_hook PermissionRequest sets status + tool_request_summary" -else - fail "_hook PermissionRequest: status=${perm_status}, summary=${summary_val}" -fi - -# Test Notification with keyword detection -echo '{"session_id": "test-session-123", "message": "I have a question for you"}' | gw _hook --event Notification 2>/dev/null -notif_status=$(jq -r '.status' "${GROVE_HOME}/.grove/status/test-session-123.json") -notif_msg=$(jq -r '.notification_message' "${GROVE_HOME}/.grove/status/test-session-123.json") -if [ "${notif_status}" = "WAITING_ANSWER" ]; then - pass "_hook Notification 'question' keyword sets WAITING_ANSWER" -else - fail "_hook Notification status should be WAITING_ANSWER, got ${notif_status}" -fi -if [ "${notif_msg}" = "I have a question for you" ]; then - pass "_hook Notification sets notification_message" -else - fail "_hook notification_message: got ${notif_msg}" -fi - -# Test UserPromptSubmit captures initial_prompt and clears notification -echo '{"session_id": "test-session-123", "prompt": "Fix the login bug"}' | gw _hook --event UserPromptSubmit 2>/dev/null -prompt_val=$(jq -r '.initial_prompt' "${GROVE_HOME}/.grove/status/test-session-123.json") -prompt_status=$(jq -r '.status' "${GROVE_HOME}/.grove/status/test-session-123.json") -prompt_notif=$(jq -r '.notification_message' "${GROVE_HOME}/.grove/status/test-session-123.json") -if [ "${prompt_val}" = "Fix the login bug" ]; then - pass "_hook UserPromptSubmit captures initial_prompt" -else - fail "_hook initial_prompt: got ${prompt_val}" -fi -if [ "${prompt_status}" = "WORKING" ]; then - pass "_hook UserPromptSubmit sets WORKING" -else - fail "_hook UserPromptSubmit status: got ${prompt_status}" -fi -if [ "${prompt_notif}" = "null" ]; then - pass "_hook UserPromptSubmit clears notification_message" -else - fail "_hook notification should be cleared, got ${prompt_notif}" -fi - -# Test SubagentStart/Stop counting (fresh session to isolate) -echo '{"session_id": "sub-session", "cwd": "/tmp"}' | gw _hook --event SessionStart 2>/dev/null -echo '{"session_id": "sub-session", "agent_type": "Explore"}' | gw _hook --event SubagentStart 2>/dev/null -echo '{"session_id": "sub-session", "agent_type": "Plan"}' | gw _hook --event SubagentStart 2>/dev/null -sub_count=$(jq -r '.subagent_count' "${GROVE_HOME}/.grove/status/sub-session.json") -active_subs=$(jq -r '.active_subagents | length' "${GROVE_HOME}/.grove/status/sub-session.json") -if [ "${sub_count}" = "2" ] && [ "${active_subs}" = "2" ]; then - pass "_hook SubagentStart tracks count + active list" -else - fail "_hook SubagentStart: count=${sub_count}, active=${active_subs}" -fi - -echo '{"session_id": "sub-session", "agent_type": "Explore"}' | gw _hook --event SubagentStop 2>/dev/null -sub_count_after=$(jq -r '.subagent_count' "${GROVE_HOME}/.grove/status/sub-session.json") -remaining=$(jq -r '.active_subagents[0]' "${GROVE_HOME}/.grove/status/sub-session.json") -if [ "${sub_count_after}" = "1" ] && [ "${remaining}" = "Plan" ]; then - pass "_hook SubagentStop decrements and removes from active list" -else - fail "_hook SubagentStop: count=${sub_count_after}, remaining=${remaining}" -fi -echo '{"session_id": "sub-session"}' | gw _hook --event SessionEnd 2>/dev/null - -# Test PreCompact tracking (fresh session) -echo '{"session_id": "compact-session", "cwd": "/tmp"}' | gw _hook --event SessionStart 2>/dev/null -echo '{"session_id": "compact-session", "trigger": "auto"}' | gw _hook --event PreCompact 2>/dev/null -compact_count=$(jq -r '.compact_count' "${GROVE_HOME}/.grove/status/compact-session.json") -compact_trigger=$(jq -r '.compact_trigger' "${GROVE_HOME}/.grove/status/compact-session.json") -if [ "${compact_count}" = "1" ] && [ "${compact_trigger}" = "auto" ]; then - pass "_hook PreCompact tracks count + trigger" -else - fail "_hook PreCompact: count=${compact_count}, trigger=${compact_trigger}" -fi -echo '{"session_id": "compact-session"}' | gw _hook --event SessionEnd 2>/dev/null - -echo '{"session_id": "test-session-123"}' | gw _hook --event SessionEnd 2>/dev/null -if [ ! -f "${GROVE_HOME}/.grove/status/test-session-123.json" ]; then - pass "_hook SessionEnd removes status file" -else - fail "_hook SessionEnd should remove status file" -fi - # --------------------------------------------------------------------------- # Test: MCP server (stdio JSON-RPC) # --------------------------------------------------------------------------- @@ -819,82 +739,6 @@ else fail "unknown command without plugin should fail" fi -# --------------------------------------------------------------------------- -# Test: hook install / status / uninstall -# --------------------------------------------------------------------------- -section "Hook installer" - -SETTINGS="${GROVE_HOME}/.claude/settings.json" - -# Status before install -if gw hook status 2>&1 | grep -q "not installed"; then - pass "hook status: not installed initially" -else - fail "hook status should show not installed" -fi - -# Install (non-interactive: pipe yes) -echo "y" | gw hook install 2>&1 -if [ -f "${SETTINGS}" ]; then - pass "hook install created settings.json" -else - fail "hook install did not create settings.json" -fi - -# Verify all hook events are present -hook_count=$(jq '.hooks | keys | length' "${SETTINGS}") -if [ "${hook_count}" = "13" ]; then - pass "hook install registered all 13 events" -else - fail "expected 13 hook events, got ${hook_count}" -fi - -# Verify hook command points to gw -if jq -r '.hooks.SessionStart[0].hooks[0].command' "${SETTINGS}" | grep -q "gw _hook"; then - pass "hook command uses gw _hook" -else - fail "hook command wrong" -fi - -# Status after install -if gw hook status 2>&1 | grep -q "installed"; then - pass "hook status: installed" -else - fail "hook status should show installed" -fi - -# Reinstall (should update, not duplicate) -echo "y" | gw hook install 2>&1 -hook_count_after=$(jq '.hooks.SessionStart | length' "${SETTINGS}") -if [ "${hook_count_after}" = "1" ]; then - pass "hook reinstall updates without duplicating" -else - fail "hook reinstall duplicated entries: ${hook_count_after}" -fi - -# Backup should exist -backup_count=$(ls "${SETTINGS}".bak.* 2>/dev/null | wc -l | tr -d ' ') -if [ "${backup_count}" -ge "1" ]; then - pass "hook install created backup(s)" -else - fail "hook install did not create backup" -fi - -# Uninstall -echo "y" | gw hook uninstall 2>&1 -if jq -e '.hooks' "${SETTINGS}" > /dev/null 2>&1; then - fail "hooks key should be removed after uninstall" -else - pass "hook uninstall removed all hooks" -fi - -# Status after uninstall -if gw hook status 2>&1 | grep -q "not installed"; then - pass "hook status: not installed after uninstall" -else - fail "hook status should show not installed after uninstall" -fi - # --------------------------------------------------------------------------- # Cleanup: delete remaining workspace # --------------------------------------------------------------------------- diff --git a/internal/claude/claude.go b/internal/claude/claude.go deleted file mode 100644 index 2aead87..0000000 --- a/internal/claude/claude.go +++ /dev/null @@ -1,234 +0,0 @@ -// Package claude handles Claude Code memory synchronization between -// source repos and worktrees. -// -// Claude Code stores per-project memory at ~/.claude/projects//memory/. -// Since worktrees have different filesystem paths, they get separate memory dirs. -// This package bridges them by copying memory files between source repos and worktrees. -package claude - -import ( - "io" - "os" - "path/filepath" - "strings" -) - -// EncodePath encodes a filesystem path the same way Claude Code does: -// replace "/" and "." with "-". -func EncodePath(path string) string { - s := strings.ReplaceAll(path, "/", "-") - s = strings.ReplaceAll(s, ".", "-") - return s -} - -// MemoryDirFor returns the memory directory for a given path. -// claudeDir is typically ~/.claude. -func MemoryDirFor(claudeDir, path string) string { - encoded := EncodePath(path) - return filepath.Join(claudeDir, "projects", encoded, "memory") -} - -// RehydrateMemory copies memory files from source repo to worktree. -// Only copies files that don't already exist in the worktree. -// Returns the number of files copied. -func RehydrateMemory(claudeDir, sourceRepo, worktreePath string) int { - srcDir := MemoryDirFor(claudeDir, sourceRepo) - dstDir := MemoryDirFor(claudeDir, worktreePath) - - entries, err := os.ReadDir(srcDir) - if err != nil { - return 0 - } - - count := 0 - for _, entry := range entries { - if entry.IsDir() { - continue - } - dstPath := filepath.Join(dstDir, entry.Name()) - if _, err := os.Stat(dstPath); err == nil { - // Already exists — skip - continue - } - - srcPath := filepath.Join(srcDir, entry.Name()) - if copyFile(srcPath, dstPath) == nil { - count++ - } - } - return count -} - -// HarvestMemory copies new/newer memory files from worktree back to source. -// Uses mtime comparison: only copies if worktree file is newer than source copy. -// Returns the number of files copied. -func HarvestMemory(claudeDir, worktreePath, sourceRepo string) int { - wtDir := MemoryDirFor(claudeDir, worktreePath) - srcDir := MemoryDirFor(claudeDir, sourceRepo) - - entries, err := os.ReadDir(wtDir) - if err != nil { - return 0 - } - - count := 0 - for _, entry := range entries { - if entry.IsDir() { - continue - } - wtPath := filepath.Join(wtDir, entry.Name()) - srcPath := filepath.Join(srcDir, entry.Name()) - - wtInfo, err := os.Stat(wtPath) - if err != nil { - continue - } - - // Check if source exists and is newer - srcInfo, err := os.Stat(srcPath) - if err == nil && srcInfo.ModTime().After(wtInfo.ModTime()) { - // Source is newer — don't overwrite - continue - } - if err == nil && srcInfo.ModTime().Equal(wtInfo.ModTime()) { - continue - } - - if copyFile(wtPath, srcPath) == nil { - count++ - } - } - return count -} - -// MigrateMemoryDir moves a memory directory from oldPath to newPath. -// If the target already exists, merges files from old into new. -// Returns true if migration succeeded or source didn't exist. -func MigrateMemoryDir(claudeDir, oldPath, newPath string) bool { - oldProjectDir := filepath.Join(claudeDir, "projects", EncodePath(oldPath)) - newProjectDir := filepath.Join(claudeDir, "projects", EncodePath(newPath)) - - if _, err := os.Stat(oldProjectDir); os.IsNotExist(err) { - return false - } - - // Check if target exists - if _, err := os.Stat(newProjectDir); err == nil { - // Merge: copy files from old memory/ to new memory/ - oldMemDir := filepath.Join(oldProjectDir, "memory") - newMemDir := filepath.Join(newProjectDir, "memory") - os.MkdirAll(newMemDir, 0o755) - - entries, _ := os.ReadDir(oldMemDir) - for _, entry := range entries { - if entry.IsDir() { - continue - } - dst := filepath.Join(newMemDir, entry.Name()) - if _, err := os.Stat(dst); err == nil { - continue // don't overwrite existing - } - copyFile(filepath.Join(oldMemDir, entry.Name()), dst) - } - os.RemoveAll(oldProjectDir) - } else { - // Simple rename - os.MkdirAll(filepath.Dir(newProjectDir), 0o755) - if err := os.Rename(oldProjectDir, newProjectDir); err != nil { - return false - } - } - - return true -} - -// FindOrphanedMemoryDirs finds Claude project dirs for worktree paths that -// no longer exist. Uses the workspace dir prefix to scope the search (avoids -// flagging non-grove Claude projects). Checks only workspace and worktree-level -// paths (not a full recursive walk). -func FindOrphanedMemoryDirs(claudeDir string, workspaceDir string) []string { - projectsDir := filepath.Join(claudeDir, "projects") - entries, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - // Encoded workspace dir prefix for scoping - wsDirEncoded := EncodePath(workspaceDir) - - // Build set of active paths: workspace dir + each immediate child (worktree dirs). - // This avoids walking into git repos, node_modules, etc. - activePaths := make(map[string]bool) - activePaths[EncodePath(workspaceDir)] = true - wsEntries, _ := os.ReadDir(workspaceDir) - for _, ws := range wsEntries { - if !ws.IsDir() { - continue - } - wsPath := filepath.Join(workspaceDir, ws.Name()) - activePaths[EncodePath(wsPath)] = true - // Worktree dirs are one level deeper - wtEntries, _ := os.ReadDir(wsPath) - for _, wt := range wtEntries { - if !wt.IsDir() { - continue - } - activePaths[EncodePath(filepath.Join(wsPath, wt.Name()))] = true - } - } - - var orphaned []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - name := entry.Name() - projDir := filepath.Join(projectsDir, name) - - // Only consider dirs that have a memory/ subdirectory - memDir := filepath.Join(projDir, "memory") - if _, err := os.Stat(memDir); os.IsNotExist(err) { - continue - } - - // Only consider dirs scoped to the workspace directory - if !strings.HasPrefix(name, wsDirEncoded) { - continue - } - - // If this encoded path matches an active directory, it's not orphaned - if activePaths[name] { - continue - } - - orphaned = append(orphaned, projDir) - } - - return orphaned -} - -// copyFile copies src to dst atomically, creating parent directories as needed. -func copyFile(src, dst string) error { - os.MkdirAll(filepath.Dir(dst), 0o755) - - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - tmp := dst + ".tmp" - out, err := os.Create(tmp) - if err != nil { - return err - } - - if _, err := io.Copy(out, in); err != nil { - out.Close() - os.Remove(tmp) - return err - } - out.Close() - - return os.Rename(tmp, dst) -} diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go deleted file mode 100644 index 37b4448..0000000 --- a/internal/claude/claude_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package claude - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestEncodePath(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"/Users/nick/.grove/workspaces/feat-foo", "-Users-nick--grove-workspaces-feat-foo"}, - {"/home/user/dev/api", "-home-user-dev-api"}, - {"/tmp/test", "-tmp-test"}, - } - for _, tt := range tests { - got := EncodePath(tt.input) - if got != tt.want { - t.Errorf("EncodePath(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestMemoryDirFor(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - dir := MemoryDirFor(claudeDir, "/Users/nick/dev/api") - if dir == "" { - t.Error("expected non-empty dir") - } - if !filepath.IsAbs(dir) { - t.Errorf("expected absolute path, got %q", dir) - } -} - -func TestRehydrateMemory(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - sourceRepo := "/fake/source/repo" - worktreePath := "/fake/worktree/path" - - // Create source memory with a file - sourceMemDir := MemoryDirFor(claudeDir, sourceRepo) - os.MkdirAll(sourceMemDir, 0o755) - os.WriteFile(filepath.Join(sourceMemDir, "context.md"), []byte("source data"), 0o644) - - // Rehydrate should copy to worktree memory - count := RehydrateMemory(claudeDir, sourceRepo, worktreePath) - if count != 1 { - t.Errorf("expected 1 file copied, got %d", count) - } - - // File should exist in worktree memory dir - wtMemDir := MemoryDirFor(claudeDir, worktreePath) - data, err := os.ReadFile(filepath.Join(wtMemDir, "context.md")) - if err != nil { - t.Fatalf("file not copied: %v", err) - } - if string(data) != "source data" { - t.Errorf("content mismatch: %q", string(data)) - } -} - -func TestRehydrateSkipsExisting(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - sourceRepo := "/fake/source" - worktreePath := "/fake/worktree" - - // Create source memory - sourceMemDir := MemoryDirFor(claudeDir, sourceRepo) - os.MkdirAll(sourceMemDir, 0o755) - os.WriteFile(filepath.Join(sourceMemDir, "existing.md"), []byte("source"), 0o644) - - // Create worktree memory with the same file (different content) - wtMemDir := MemoryDirFor(claudeDir, worktreePath) - os.MkdirAll(wtMemDir, 0o755) - os.WriteFile(filepath.Join(wtMemDir, "existing.md"), []byte("worktree version"), 0o644) - - // Rehydrate should skip since file exists - count := RehydrateMemory(claudeDir, sourceRepo, worktreePath) - if count != 0 { - t.Errorf("expected 0 files copied (skip existing), got %d", count) - } - - // Content should be unchanged - data, _ := os.ReadFile(filepath.Join(wtMemDir, "existing.md")) - if string(data) != "worktree version" { - t.Error("existing file should not be overwritten") - } -} - -func TestRehydrateNoSource(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - count := RehydrateMemory(claudeDir, "/no/source", "/no/worktree") - if count != 0 { - t.Errorf("expected 0 for missing source, got %d", count) - } -} - -func TestHarvestMemory(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - sourceRepo := "/fake/source" - worktreePath := "/fake/worktree" - - // Create worktree memory with a new file - wtMemDir := MemoryDirFor(claudeDir, worktreePath) - os.MkdirAll(wtMemDir, 0o755) - os.WriteFile(filepath.Join(wtMemDir, "new-learning.md"), []byte("learned something"), 0o644) - - // Harvest should copy back to source - count := HarvestMemory(claudeDir, worktreePath, sourceRepo) - if count != 1 { - t.Errorf("expected 1 file harvested, got %d", count) - } - - sourceMemDir := MemoryDirFor(claudeDir, sourceRepo) - data, err := os.ReadFile(filepath.Join(sourceMemDir, "new-learning.md")) - if err != nil { - t.Fatalf("file not harvested: %v", err) - } - if string(data) != "learned something" { - t.Errorf("content mismatch: %q", string(data)) - } -} - -func TestHarvestOverwritesOlder(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - sourceRepo := "/fake/source" - worktreePath := "/fake/worktree" - - // Create source memory with an old file - sourceMemDir := MemoryDirFor(claudeDir, sourceRepo) - os.MkdirAll(sourceMemDir, 0o755) - os.WriteFile(filepath.Join(sourceMemDir, "evolving.md"), []byte("old"), 0o644) - // Set old mtime - oldTime := time.Now().Add(-1 * time.Hour) - os.Chtimes(filepath.Join(sourceMemDir, "evolving.md"), oldTime, oldTime) - - // Create worktree memory with newer version - wtMemDir := MemoryDirFor(claudeDir, worktreePath) - os.MkdirAll(wtMemDir, 0o755) - os.WriteFile(filepath.Join(wtMemDir, "evolving.md"), []byte("new"), 0o644) - - count := HarvestMemory(claudeDir, worktreePath, sourceRepo) - if count != 1 { - t.Errorf("expected 1 file harvested, got %d", count) - } - - data, _ := os.ReadFile(filepath.Join(sourceMemDir, "evolving.md")) - if string(data) != "new" { - t.Errorf("should overwrite older: got %q", string(data)) - } -} - -func TestHarvestPreservesNewer(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - sourceRepo := "/fake/source" - worktreePath := "/fake/worktree" - - // Create source memory with a NEWER file - sourceMemDir := MemoryDirFor(claudeDir, sourceRepo) - os.MkdirAll(sourceMemDir, 0o755) - os.WriteFile(filepath.Join(sourceMemDir, "recent.md"), []byte("fresh"), 0o644) - - // Create worktree memory with an OLDER version - wtMemDir := MemoryDirFor(claudeDir, worktreePath) - os.MkdirAll(wtMemDir, 0o755) - os.WriteFile(filepath.Join(wtMemDir, "recent.md"), []byte("stale"), 0o644) - oldTime := time.Now().Add(-1 * time.Hour) - os.Chtimes(filepath.Join(wtMemDir, "recent.md"), oldTime, oldTime) - - count := HarvestMemory(claudeDir, worktreePath, sourceRepo) - if count != 0 { - t.Errorf("expected 0 (preserve newer), got %d", count) - } - - data, _ := os.ReadFile(filepath.Join(sourceMemDir, "recent.md")) - if string(data) != "fresh" { - t.Error("newer source should not be overwritten") - } -} - -func TestHarvestNoWorktreeMemory(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - count := HarvestMemory(claudeDir, "/no/worktree", "/no/source") - if count != 0 { - t.Errorf("expected 0, got %d", count) - } -} - -func TestMigrateMemoryDir(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - oldPath := "/fake/old/path" - newPath := "/fake/new/path" - - // Create memory for old path - oldMemDir := MemoryDirFor(claudeDir, oldPath) - os.MkdirAll(oldMemDir, 0o755) - os.WriteFile(filepath.Join(oldMemDir, "data.md"), []byte("content"), 0o644) - - ok := MigrateMemoryDir(claudeDir, oldPath, newPath) - if !ok { - t.Error("migration should succeed") - } - - // Old should be gone - oldProjectDir := filepath.Dir(oldMemDir) // project dir (parent of memory/) - if _, err := os.Stat(oldProjectDir); !os.IsNotExist(err) { - t.Error("old project dir should be removed") - } - - // New should exist - newMemDir := MemoryDirFor(claudeDir, newPath) - data, err := os.ReadFile(filepath.Join(newMemDir, "data.md")) - if err != nil { - t.Fatalf("migrated file missing: %v", err) - } - if string(data) != "content" { - t.Errorf("content mismatch: %q", string(data)) - } -} - -func TestMigrateMemoryDirMerge(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - oldPath := "/fake/old" - newPath := "/fake/new" - - // Create memory for both - oldMemDir := MemoryDirFor(claudeDir, oldPath) - os.MkdirAll(oldMemDir, 0o755) - os.WriteFile(filepath.Join(oldMemDir, "old-only.md"), []byte("from old"), 0o644) - - newMemDir := MemoryDirFor(claudeDir, newPath) - os.MkdirAll(newMemDir, 0o755) - os.WriteFile(filepath.Join(newMemDir, "new-only.md"), []byte("from new"), 0o644) - - ok := MigrateMemoryDir(claudeDir, oldPath, newPath) - if !ok { - t.Error("merge migration should succeed") - } - - // Both files should exist in new - if _, err := os.Stat(filepath.Join(newMemDir, "old-only.md")); os.IsNotExist(err) { - t.Error("old file should be merged into new") - } - if _, err := os.Stat(filepath.Join(newMemDir, "new-only.md")); os.IsNotExist(err) { - t.Error("new file should be preserved") - } -} - -func TestMigrateNoSource(t *testing.T) { - home := t.TempDir() - claudeDir := filepath.Join(home, ".claude") - - ok := MigrateMemoryDir(claudeDir, "/no/exist", "/fake/new") - if ok { - t.Error("expected false for missing source") - } -} diff --git a/internal/hook/hook.go b/internal/hook/hook.go deleted file mode 100644 index ffad498..0000000 --- a/internal/hook/hook.go +++ /dev/null @@ -1,409 +0,0 @@ -package hook - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "time" -) - -var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - -// StatusData is the state written to ~/.grove/status/{session_id}.json. -type StatusData struct { - SessionID string `json:"session_id"` - Status string `json:"status"` - CWD string `json:"cwd,omitempty"` - ProjectName string `json:"project_name,omitempty"` - StartedAt string `json:"started_at,omitempty"` - Model string `json:"model,omitempty"` - LastEvent string `json:"last_event"` - LastEventTime string `json:"last_event_time"` - LastTool string `json:"last_tool,omitempty"` - LastMessage string `json:"last_message,omitempty"` - LastError string `json:"last_error,omitempty"` - ToolCount int `json:"tool_count"` - ErrorCount int `json:"error_count"` - SubagentCount int `json:"subagent_count"` - CompactCount int `json:"compact_count"` - ActivityHistory []int `json:"activity_history"` - GitBranch string `json:"git_branch,omitempty"` - GitDirtyCount int `json:"git_dirty_count"` - PID int `json:"pid"` - ZellijSession string `json:"zellij_session,omitempty"` - PermissionMode string `json:"permission_mode,omitempty"` - SessionSource string `json:"session_source,omitempty"` - InitialPrompt string `json:"initial_prompt,omitempty"` - CompactTrigger string `json:"compact_trigger,omitempty"` - NotificationMessage *string `json:"notification_message"` - ToolRequestSummary *string `json:"tool_request_summary"` - ActiveSubagents []string `json:"active_subagents"` -} - -// Handler processes Claude Code hook events. -// All dependencies are injectable for testing. -type Handler struct { - StatusDir string - NowFn func() time.Time - PidFn func() int - EnvFn func(string) string - GitBranchFn func(dir string) string - GitDirtyFn func(dir string) int -} - -// NewHandler creates a Handler with real dependencies. -func NewHandler(groveDir string) *Handler { - return &Handler{ - StatusDir: filepath.Join(groveDir, "status"), - NowFn: time.Now, - PidFn: os.Getppid, - EnvFn: os.Getenv, - GitBranchFn: defaultGitBranch, - GitDirtyFn: defaultGitDirtyCount, - } -} - -func (h *Handler) statusPath(sessionID string) string { - return filepath.Join(h.StatusDir, sessionID+".json") -} - -// HandleEvent processes a Claude Code hook event. -func (h *Handler) HandleEvent(event string, payload map[string]any) { - sessionID, _ := payload["session_id"].(string) - if sessionID == "" { - return - } - if !validSessionID.MatchString(sessionID) { - return - } - - if event == "SessionEnd" { - os.Remove(h.statusPath(sessionID)) - return - } - - // Load or create status - data := h.loadStatus(sessionID) - now := h.NowFn().UTC().Format("2006-01-02T15:04:05Z") - - data.SessionID = sessionID - data.LastEvent = event - data.LastEventTime = now - data.PID = h.PidFn() - - // Bootstrap: set project_name from cwd if missing (handles mid-session hook install) - if cwd, ok := payload["cwd"].(string); ok && cwd != "" && data.CWD == "" { - data.CWD = cwd - } - if data.ProjectName == "" && data.CWD != "" { - data.ProjectName = filepath.Base(data.CWD) - } - - // Track permission mode (available on every event) - if pm, ok := payload["permission_mode"].(string); ok && pm != "" { - data.PermissionMode = pm - } - - switch event { - case "SessionStart": - cwd, _ := payload["cwd"].(string) - data.Status = "IDLE" - data.StartedAt = now - data.CWD = cwd - if cwd != "" { - data.ProjectName = filepath.Base(cwd) - data.GitBranch = h.GitBranchFn(cwd) - data.GitDirtyCount = h.GitDirtyFn(cwd) - } - if model, ok := payload["model"].(string); ok { - data.Model = model - } - if pm, ok := payload["permission_mode"].(string); ok { - data.PermissionMode = pm - } - if src, ok := payload["source"].(string); ok { - data.SessionSource = src - } - data.ActivityHistory = make([]int, 10) - data.ActiveSubagents = nil - data.NotificationMessage = nil - data.ToolRequestSummary = nil - data.InitialPrompt = "" - data.CompactCount = 0 - data.CompactTrigger = "" - data.ToolCount = 0 - data.ErrorCount = 0 - data.SubagentCount = 0 - data.ZellijSession = h.EnvFn("ZELLIJ_SESSION_NAME") - - case "PreToolUse": - data.Status = "WORKING" - data.ToolCount++ - data.NotificationMessage = nil - data.ToolRequestSummary = nil - if tool, ok := payload["tool_name"].(string); ok { - data.LastTool = tool - } - bumpActivity(data) - - case "PostToolUse": - data.Status = "WORKING" - data.NotificationMessage = nil - data.ToolRequestSummary = nil - - case "PostToolUseFailure": - data.Status = "ERROR" - data.ErrorCount++ - data.ToolRequestSummary = nil - if errMsg, ok := payload["error"].(string); ok { - if len(errMsg) > 500 { - errMsg = errMsg[:500] - } - data.LastError = errMsg - } - - case "Stop": - if data.Status != "WAITING_ANSWER" { - data.Status = "IDLE" - } - if msg, ok := payload["last_assistant_message"].(string); ok { - if len(msg) > 500 { - msg = msg[:500] - } - data.LastMessage = msg - } - - case "PermissionRequest": - data.Status = "WAITING_PERMISSION" - if tool, ok := payload["tool_name"].(string); ok { - data.LastTool = tool - } - data.ToolRequestSummary = toolSummary(payload) - - case "Notification": - msg, _ := payload["message"].(string) - data.NotificationMessage = &msg - lower := strings.ToLower(msg) - if strings.Contains(lower, "permission") { - data.Status = "WAITING_PERMISSION" - } else if containsAny(lower, "question", "input", "answer", "elicitation") { - data.Status = "WAITING_ANSWER" - } - - case "UserPromptSubmit": - data.Status = "WORKING" - data.NotificationMessage = nil - data.ToolRequestSummary = nil - if prompt, ok := payload["prompt"].(string); ok && prompt != "" && data.InitialPrompt == "" { - if len(prompt) > 300 { - prompt = prompt[:300] - } - data.InitialPrompt = prompt - } - - case "SubagentStart": - data.SubagentCount++ - if agentType, ok := payload["agent_type"].(string); ok && agentType != "" { - data.ActiveSubagents = append(data.ActiveSubagents, agentType) - if len(data.ActiveSubagents) > 5 { - data.ActiveSubagents = data.ActiveSubagents[len(data.ActiveSubagents)-5:] - } - } - - case "SubagentStop": - if data.SubagentCount > 0 { - data.SubagentCount-- - } - if agentType, ok := payload["agent_type"].(string); ok && agentType != "" { - for i, a := range data.ActiveSubagents { - if a == agentType { - data.ActiveSubagents = append(data.ActiveSubagents[:i], data.ActiveSubagents[i+1:]...) - break - } - } - } - - case "PreCompact": - data.CompactCount++ - if trigger, ok := payload["trigger"].(string); ok { - data.CompactTrigger = trigger - } else { - data.CompactTrigger = "auto" - } - - case "TaskCompleted": - // no-op (event + time already recorded above) - } - - h.saveStatus(data) -} - -func (h *Handler) loadStatus(sessionID string) *StatusData { - path := h.statusPath(sessionID) - rawData, err := os.ReadFile(path) - if err != nil { - return &StatusData{SessionID: sessionID} - } - var data StatusData - if err := json.Unmarshal(rawData, &data); err != nil { - return &StatusData{SessionID: sessionID} - } - return &data -} - -func (h *Handler) saveStatus(data *StatusData) { - os.MkdirAll(h.StatusDir, 0o755) - - raw, err := json.MarshalIndent(data, "", " ") - if err != nil { - return - } - - tmp := h.statusPath(data.SessionID) + ".tmp" - if err := os.WriteFile(tmp, raw, 0o644); err != nil { - return - } - os.Rename(tmp, h.statusPath(data.SessionID)) -} - -// containsAny returns true if s contains any of the given substrings. -func containsAny(s string, subs ...string) bool { - for _, sub := range subs { - if strings.Contains(s, sub) { - return true - } - } - return false -} - -// toolSummary builds a human-readable summary of a tool request from the payload. -// Returns nil if no tool_input is present. -func toolSummary(payload map[string]any) *string { - toolInput, ok := payload["tool_input"].(map[string]any) - if !ok { - return nil - } - - toolName, _ := payload["tool_name"].(string) - var summary string - - switch toolName { - case "Bash": - cmd, _ := toolInput["command"].(string) - if cmd == "" { - return nil - } - if len(cmd) > 300 { - cmd = cmd[:300] - } - summary = "$ " + cmd - - case "Edit": - fp, _ := toolInput["file_path"].(string) - oldStr, _ := toolInput["old_string"].(string) - newStr, _ := toolInput["new_string"].(string) - var parts []string - parts = append(parts, fp) - oldLines := strings.SplitN(oldStr, "\n", 4) - for _, line := range oldLines[:min(3, len(oldLines))] { - parts = append(parts, "- "+line) - } - newLines := strings.SplitN(newStr, "\n", 4) - for _, line := range newLines[:min(3, len(newLines))] { - parts = append(parts, "+ "+line) - } - summary = strings.Join(parts, "\n") - - case "Write": - fp, _ := toolInput["file_path"].(string) - content, _ := toolInput["content"].(string) - lines := len(strings.Split(content, "\n")) - if content == "" { - lines = 0 - } else if strings.HasSuffix(content, "\n") { - lines-- - } - summary = fmt.Sprintf("%s (%d lines)", fp, lines) - - case "Read": - fp, _ := toolInput["file_path"].(string) - if fp == "" { - return nil - } - summary = fp - - case "WebFetch": - url, _ := toolInput["url"].(string) - if url == "" { - return nil - } - summary = url - - case "Grep", "Glob": - pat, _ := toolInput["pattern"].(string) - p, _ := toolInput["path"].(string) - if p != "" { - summary = pat + " in " + p - } else { - summary = pat - } - - default: - data, err := json.Marshal(toolInput) - if err != nil { - return nil - } - s := string(data) - if len(s) > 300 { - s = s[:300] - } - summary = s - } - - return &summary -} - -func bumpActivity(data *StatusData) { - if len(data.ActivityHistory) == 0 { - data.ActivityHistory = make([]int, 10) - } - // Shift left and increment last bucket - data.ActivityHistory = append(data.ActivityHistory[1:], data.ActivityHistory[len(data.ActivityHistory)-1]+1) -} - -func defaultGitBranch(dir string) string { - cmd := exec.Command("git", "branch", "--show-current") - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} - -func defaultGitDirtyCount(dir string) int { - cmd := exec.Command("git", "status", "--short") - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return 0 - } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if len(lines) == 1 && lines[0] == "" { - return 0 - } - return len(lines) -} - -// WriteStatusLine writes the status to stderr in a format suitable for shell prompts. -func (h *Handler) WriteStatusLine(sessionID string) string { - data := h.loadStatus(sessionID) - if data.Status == "" { - return "" - } - return fmt.Sprintf("%s [%s] %s", data.SessionID, data.Status, data.LastTool) -} diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go deleted file mode 100644 index 77befe9..0000000 --- a/internal/hook/hook_test.go +++ /dev/null @@ -1,748 +0,0 @@ -package hook - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func testHandler(t *testing.T) *Handler { - t.Helper() - dir := t.TempDir() - groveDir := filepath.Join(dir, ".grove") - os.MkdirAll(groveDir, 0o755) - return &Handler{ - StatusDir: filepath.Join(groveDir, "status"), - NowFn: time.Now, - PidFn: func() int { return 12345 }, - EnvFn: func(string) string { return "" }, - GitBranchFn: func(string) string { return "main" }, - GitDirtyFn: func(string) int { return 0 }, - } -} - -func readStatus(t *testing.T, h *Handler, sessionID string) StatusData { - t.Helper() - var data StatusData - raw, _ := os.ReadFile(h.statusPath(sessionID)) - json.Unmarshal(raw, &data) - return data -} - -func TestSessionStartCreatesFile(t *testing.T) { - h := testHandler(t) - - payload := map[string]any{ - "session_id": "test-123", - "cwd": t.TempDir(), - "model": "claude-4", - } - h.HandleEvent("SessionStart", payload) - - path := h.statusPath("test-123") - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Fatal("status file should exist") - } - - data := readStatus(t, h, "test-123") - if data.Status != "IDLE" { - t.Errorf("status: got %q, want IDLE", data.Status) - } - if data.SessionID != "test-123" { - t.Errorf("session_id: got %q", data.SessionID) - } - if data.Model != "claude-4" { - t.Errorf("model: got %q", data.Model) - } -} - -func TestSessionStartRecordsPID(t *testing.T) { - h := testHandler(t) - h.PidFn = func() int { return 99999 } - - h.HandleEvent("SessionStart", map[string]any{"session_id": "pid-test"}) - - data := readStatus(t, h, "pid-test") - if data.PID != 99999 { - t.Errorf("PID: got %d, want 99999", data.PID) - } -} - -func TestSessionStartRecordsZellijSession(t *testing.T) { - h := testHandler(t) - h.EnvFn = func(key string) string { - if key == "ZELLIJ_SESSION_NAME" { - return "my-session" - } - return "" - } - - h.HandleEvent("SessionStart", map[string]any{"session_id": "zellij-test"}) - - data := readStatus(t, h, "zellij-test") - if data.ZellijSession != "my-session" { - t.Errorf("zellij_session: got %q, want my-session", data.ZellijSession) - } -} - -func TestSessionStartRecordsGitBranch(t *testing.T) { - h := testHandler(t) - h.GitBranchFn = func(string) string { return "feat/cool" } - h.GitDirtyFn = func(string) int { return 3 } - - h.HandleEvent("SessionStart", map[string]any{ - "session_id": "git-test", - "cwd": "/some/dir", - }) - - data := readStatus(t, h, "git-test") - if data.GitBranch != "feat/cool" { - t.Errorf("git_branch: got %q, want feat/cool", data.GitBranch) - } - if data.GitDirtyCount != 3 { - t.Errorf("git_dirty_count: got %d, want 3", data.GitDirtyCount) - } -} - -func TestSessionStartRecordsTimestamp(t *testing.T) { - h := testHandler(t) - fixed := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) - h.NowFn = func() time.Time { return fixed } - - h.HandleEvent("SessionStart", map[string]any{"session_id": "time-test"}) - - data := readStatus(t, h, "time-test") - if data.StartedAt != "2025-06-15T12:00:00Z" { - t.Errorf("started_at: got %q", data.StartedAt) - } -} - -func TestSessionEndRemovesFile(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "end-test"}) - h.HandleEvent("SessionEnd", map[string]any{"session_id": "end-test"}) - - path := h.statusPath("end-test") - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Error("status file should be removed") - } -} - -func TestInvalidSessionIDRejected(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "../../../etc/passwd"}) - path := h.statusPath("../../../etc/passwd") - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Error("should reject path traversal session IDs") - } -} - -func TestEmptySessionIDRejected(t *testing.T) { - h := testHandler(t) - h.HandleEvent("SessionStart", map[string]any{"session_id": ""}) - // No crash = pass -} - -func TestPreToolUseSetsWorking(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "tool-test"}) - h.HandleEvent("PreToolUse", map[string]any{ - "session_id": "tool-test", - "tool_name": "Read", - }) - - data := readStatus(t, h, "tool-test") - if data.Status != "WORKING" { - t.Errorf("status: got %q, want WORKING", data.Status) - } - if data.ToolCount != 1 { - t.Errorf("tool_count: got %d, want 1", data.ToolCount) - } - if data.LastTool != "Read" { - t.Errorf("last_tool: got %q, want Read", data.LastTool) - } -} - -func TestPostToolUseFailureSetsError(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "err-test"}) - h.HandleEvent("PostToolUseFailure", map[string]any{ - "session_id": "err-test", - "error": "file not found", - }) - - data := readStatus(t, h, "err-test") - if data.Status != "ERROR" { - t.Errorf("status: got %q, want ERROR", data.Status) - } - if data.ErrorCount != 1 { - t.Errorf("error_count: got %d, want 1", data.ErrorCount) - } - if data.LastError != "file not found" { - t.Errorf("last_error: got %q", data.LastError) - } -} - -func TestPermissionRequestSetsWaiting(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "perm-test"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "perm-test", - "tool_name": "Bash", - }) - - data := readStatus(t, h, "perm-test") - if data.Status != "WAITING_PERMISSION" { - t.Errorf("status: got %q, want WAITING_PERMISSION", data.Status) - } -} - -func TestStopPreservesWaitingAnswer(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "stop-test"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "stop-test", - "message": "I have a question for you", - }) - h.HandleEvent("Stop", map[string]any{"session_id": "stop-test"}) - - data := readStatus(t, h, "stop-test") - if data.Status != "WAITING_ANSWER" { - t.Errorf("Stop should preserve WAITING_ANSWER, got %q", data.Status) - } -} - -func TestStopResetsToIdle(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "idle-test"}) - h.HandleEvent("PreToolUse", map[string]any{"session_id": "idle-test", "tool_name": "Read"}) - h.HandleEvent("Stop", map[string]any{"session_id": "idle-test"}) - - data := readStatus(t, h, "idle-test") - if data.Status != "IDLE" { - t.Errorf("Stop should reset WORKING to IDLE, got %q", data.Status) - } -} - -func TestSubagentCounting(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "sub-test"}) - h.HandleEvent("SubagentStart", map[string]any{"session_id": "sub-test"}) - h.HandleEvent("SubagentStart", map[string]any{"session_id": "sub-test"}) - - data := readStatus(t, h, "sub-test") - if data.SubagentCount != 2 { - t.Errorf("subagent_count: got %d, want 2", data.SubagentCount) - } -} - -func TestActivityHistoryShifts(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "activity-test"}) - for i := 0; i < 3; i++ { - h.HandleEvent("PreToolUse", map[string]any{"session_id": "activity-test", "tool_name": "Read"}) - } - - data := readStatus(t, h, "activity-test") - if len(data.ActivityHistory) != 10 { - t.Fatalf("expected 10 buckets, got %d", len(data.ActivityHistory)) - } - last := data.ActivityHistory[len(data.ActivityHistory)-1] - if last == 0 { - t.Error("last activity bucket should be non-zero") - } -} - -func TestBootstrapWithoutSessionStart(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("PreToolUse", map[string]any{ - "session_id": "bootstrap-test", - "tool_name": "Bash", - }) - - data := readStatus(t, h, "bootstrap-test") - if data.Status != "WORKING" { - t.Errorf("status: got %q, want WORKING", data.Status) - } - if data.LastTool != "Bash" { - t.Errorf("last_tool: got %q, want Bash", data.LastTool) - } -} - -func TestErrorMessageTruncation(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "trunc-test"}) - - longError := "" - for i := 0; i < 600; i++ { - longError += "x" - } - h.HandleEvent("PostToolUseFailure", map[string]any{ - "session_id": "trunc-test", - "error": longError, - }) - - data := readStatus(t, h, "trunc-test") - if len(data.LastError) > 500 { - t.Errorf("error should be truncated to 500, got %d", len(data.LastError)) - } -} - -// --------------------------------------------------------------------------- -// Tests for parity with Python hook handler -// --------------------------------------------------------------------------- - -func TestSessionStartSetsProjectName(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{ - "session_id": "proj-test", - "cwd": "/home/user/projects/my-app", - }) - - data := readStatus(t, h, "proj-test") - if data.ProjectName != "my-app" { - t.Errorf("project_name: got %q, want my-app", data.ProjectName) - } -} - -func TestSessionStartRecordsSessionSource(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{ - "session_id": "src-test", - "source": "vscode", - "permission_mode": "plan", - }) - - data := readStatus(t, h, "src-test") - if data.SessionSource != "vscode" { - t.Errorf("session_source: got %q, want vscode", data.SessionSource) - } -} - -func TestNotificationSetsMessage(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "notif-test"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "notif-test", - "message": "Task completed successfully", - }) - - data := readStatus(t, h, "notif-test") - if data.NotificationMessage == nil || *data.NotificationMessage != "Task completed successfully" { - t.Errorf("notification_message: got %v", data.NotificationMessage) - } -} - -func TestNotificationPermissionKeyword(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "notif-perm"}) - h.HandleEvent("PreToolUse", map[string]any{"session_id": "notif-perm", "tool_name": "Bash"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "notif-perm", - "message": "Needs permission to proceed", - }) - - data := readStatus(t, h, "notif-perm") - if data.Status != "WAITING_PERMISSION" { - t.Errorf("status: got %q, want WAITING_PERMISSION", data.Status) - } -} - -func TestNotificationQuestionKeyword(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "notif-q"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "notif-q", - "message": "I have a question for you", - }) - - data := readStatus(t, h, "notif-q") - if data.Status != "WAITING_ANSWER" { - t.Errorf("status: got %q, want WAITING_ANSWER", data.Status) - } -} - -func TestPermissionRequestSetsToolSummaryBash(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "summary-bash"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "summary-bash", - "tool_name": "Bash", - "tool_input": map[string]any{"command": "echo hello"}, - }) - - data := readStatus(t, h, "summary-bash") - if data.ToolRequestSummary == nil || *data.ToolRequestSummary != "$ echo hello" { - t.Errorf("tool_request_summary: got %v", data.ToolRequestSummary) - } -} - -func TestPermissionRequestSetsToolSummaryEdit(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "summary-edit"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "summary-edit", - "tool_name": "Edit", - "tool_input": map[string]any{ - "file_path": "/foo/bar.py", - "old_string": "x = 1", - "new_string": "x = 2", - }, - }) - - data := readStatus(t, h, "summary-edit") - if data.ToolRequestSummary == nil { - t.Fatal("tool_request_summary should not be nil") - } - summary := *data.ToolRequestSummary - if !strings.Contains(summary, "/foo/bar.py") { - t.Errorf("summary should contain file path, got: %s", summary) - } - if !strings.Contains(summary, "- x = 1") { - t.Errorf("summary should contain old_string, got: %s", summary) - } - if !strings.Contains(summary, "+ x = 2") { - t.Errorf("summary should contain new_string, got: %s", summary) - } -} - -func TestPermissionRequestSetsToolSummaryGrep(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "summary-grep"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "summary-grep", - "tool_name": "Grep", - "tool_input": map[string]any{"pattern": "TODO", "path": "/src"}, - }) - - data := readStatus(t, h, "summary-grep") - if data.ToolRequestSummary == nil || *data.ToolRequestSummary != "TODO in /src" { - t.Errorf("tool_request_summary: got %v", data.ToolRequestSummary) - } -} - -func TestPermissionRequestNoToolInput(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "summary-nil"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "summary-nil", - "tool_name": "Bash", - }) - - data := readStatus(t, h, "summary-nil") - if data.ToolRequestSummary != nil { - t.Errorf("tool_request_summary should be nil when no tool_input, got %v", data.ToolRequestSummary) - } -} - -func TestPreToolUseClearsNotification(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "clear-test"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "clear-test", - "message": "some notification", - }) - h.HandleEvent("PreToolUse", map[string]any{ - "session_id": "clear-test", - "tool_name": "Read", - }) - - data := readStatus(t, h, "clear-test") - if data.NotificationMessage != nil { - t.Errorf("notification_message should be cleared on PreToolUse, got %v", data.NotificationMessage) - } - if data.ToolRequestSummary != nil { - t.Errorf("tool_request_summary should be cleared on PreToolUse, got %v", data.ToolRequestSummary) - } -} - -func TestSubagentStopDecrements(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "sub-dec"}) - h.HandleEvent("SubagentStart", map[string]any{"session_id": "sub-dec", "agent_type": "Explore"}) - h.HandleEvent("SubagentStart", map[string]any{"session_id": "sub-dec", "agent_type": "Plan"}) - h.HandleEvent("SubagentStop", map[string]any{"session_id": "sub-dec", "agent_type": "Explore"}) - - data := readStatus(t, h, "sub-dec") - if data.SubagentCount != 1 { - t.Errorf("subagent_count: got %d, want 1 after start(2)+stop(1)", data.SubagentCount) - } -} - -func TestSubagentStopFloorsAtZero(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "sub-floor"}) - h.HandleEvent("SubagentStop", map[string]any{"session_id": "sub-floor"}) - - data := readStatus(t, h, "sub-floor") - if data.SubagentCount != 0 { - t.Errorf("subagent_count: got %d, want 0 (floor)", data.SubagentCount) - } -} - -func TestActiveSubagentsTracked(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "active-sub"}) - h.HandleEvent("SubagentStart", map[string]any{"session_id": "active-sub", "agent_type": "Explore"}) - h.HandleEvent("SubagentStart", map[string]any{"session_id": "active-sub", "agent_type": "Plan"}) - - data := readStatus(t, h, "active-sub") - if len(data.ActiveSubagents) != 2 { - t.Errorf("active_subagents length: got %d, want 2", len(data.ActiveSubagents)) - } - - h.HandleEvent("SubagentStop", map[string]any{"session_id": "active-sub", "agent_type": "Explore"}) - data = readStatus(t, h, "active-sub") - if len(data.ActiveSubagents) != 1 { - t.Errorf("active_subagents length after stop: got %d, want 1", len(data.ActiveSubagents)) - } - if len(data.ActiveSubagents) > 0 && data.ActiveSubagents[0] != "Plan" { - t.Errorf("remaining subagent: got %q, want Plan", data.ActiveSubagents[0]) - } -} - -func TestPreCompactTracksCount(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "compact-test"}) - h.HandleEvent("PreCompact", map[string]any{ - "session_id": "compact-test", - "trigger": "auto", - }) - h.HandleEvent("PreCompact", map[string]any{ - "session_id": "compact-test", - "trigger": "manual", - }) - - data := readStatus(t, h, "compact-test") - if data.CompactCount != 2 { - t.Errorf("compact_count: got %d, want 2", data.CompactCount) - } - if data.CompactTrigger != "manual" { - t.Errorf("compact_trigger: got %q, want manual", data.CompactTrigger) - } -} - -func TestUserPromptSubmitCapturesInitialPrompt(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "prompt-test"}) - h.HandleEvent("UserPromptSubmit", map[string]any{ - "session_id": "prompt-test", - "prompt": "Fix the login bug", - }) - - data := readStatus(t, h, "prompt-test") - if data.InitialPrompt != "Fix the login bug" { - t.Errorf("initial_prompt: got %q, want 'Fix the login bug'", data.InitialPrompt) - } - if data.Status != "WORKING" { - t.Errorf("status: got %q, want WORKING", data.Status) - } - - // Second prompt should NOT overwrite - h.HandleEvent("UserPromptSubmit", map[string]any{ - "session_id": "prompt-test", - "prompt": "Also fix the signup bug", - }) - data = readStatus(t, h, "prompt-test") - if data.InitialPrompt != "Fix the login bug" { - t.Errorf("initial_prompt should not change, got %q", data.InitialPrompt) - } -} - -func TestUserPromptSubmitClearsNotification(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "prompt-clear"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "prompt-clear", - "message": "waiting for input", - }) - h.HandleEvent("UserPromptSubmit", map[string]any{ - "session_id": "prompt-clear", - "prompt": "go ahead", - }) - - data := readStatus(t, h, "prompt-clear") - if data.NotificationMessage != nil { - t.Errorf("notification_message should be cleared, got %v", data.NotificationMessage) - } - if data.ToolRequestSummary != nil { - t.Errorf("tool_request_summary should be cleared, got %v", data.ToolRequestSummary) - } -} - -func TestBootstrapSetsProjectName(t *testing.T) { - h := testHandler(t) - - // First event is PreToolUse (mid-session hook install) - h.HandleEvent("PreToolUse", map[string]any{ - "session_id": "boot-proj", - "cwd": "/home/user/my-project", - "tool_name": "Read", - }) - - data := readStatus(t, h, "boot-proj") - if data.ProjectName != "my-project" { - t.Errorf("project_name: got %q, want my-project", data.ProjectName) - } -} - -func TestStopRecordsLastMessage(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "msg-test"}) - h.HandleEvent("Stop", map[string]any{ - "session_id": "msg-test", - "last_assistant_message": "I've completed the task", - }) - - data := readStatus(t, h, "msg-test") - if data.LastMessage != "I've completed the task" { - t.Errorf("last_message: got %q", data.LastMessage) - } -} - -func TestPostToolUseClearsNotification(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "post-clear"}) - h.HandleEvent("Notification", map[string]any{ - "session_id": "post-clear", - "message": "some notification", - }) - h.HandleEvent("PostToolUse", map[string]any{ - "session_id": "post-clear", - }) - - data := readStatus(t, h, "post-clear") - if data.NotificationMessage != nil { - t.Errorf("notification_message should be cleared on PostToolUse, got %v", data.NotificationMessage) - } -} - -func TestNotificationNeutralLeavesStatus(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "notif-neutral"}) - h.HandleEvent("PreToolUse", map[string]any{"session_id": "notif-neutral", "tool_name": "Read"}) - - // Neutral message — no keyword match — should NOT change WORKING status - h.HandleEvent("Notification", map[string]any{ - "session_id": "notif-neutral", - "message": "Task completed successfully", - }) - - data := readStatus(t, h, "notif-neutral") - if data.Status != "WORKING" { - t.Errorf("neutral notification should not change status, got %q", data.Status) - } -} - -func TestStopResetsWaitingPermission(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "stop-perm"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "stop-perm", - "tool_name": "Bash", - }) - - data := readStatus(t, h, "stop-perm") - if data.Status != "WAITING_PERMISSION" { - t.Fatalf("precondition: status should be WAITING_PERMISSION, got %q", data.Status) - } - - // Stop should reset WAITING_PERMISSION to IDLE (unlike WAITING_ANSWER which is preserved) - h.HandleEvent("Stop", map[string]any{"session_id": "stop-perm"}) - - data = readStatus(t, h, "stop-perm") - if data.Status != "IDLE" { - t.Errorf("Stop should reset WAITING_PERMISSION to IDLE, got %q", data.Status) - } -} - -func TestSessionStartNoCwdNoProjectName(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "no-cwd"}) - - data := readStatus(t, h, "no-cwd") - if data.ProjectName != "" { - t.Errorf("project_name should be empty when cwd is empty, got %q", data.ProjectName) - } -} - -func TestWriteSummaryLineCount(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "write-count"}) - - // Trailing newline — should count as 2 lines (matching Python splitlines) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "write-count", - "tool_name": "Write", - "tool_input": map[string]any{ - "file_path": "/tmp/test.py", - "content": "line1\nline2\n", - }, - }) - - data := readStatus(t, h, "write-count") - if data.ToolRequestSummary == nil { - t.Fatal("tool_request_summary should not be nil") - } - if *data.ToolRequestSummary != "/tmp/test.py (2 lines)" { - t.Errorf("summary: got %q, want '/tmp/test.py (2 lines)'", *data.ToolRequestSummary) - } -} - -func TestWriteSummaryEmptyContent(t *testing.T) { - h := testHandler(t) - - h.HandleEvent("SessionStart", map[string]any{"session_id": "write-empty"}) - h.HandleEvent("PermissionRequest", map[string]any{ - "session_id": "write-empty", - "tool_name": "Write", - "tool_input": map[string]any{ - "file_path": "/tmp/empty.py", - "content": "", - }, - }) - - data := readStatus(t, h, "write-empty") - if data.ToolRequestSummary == nil { - t.Fatal("tool_request_summary should not be nil") - } - if *data.ToolRequestSummary != "/tmp/empty.py (0 lines)" { - t.Errorf("summary: got %q, want '/tmp/empty.py (0 lines)'", *data.ToolRequestSummary) - } -} diff --git a/internal/hook/installer.go b/internal/hook/installer.go deleted file mode 100644 index 8700eb1..0000000 --- a/internal/hook/installer.go +++ /dev/null @@ -1,275 +0,0 @@ -package hook - -import ( - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// HookEvents lists all Claude Code hook event types. -var HookEvents = []string{ - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "Stop", - "SessionStart", - "SessionEnd", - "Notification", - "PermissionRequest", - "UserPromptSubmit", - "SubagentStart", - "SubagentStop", - "PreCompact", - "TaskCompleted", -} - -const groveMarker = "gw _hook" - -// Installer manages Claude Code hook registration in settings.json. -type Installer struct { - SettingsPath string - NowFn func() time.Time -} - -// NewInstaller creates an Installer pointing at ~/.claude/settings.json. -func NewInstaller() *Installer { - home, _ := os.UserHomeDir() - return &Installer{ - SettingsPath: filepath.Join(home, ".claude", "settings.json"), - NowFn: time.Now, - } -} - -// ResolveGW finds the gw binary path. -// Prefers PATH lookup; falls back to the current executable. -func ResolveGW() (string, error) { - if path, err := exec.LookPath("gw"); err == nil { - return path, nil - } - // Fallback: use the running binary itself - if exe, err := os.Executable(); err == nil { - return exe, nil - } - return "", fmt.Errorf("gw not found on PATH") -} - -// IsInstalled checks if Grove hooks are present in settings.json. -func (inst *Installer) IsInstalled() bool { - settings, err := inst.loadSettings() - if err != nil { - return false - } - - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - return false - } - - for _, eventHooks := range hooks { - rules, ok := eventHooks.([]any) - if !ok { - continue - } - for _, rule := range rules { - if inst.ruleIsGrove(rule) { - return true - } - } - } - return false -} - -// Install registers Grove hooks in settings.json. -// Returns the number of hooks installed and any error. -func (inst *Installer) Install(gwPath string) (int, error) { - settings, err := inst.loadSettings() - if err != nil { - settings = map[string]any{} - } - - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - hooks = map[string]any{} - } - - count := 0 - for _, event := range HookEvents { - command := fmt.Sprintf("GROVE_EVENT=%s %s _hook --event %s", event, gwPath, event) - ruleEntry := map[string]any{ - "matcher": "", - "hooks": []any{ - map[string]any{ - "type": "command", - "command": command, - }, - }, - } - - eventHooks, _ := hooks[event].([]any) - - // Find existing grove rule - groveIdx := -1 - for i, rule := range eventHooks { - if inst.ruleIsGrove(rule) { - groveIdx = i - break - } - } - - if groveIdx >= 0 { - eventHooks[groveIdx] = ruleEntry - } else { - eventHooks = append(eventHooks, ruleEntry) - count++ - } - - hooks[event] = eventHooks - } - - settings["hooks"] = hooks - - if err := inst.saveSettings(settings); err != nil { - return 0, err - } - - if count == 0 { - count = len(HookEvents) // all updated - } - return count, nil -} - -// Uninstall removes all Grove hooks from settings.json. -// Returns the number of hooks removed. -func (inst *Installer) Uninstall() (int, error) { - settings, err := inst.loadSettings() - if err != nil { - return 0, nil - } - - hooks, ok := settings["hooks"].(map[string]any) - if !ok { - return 0, nil - } - - removed := 0 - for event, eventHooksRaw := range hooks { - eventHooks, ok := eventHooksRaw.([]any) - if !ok { - continue - } - - var filtered []any - for _, rule := range eventHooks { - if inst.ruleIsGrove(rule) { - removed++ - } else { - filtered = append(filtered, rule) - } - } - - if len(filtered) > 0 { - hooks[event] = filtered - } else { - delete(hooks, event) - } - } - - if len(hooks) > 0 { - settings["hooks"] = hooks - } else { - delete(settings, "hooks") - } - - if removed > 0 { - if err := inst.saveSettings(settings); err != nil { - return 0, err - } - } - return removed, nil -} - -// Backup creates a timestamped backup of settings.json. -// Returns the backup path, or empty string if no settings file exists. -func (inst *Installer) Backup() (string, error) { - if _, err := os.Stat(inst.SettingsPath); os.IsNotExist(err) { - return "", nil - } - - ts := inst.NowFn().Format("20060102_150405") - backupPath := inst.SettingsPath + ".bak." + ts - - src, err := os.Open(inst.SettingsPath) - if err != nil { - return "", fmt.Errorf("opening settings: %w", err) - } - defer src.Close() - - dst, err := os.Create(backupPath) - if err != nil { - return "", fmt.Errorf("creating backup: %w", err) - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - return "", fmt.Errorf("copying settings: %w", err) - } - - return backupPath, nil -} - -func (inst *Installer) ruleIsGrove(rule any) bool { - ruleMap, ok := rule.(map[string]any) - if !ok { - return false - } - innerHooks, ok := ruleMap["hooks"].([]any) - if !ok { - return false - } - for _, h := range innerHooks { - hMap, ok := h.(map[string]any) - if !ok { - continue - } - cmd, _ := hMap["command"].(string) - if strings.Contains(cmd, groveMarker) { - return true - } - } - return false -} - -func (inst *Installer) loadSettings() (map[string]any, error) { - data, err := os.ReadFile(inst.SettingsPath) - if err != nil { - return nil, err - } - var settings map[string]any - if err := json.Unmarshal(data, &settings); err != nil { - return nil, fmt.Errorf("parsing settings.json: %w", err) - } - return settings, nil -} - -func (inst *Installer) saveSettings(settings map[string]any) error { - data, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - - if err := os.MkdirAll(filepath.Dir(inst.SettingsPath), 0o755); err != nil { - return err - } - - tmp := inst.SettingsPath + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, inst.SettingsPath) -} diff --git a/internal/hook/installer_test.go b/internal/hook/installer_test.go deleted file mode 100644 index 2934380..0000000 --- a/internal/hook/installer_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package hook - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" -) - -func newTestInstaller(t *testing.T) *Installer { - t.Helper() - dir := t.TempDir() - return &Installer{ - SettingsPath: filepath.Join(dir, "settings.json"), - NowFn: func() time.Time { return time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC) }, - } -} - -func TestInstallCreatesSettings(t *testing.T) { - inst := newTestInstaller(t) - - count, err := inst.Install("/usr/local/bin/gw") - if err != nil { - t.Fatal(err) - } - if count != len(HookEvents) { - t.Errorf("expected %d hooks installed, got %d", len(HookEvents), count) - } - - // Verify file was created - data, err := os.ReadFile(inst.SettingsPath) - if err != nil { - t.Fatal(err) - } - - var settings map[string]any - if err := json.Unmarshal(data, &settings); err != nil { - t.Fatal(err) - } - - hooks := settings["hooks"].(map[string]any) - if len(hooks) != len(HookEvents) { - t.Errorf("expected %d event types, got %d", len(HookEvents), len(hooks)) - } - - // Check one hook entry - sessionStart := hooks["SessionStart"].([]any) - rule := sessionStart[0].(map[string]any) - innerHooks := rule["hooks"].([]any) - hookEntry := innerHooks[0].(map[string]any) - cmd := hookEntry["command"].(string) - if cmd != "GROVE_EVENT=SessionStart /usr/local/bin/gw _hook --event SessionStart" { - t.Errorf("unexpected command: %s", cmd) - } -} - -func TestInstallPreservesExistingSettings(t *testing.T) { - inst := newTestInstaller(t) - - // Write existing settings with a non-hook key - existing := map[string]any{ - "permissions": map[string]any{"allow": true}, - } - data, _ := json.Marshal(existing) - os.MkdirAll(filepath.Dir(inst.SettingsPath), 0o755) - os.WriteFile(inst.SettingsPath, data, 0o644) - - _, err := inst.Install("/usr/local/bin/gw") - if err != nil { - t.Fatal(err) - } - - // Verify existing keys preserved - result, _ := os.ReadFile(inst.SettingsPath) - var settings map[string]any - json.Unmarshal(result, &settings) - - if settings["permissions"] == nil { - t.Error("existing settings key was removed") - } -} - -func TestInstallPreservesExistingHooks(t *testing.T) { - inst := newTestInstaller(t) - - // Write settings with an existing non-grove hook - existing := map[string]any{ - "hooks": map[string]any{ - "PreToolUse": []any{ - map[string]any{ - "matcher": "Write", - "hooks": []any{ - map[string]any{ - "type": "command", - "command": "my-custom-hook", - }, - }, - }, - }, - }, - } - data, _ := json.Marshal(existing) - os.MkdirAll(filepath.Dir(inst.SettingsPath), 0o755) - os.WriteFile(inst.SettingsPath, data, 0o644) - - _, err := inst.Install("/usr/local/bin/gw") - if err != nil { - t.Fatal(err) - } - - result, _ := os.ReadFile(inst.SettingsPath) - var settings map[string]any - json.Unmarshal(result, &settings) - - hooks := settings["hooks"].(map[string]any) - preToolUse := hooks["PreToolUse"].([]any) - - // Should have 2 entries: custom + grove - if len(preToolUse) != 2 { - t.Errorf("expected 2 PreToolUse hooks, got %d", len(preToolUse)) - } -} - -func TestInstallUpdatesExistingGroveHook(t *testing.T) { - inst := newTestInstaller(t) - - // Install once - inst.Install("/usr/local/bin/gw") - - // Install again with different path - _, err := inst.Install("/opt/homebrew/bin/gw") - if err != nil { - t.Fatal(err) - } - - result, _ := os.ReadFile(inst.SettingsPath) - var settings map[string]any - json.Unmarshal(result, &settings) - - hooks := settings["hooks"].(map[string]any) - sessionStart := hooks["SessionStart"].([]any) - - // Should still be just 1 entry, not 2 - if len(sessionStart) != 1 { - t.Errorf("expected 1 hook (updated), got %d", len(sessionStart)) - } - - // Should use new path - rule := sessionStart[0].(map[string]any) - innerHooks := rule["hooks"].([]any) - cmd := innerHooks[0].(map[string]any)["command"].(string) - if cmd != "GROVE_EVENT=SessionStart /opt/homebrew/bin/gw _hook --event SessionStart" { - t.Errorf("hook not updated: %s", cmd) - } -} - -func TestUninstall(t *testing.T) { - inst := newTestInstaller(t) - - inst.Install("/usr/local/bin/gw") - - removed, err := inst.Uninstall() - if err != nil { - t.Fatal(err) - } - if removed != len(HookEvents) { - t.Errorf("expected %d removed, got %d", len(HookEvents), removed) - } - - // Hooks key should be gone - result, _ := os.ReadFile(inst.SettingsPath) - var settings map[string]any - json.Unmarshal(result, &settings) - - if settings["hooks"] != nil { - t.Error("hooks key should be removed when empty") - } -} - -func TestUninstallPreservesOtherHooks(t *testing.T) { - inst := newTestInstaller(t) - - // Install grove hooks - inst.Install("/usr/local/bin/gw") - - // Add a custom hook manually - settings, _ := inst.loadSettings() - hooks := settings["hooks"].(map[string]any) - preToolUse := hooks["PreToolUse"].([]any) - preToolUse = append(preToolUse, map[string]any{ - "matcher": "Write", - "hooks": []any{ - map[string]any{"type": "command", "command": "my-hook"}, - }, - }) - hooks["PreToolUse"] = preToolUse - settings["hooks"] = hooks - inst.saveSettings(settings) - - removed, _ := inst.Uninstall() - if removed != len(HookEvents) { - t.Errorf("expected %d removed, got %d", len(HookEvents), removed) - } - - // Custom hook should remain - result, _ := os.ReadFile(inst.SettingsPath) - var after map[string]any - json.Unmarshal(result, &after) - - afterHooks := after["hooks"].(map[string]any) - afterPTU := afterHooks["PreToolUse"].([]any) - if len(afterPTU) != 1 { - t.Errorf("custom hook should remain, got %d hooks", len(afterPTU)) - } -} - -func TestUninstallNoFile(t *testing.T) { - inst := newTestInstaller(t) - - removed, err := inst.Uninstall() - if err != nil { - t.Fatal(err) - } - if removed != 0 { - t.Errorf("expected 0 removed, got %d", removed) - } -} - -func TestIsInstalled(t *testing.T) { - inst := newTestInstaller(t) - - if inst.IsInstalled() { - t.Error("should not be installed initially") - } - - inst.Install("/usr/local/bin/gw") - - if !inst.IsInstalled() { - t.Error("should be installed after install") - } - - inst.Uninstall() - - if inst.IsInstalled() { - t.Error("should not be installed after uninstall") - } -} - -func TestBackup(t *testing.T) { - inst := newTestInstaller(t) - - // No file — no backup - path, err := inst.Backup() - if err != nil { - t.Fatal(err) - } - if path != "" { - t.Error("should return empty path when no settings file") - } - - // Create a settings file - os.MkdirAll(filepath.Dir(inst.SettingsPath), 0o755) - os.WriteFile(inst.SettingsPath, []byte(`{"foo": "bar"}`), 0o644) - - path, err = inst.Backup() - if err != nil { - t.Fatal(err) - } - if path == "" { - t.Fatal("expected backup path") - } - - // Verify backup content matches - original, _ := os.ReadFile(inst.SettingsPath) - backup, _ := os.ReadFile(path) - if string(original) != string(backup) { - t.Error("backup content doesn't match original") - } - - // Verify backup filename contains timestamp - expected := inst.SettingsPath + ".bak.20260404_120000" - if path != expected { - t.Errorf("expected backup at %s, got %s", expected, path) - } -} diff --git a/internal/models/models.go b/internal/models/models.go index e552623..5e2c06f 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -73,7 +73,6 @@ type Preset struct { type Config struct { RepoDirs []string `toml:"repo_dirs"` WorkspaceDir string `toml:"workspace_dir"` - ClaudeMemorySync bool `toml:"claude_memory_sync"` Presets map[string]Preset `toml:"presets"` Hooks map[string]string `toml:"hooks"` // Legacy field — auto-migrated to RepoDirs diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 31ce17a..8051389 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -9,24 +9,20 @@ import ( "github.com/nicksenap/grove/internal/stats" ) + // Service orchestrates workspace operations with injectable dependencies. type Service struct { State *state.Store Stats *stats.Tracker - ClaudeDir string - WorkspaceDir string // for orphan detection in Doctor RunCmd func(dir, cmd string) error RunCmdSilent func(dir, cmd string) error } // NewService creates a Service with production dependencies. func NewService() *Service { - home, _ := os.UserHomeDir() return &Service{ State: state.NewStore(config.GroveDir), Stats: stats.NewTracker(config.GroveDir), - ClaudeDir: home + "/.claude", - WorkspaceDir: config.DefaultWorkspaceDir, RunCmd: prodRunCmd, RunCmdSilent: prodRunCmdSilent, } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 218aecb..f8c5f6e 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -8,7 +8,6 @@ import ( "strings" "sync" - "github.com/nicksenap/grove/internal/claude" "github.com/nicksenap/grove/internal/console" "github.com/nicksenap/grove/internal/gitops" "github.com/nicksenap/grove/internal/logging" @@ -93,15 +92,6 @@ func (s *Service) Create(name, branch string, repoNames []string, repoMap map[st // Record stats s.Stats.RecordCreated(ws) - // Rehydrate Claude memory - if cfg.ClaudeMemorySync { - for _, r := range ws.Repos { - if n := claude.RehydrateMemory(s.ClaudeDir, r.SourceRepo, r.WorktreePath); n > 0 { - logging.Info("rehydrated %d Claude memory file(s) for %s", n, r.RepoName) - } - } - } - // Write .mcp.json writeMCPConfig(ws) @@ -285,15 +275,6 @@ func (s *Service) Delete(name string) error { logging.Info("deleting workspace %q", name) removeMCPConfig(*ws) - // Harvest Claude memory before destruction - if s.ClaudeDir != "" { - for _, r := range ws.Repos { - if n := claude.HarvestMemory(s.ClaudeDir, r.WorktreePath, r.SourceRepo); n > 0 { - logging.Info("harvested %d Claude memory file(s) for %s", n, r.RepoName) - } - } - } - // Parallel teardown+remove for all repos succeeded := make([]bool, len(ws.Repos)) var wg sync.WaitGroup @@ -408,12 +389,6 @@ func (s *Service) Rename(oldName, newName string) error { gitops.WorktreeRepair(r.SourceRepo, r.WorktreePath) } - if s.ClaudeDir != "" { - for i := range ws.Repos { - claude.MigrateMemoryDir(s.ClaudeDir, origWorktreePaths[i], ws.Repos[i].WorktreePath) - } - } - logging.Info("workspace %q renamed to %q", oldName, newName) console.Successf("Workspace %s renamed to %s", oldName, newName) return nil @@ -845,22 +820,6 @@ func (s *Service) Doctor(fix bool) ([]models.DoctorIssue, int, error) { var issues []models.DoctorIssue fixed := 0 - if s.ClaudeDir != "" && s.WorkspaceDir != "" { - orphaned := claude.FindOrphanedMemoryDirs(s.ClaudeDir, s.WorkspaceDir) - for _, dir := range orphaned { - issues = append(issues, models.DoctorIssue{ - Workspace: "", - Repo: nil, - Issue: fmt.Sprintf("orphaned Claude memory: %s", filepath.Base(dir)), - SuggestedAction: "remove orphaned Claude memory directory", - }) - if fix { - os.RemoveAll(dir) - fixed++ - } - } - } - var wsToRemove []string for _, ws := range workspaces { diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index e91f363..507e6e9 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -51,8 +51,6 @@ func setupTestEnv(t *testing.T) *testEnv { svc := &Service{ State: store, Stats: &stats.Tracker{StatsPath: filepath.Join(groveDir, "stats.json"), NowFn: time.Now}, - ClaudeDir: filepath.Join(dir, ".claude"), - WorkspaceDir: wsDir, RunCmd: prodRunCmd, RunCmdSilent: prodRunCmdSilent, } @@ -741,42 +739,6 @@ func TestDoctorDetectsMissingWorkspaceDir(t *testing.T) { } } -func TestDoctorDetectsOrphanedClaudeMemory(t *testing.T) { - env := setupTestEnv(t) - env.cfg.ClaudeMemorySync = true - - env.createRepo("api") - env.svc.Create("orphan-ws", "feat/orphan", []string{"api"}, env.repoMap, env.cfg) - - // Delete workspace but leave Claude memory dir behind - ws, _ := env.svc.State.GetWorkspace("orphan-ws") - wtPath := ws.Repos[0].WorktreePath - - env.svc.Delete("orphan-ws") - - // Manually create an orphaned Claude memory dir for the deleted worktree - claudeDir := env.svc.ClaudeDir - encoded := strings.ReplaceAll(wtPath, "/", "-") - encoded = strings.ReplaceAll(encoded, ".", "-") - orphanDir := filepath.Join(claudeDir, "projects", encoded, "memory") - os.MkdirAll(orphanDir, 0o755) - os.WriteFile(filepath.Join(orphanDir, "stale.md"), []byte("old"), 0o644) - - issues, _, err := env.svc.Doctor(false) - if err != nil { - t.Fatalf("doctor: %v", err) - } - - found := false - for _, issue := range issues { - if strings.Contains(issue.Issue, "orphaned Claude memory") { - found = true - } - } - if !found { - t.Error("expected orphaned Claude memory issue") - } -} // --------------------------------------------------------------------------- // Parallel behavior tests @@ -1000,100 +962,6 @@ func TestMCPConfigRemoveOnlyGrove(t *testing.T) { } } -// --------------------------------------------------------------------------- -// Claude memory sync tests -// --------------------------------------------------------------------------- - -func TestCreateRehydratesClaudeMemory(t *testing.T) { - env := setupTestEnv(t) - repo := env.createRepo("api") - env.cfg.ClaudeMemorySync = true - - // Set up a fake ~/.claude with memory for the source repo - claudeDir := filepath.Join(env.dir, ".claude") - encodedSource := strings.ReplaceAll(repo, "/", "-") - encodedSource = strings.ReplaceAll(encodedSource, ".", "-") - memDir := filepath.Join(claudeDir, "projects", encodedSource, "memory") - os.MkdirAll(memDir, 0o755) - os.WriteFile(filepath.Join(memDir, "context.md"), []byte("source memory"), 0o644) - - env.svc.Create("claude-ws", "feat/claude", []string{"api"}, env.repoMap, env.cfg) - - // Memory should be copied to worktree's claude project dir - wtPath := filepath.Join(env.wsDir, "claude-ws", "api") - encodedWT := strings.ReplaceAll(wtPath, "/", "-") - encodedWT = strings.ReplaceAll(encodedWT, ".", "-") - wtMemDir := filepath.Join(claudeDir, "projects", encodedWT, "memory") - - data, err := os.ReadFile(filepath.Join(wtMemDir, "context.md")) - if err != nil { - t.Fatalf("memory not rehydrated: %v", err) - } - if string(data) != "source memory" { - t.Errorf("content mismatch: %q", string(data)) - } -} - -func TestCreateSkipsClaudeSyncWhenDisabled(t *testing.T) { - env := setupTestEnv(t) - repo := env.createRepo("api") - env.cfg.ClaudeMemorySync = false - - // Set up memory - claudeDir := filepath.Join(env.dir, ".claude") - encodedSource := strings.ReplaceAll(repo, "/", "-") - encodedSource = strings.ReplaceAll(encodedSource, ".", "-") - memDir := filepath.Join(claudeDir, "projects", encodedSource, "memory") - os.MkdirAll(memDir, 0o755) - os.WriteFile(filepath.Join(memDir, "context.md"), []byte("source memory"), 0o644) - - env.svc.Create("no-sync", "feat/nosync", []string{"api"}, env.repoMap, env.cfg) - - // Memory should NOT be copied - wtPath := filepath.Join(env.wsDir, "no-sync", "api") - encodedWT := strings.ReplaceAll(wtPath, "/", "-") - encodedWT = strings.ReplaceAll(encodedWT, ".", "-") - wtMemFile := filepath.Join(claudeDir, "projects", encodedWT, "memory", "context.md") - - if _, err := os.Stat(wtMemFile); !os.IsNotExist(err) { - t.Error("memory should not be synced when disabled") - } -} - -func TestDeleteHarvestsClaudeMemory(t *testing.T) { - env := setupTestEnv(t) - env.createRepo("api") - env.cfg.ClaudeMemorySync = true - // persist so Delete can load it - - env.svc.Create("harvest-ws", "feat/harvest", []string{"api"}, env.repoMap, env.cfg) - - // Add memory in the worktree's project dir - claudeDir := filepath.Join(env.dir, ".claude") - wtPath := filepath.Join(env.wsDir, "harvest-ws", "api") - encodedWT := strings.ReplaceAll(wtPath, "/", "-") - encodedWT = strings.ReplaceAll(encodedWT, ".", "-") - wtMemDir := filepath.Join(claudeDir, "projects", encodedWT, "memory") - os.MkdirAll(wtMemDir, 0o755) - os.WriteFile(filepath.Join(wtMemDir, "learned.md"), []byte("new insight"), 0o644) - - env.svc.Delete("harvest-ws") - - // Memory should be harvested back to source repo's project dir - sourceRepo := env.repoMap["api"] - encodedSource := strings.ReplaceAll(sourceRepo, "/", "-") - encodedSource = strings.ReplaceAll(encodedSource, ".", "-") - srcMemFile := filepath.Join(claudeDir, "projects", encodedSource, "memory", "learned.md") - - data, err := os.ReadFile(srcMemFile) - if err != nil { - t.Fatalf("memory not harvested: %v", err) - } - if string(data) != "new insight" { - t.Errorf("content mismatch: %q", string(data)) - } -} - func TestSyncSkipsDirty(t *testing.T) { env := setupTestEnv(t) env.createRepoWithRemote("api")