Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/grove/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -949,8 +950,13 @@ def _do_cleanup(ws_name: str) -> None:
immediately without waiting for worktree removal to finish.
If cleanup fails, ``gw doctor`` will catch the stale state.
"""
gw_path = shutil.which("gw")
if gw_path:
cmd = [gw_path, "delete", "--force", ws_name]
else:
cmd = [sys.executable, "-m", "grove", "delete", "--force", ws_name]
subprocess.Popen(
[sys.executable, "-m", "grove", "delete", "--force", ws_name],
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
Expand Down Expand Up @@ -1054,6 +1060,22 @@ def dash_main(ctx: typer.Context) -> None:
run_dashboard()


@app.command("_hook", hidden=True)
def _hook_entrypoint(
event: str = typer.Option(..., "--event", help="Hook event type"),
) -> None:
"""Internal hook handler invoked by Claude Code. Not for direct use."""
import json as _json

from grove.dash.hook import handle_event

try:
input_data = _json.load(sys.stdin)
except (ValueError, _json.JSONDecodeError):
return
handle_event(event, input_data)


@dash_app.command("install")
def dash_install(
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would change"),
Expand Down
33 changes: 28 additions & 5 deletions src/grove/dash/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,42 @@
]

# Marker to identify our hooks
_GROVE_MARKER = "grove.dash"
_GROVE_MARKER = "gw _hook"
# Legacy marker from pre-v0.13 (used sys.executable + python -m grove.dash).
# Needed so uninstall can clean up old hooks. Safe to remove in v0.14+.
_LEGACY_MARKER = "grove.dash"


def _hook_command(event: str) -> str:
"""Build the hook command string for a given event."""
python = sys.executable
return f"GROVE_EVENT={event} {python} -m grove.dash --event {event}"
"""Build the hook command string for a given event.

Uses the ``gw`` console-script entry point (stable across upgrades)
rather than the versioned Python interpreter path, which breaks when
Homebrew or uv upgrades Grove to a new version.
"""
gw = _resolve_gw()
return f"GROVE_EVENT={event} {gw} _hook --event {event}"


def _resolve_gw() -> str:
"""Find the stable ``gw`` binary path.

Prefers ``shutil.which`` so we get the canonical PATH entry (e.g.
/opt/homebrew/bin/gw) rather than a version-specific Cellar path.
Falls back to ``sys.executable -m grove.dash`` for editable/dev installs
where ``gw`` might not be on PATH.
"""
gw_path = shutil.which("gw")
if gw_path:
return gw_path
# Fallback for dev installs — multi-token string, interpreted by shell
return f"{sys.executable} -m grove.dash"


def _is_grove_hook(hook: dict) -> bool:
"""Check if a hook entry belongs to Grove."""
cmd = hook.get("command", "")
return _GROVE_MARKER in cmd
return _GROVE_MARKER in cmd or _LEGACY_MARKER in cmd


def install_hooks(dry_run: bool = False) -> dict[str, list[str]]:
Expand Down
9 changes: 6 additions & 3 deletions tests/test_dash_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def test_install_preserves_existing_hooks(self, claude_settings: Path) -> None:
assert len(pre_rules) == 2
all_cmds = [h["command"] for rule in pre_rules for h in rule.get("hooks", [])]
assert "my-custom-hook" in all_cmds
assert any("grove.dash" in c for c in all_cmds)
assert any("gw _hook" in c for c in all_cmds)

# Other settings preserved
assert settings["other_setting"] is True
Expand All @@ -73,7 +73,7 @@ def test_install_updates_existing_grove_hook(self, claude_settings: Path) -> Non
grove_rules = [
rule
for rule in pre_rules
if any("grove.dash" in h.get("command", "") for h in rule.get("hooks", []))
if any("gw _hook" in h.get("command", "") for h in rule.get("hooks", []))
]
assert len(grove_rules) == 1

Expand Down Expand Up @@ -109,7 +109,10 @@ def test_uninstall_preserves_other_hooks(self, claude_settings: Path) -> None:
"hooks": {
"PreToolUse": [
{"matcher": "", "hooks": [{"type": "command", "command": "my-custom-hook"}]},
{"matcher": "", "hooks": [{"type": "command", "command": "grove.dash x"}]},
{
"matcher": "",
"hooks": [{"type": "command", "command": "gw _hook --event PreToolUse"}],
},
]
}
}
Expand Down
Loading