fix(hooks): use get_repo_root() to fix __file__ overshoot bug #4

Merged
sdk-lead merged 4 commits from plugin/fix-repo-root-overshoot into main 2026-05-10 15:23:55 +00:00
5 changed files with 111 additions and 3 deletions

1
.gitignore vendored
View File

@ -23,3 +23,4 @@
# Python bytecode
__pycache__/
*.py[cod]
.pytest_cache/

View File

@ -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()

View File

@ -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
View 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
```

View File

@ -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"