fix(hooks): correct get_repo_root() layout detection via relpath
The overshoot detection was inverted — it returned workspace when the workspace had hooks/, and vice versa. Both freeze-scope and audit-trail have the same bug. Correct approach: check whether the hook's relative path from the workspace starts with 'hooks/'. If yes, the workspace IS the repo (dev layout). If no, the plugin is nested inside the workspace (production layout → return the plugin repo, which is the workspace root). Also update docstring and test names/assertions to match corrected logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
39f4215a74
commit
b957f557cb
@ -14,28 +14,35 @@ def get_repo_root(hook_file: str) -> str:
|
||||
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, so we detect this by
|
||||
checking whether the resolved workspace contains a 'hooks/' subdirectory.
|
||||
the naive dirname chain overshoots by one level.
|
||||
|
||||
- Normal layout (plugin installed in workspace): repo = <workspace>/<plugin>/.
|
||||
workspace = <workspace>/. If workspace has hooks/, we've walked too far
|
||||
and the actual repo is one level deeper → return repo.
|
||||
- Dev layout (plugin checked out directly): repo = workspace.
|
||||
workspace = parent-of-repo, which lacks hooks/ → workspace is right.
|
||||
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 overshoot by checking whether the resolved workspace contains
|
||||
# a 'hooks/' subdirectory. In a normal install, the workspace is the parent
|
||||
# of the plugin repo and has no hooks/; finding one means we walked too far.
|
||||
# In a dev layout, workspace is the parent of the repo (no hooks/) → correct.
|
||||
if os.path.isdir(os.path.join(workspace, "hooks")):
|
||||
# Overshot: workspace is actually the parent; the repo is one level deeper.
|
||||
return repo
|
||||
return workspace
|
||||
# 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:
|
||||
|
||||
@ -119,26 +119,23 @@ class TestAddContext:
|
||||
class TestGetRepoRoot:
|
||||
"""Tests for get_repo_root helper (imported from _lib)."""
|
||||
|
||||
def test_workspace_has_hooks_returns_repo(self):
|
||||
"""When workspace has hooks/ (production install), return the plugin repo."""
|
||||
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
|
||||
|
||||
# Production: hook at <plugin-repo>/hooks/hook.py, workspace = parent-of-plugin-repo
|
||||
# /tmp/my-repo/hooks/hook.py → parent=/tmp/my-repo/hooks, repo=/tmp/my-repo, workspace=/tmp
|
||||
# The function checks isdir(workspace + "/hooks") = isdir("/tmp/hooks")
|
||||
with mock.patch("os.path.isdir", return_value=True):
|
||||
result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py")
|
||||
# True → overshot → return repo
|
||||
assert result == "/tmp/my-repo"
|
||||
# /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_workspace_lacks_hooks_returns_workspace(self):
|
||||
"""When workspace lacks hooks/ (dev layout), workspace IS the repo root."""
|
||||
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
|
||||
|
||||
with mock.patch("os.path.isdir", return_value=False):
|
||||
result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py")
|
||||
# False → no overshoot → return workspace
|
||||
assert result == "/tmp"
|
||||
# /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."""
|
||||
@ -149,8 +146,6 @@ class TestGetRepoRoot:
|
||||
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
|
||||
# Sanity: result is an absolute path and is a directory
|
||||
assert os.path.isabs(result), f"result {result!r} is not absolute"
|
||||
# The result is the workspace (if installed) or repo (if dev layout).
|
||||
# Either way the hook file is inside it.
|
||||
assert os.path.isdir(result), f"result {result!r} is not a directory"
|
||||
Loading…
Reference in New Issue
Block a user