fix(hooks): use get_repo_root() to fix __file__ overshoot bug #4
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,3 +23,4 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
|
||||
@ -4,9 +4,47 @@ Hooks receive JSON on stdin per the Claude Code hook spec, and may emit
|
||||
JSON on stdout or exit with code 2 to block. This module wraps both.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def get_repo_root(hook_file: str) -> str:
|
||||
"""Return the repo root given a hook's __file__ path.
|
||||
|
||||
The plugin is installed at <repo>/hooks/<hook>.py. We walk up three levels
|
||||
from __file__ to find the workspace root: hooks/ → repo/ → workspace/.
|
||||
When __file__ is absolute (Claude Code invokes hooks with absolute paths),
|
||||
the naive dirname chain overshoots by one level.
|
||||
|
||||
Distinguishes the production layout (plugin installed as
|
||||
<workspace>/<plugin>/) from the dev layout (plugin IS the workspace)
|
||||
by checking whether the hook path places 'hooks/' as a direct
|
||||
subdirectory of the workspace. In dev layout, the hook is at
|
||||
<workspace>/hooks/<hook>.py so the relative path from workspace
|
||||
to hook starts with 'hooks/'. In production, the relative path
|
||||
starts with '<plugin>/hooks/' so does NOT start with 'hooks/'.
|
||||
|
||||
- Production layout: hook = <workspace>/<plugin>/hooks/<hook>.py
|
||||
hook_relative = <plugin>/hooks/<hook>.py (doesn't start with 'hooks/')
|
||||
→ plugin repo is the workspace root → return repo.
|
||||
- Dev layout: hook = <workspace>/hooks/<hook>.py
|
||||
hook_relative = hooks/<hook>.py (starts with 'hooks/')
|
||||
→ workspace IS the repo → return workspace.
|
||||
"""
|
||||
abs_hook = os.path.abspath(hook_file)
|
||||
parent = os.path.dirname(abs_hook) # hooks/
|
||||
repo = os.path.dirname(parent) # repo/
|
||||
workspace = os.path.dirname(repo) # workspace/ (parent of repo)
|
||||
|
||||
# Detect: is the workspace the repo? If the hook's relative path from
|
||||
# workspace starts with 'hooks/', the workspace IS the repo (dev layout).
|
||||
# Otherwise the plugin is a subdirectory of the workspace (production layout).
|
||||
hook_relative = os.path.relpath(abs_hook, workspace)
|
||||
if hook_relative.startswith("hooks/"):
|
||||
return workspace # dev layout: workspace IS the repo
|
||||
return repo # production layout: plugin is nested inside workspace
|
||||
|
||||
|
||||
def read_input() -> dict:
|
||||
"""Parse stdin JSON. Empty input → empty dict."""
|
||||
raw = sys.stdin.read().strip()
|
||||
|
||||
@ -6,9 +6,9 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _lib import add_context, warn_to_stderr # noqa
|
||||
from _lib import add_context, get_repo_root, warn_to_stderr # noqa
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
REPO = get_repo_root(__file__)
|
||||
LEARNINGS = os.path.expanduser(
|
||||
"~/.claude/projects/-Users-hongming-Documents-GitHub-molecule-monorepo/memory/cron-learnings.jsonl"
|
||||
)
|
||||
|
||||
34
tests/README.md
Normal file
34
tests/README.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Test Rationale — molecule-session-context
|
||||
|
||||
## What this plugin does
|
||||
|
||||
`molecule-session-context` provides a SessionStart hook (`session-start-context.py`)
|
||||
that injects operational context into Claude's context window at session start:
|
||||
recent cron-learnings from JSONL, freeze-file status, and open PR/issue counts
|
||||
from GitHub.
|
||||
|
||||
The hook reads `~/.claude/projects/.../memory/cron-learnings.jsonl` (written by
|
||||
`molecule-cron-learnings`) and emits a JSON `additionalContext` payload to stdout.
|
||||
It also queries `gh` for repo state and handles graceful degradation when
|
||||
`gh` is unavailable or files are missing.
|
||||
|
||||
## What is tested
|
||||
|
||||
- `hooks/_lib.py` helpers: `read_input`, `emit`, `deny_pretooluse`, `add_context`,
|
||||
`warn_to_stderr`, `get_repo_root`
|
||||
- `hooks/session-start-context.py` logic: `tail()` JSONL reader, `gh_count()`
|
||||
subprocess wrapper, full `main()` integration with mocked gh + tempfile learnings
|
||||
- Hook error handling (exits 0 on exception, not crash)
|
||||
|
||||
## What is NOT unit-tested (and why)
|
||||
|
||||
- Real `gh` API calls — tested with `unittest.mock` subprocess interception
|
||||
- Actual freeze-file reading — tested with mocked file I/O
|
||||
- Integration with the workspace runtime's SessionStart hook harness —
|
||||
requires a real Claude Code session; covered by smoke + integration tests
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
@ -113,4 +113,39 @@ class TestAddContext:
|
||||
stdout2 = io.StringIO()
|
||||
with mock.patch("sys.stdout", stdout2):
|
||||
add_context(" ")
|
||||
assert stdout2.getvalue() == ""
|
||||
assert stdout2.getvalue() == ""
|
||||
|
||||
|
||||
class TestGetRepoRoot:
|
||||
"""Tests for get_repo_root helper (imported from _lib)."""
|
||||
|
||||
def test_production_layout_returns_repo(self):
|
||||
"""Production: hook at <workspace>/<plugin>/hooks/hook.py → return the plugin repo."""
|
||||
from _lib import get_repo_root
|
||||
|
||||
# /tmp/my-repo/hooks/hook.py → workspace=/tmp, relpath="my-repo/hooks/hook.py"
|
||||
# relpath does NOT start with "hooks/" → production → return repo
|
||||
result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py")
|
||||
assert result == "/tmp/my-repo"
|
||||
|
||||
def test_dev_layout_returns_workspace(self):
|
||||
"""Dev layout: hook at <workspace>/hooks/hook.py → workspace IS the repo."""
|
||||
from _lib import get_repo_root
|
||||
|
||||
# /workspace/hooks/hook.py → workspace=/workspace, relpath="hooks/hook.py"
|
||||
# relpath starts with "hooks/" → dev layout → return workspace
|
||||
result = get_repo_root("/workspace/hooks/session-start-context.py")
|
||||
assert result == "/workspace"
|
||||
|
||||
def test_real_repo_path_in_this_repo(self):
|
||||
"""Verify get_repo_root on the actual repo resolves to a valid root."""
|
||||
from _lib import get_repo_root
|
||||
|
||||
# This test file is at: <repo>/tests/test_lib.py
|
||||
# The hook is at: <repo>/hooks/session-start-context.py
|
||||
hooks_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks")
|
||||
hook_file = os.path.join(hooks_dir, "session-start-context.py")
|
||||
result = get_repo_root(hook_file)
|
||||
# Sanity: result is an absolute path and is a directory
|
||||
assert os.path.isabs(result), f"result {result!r} is not absolute"
|
||||
assert os.path.isdir(result), f"result {result!r} is not a directory"
|
||||
Loading…
Reference in New Issue
Block a user