fix(hooks): correct get_repo_root() layout detection via relpath
Some checks failed
CI / validate (push) Failing after 1s
CI / validate (pull_request) Failing after 1s

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:
Molecule AI · plugin-dev 2026-05-10 15:20:54 +00:00
parent 39f4215a74
commit b957f557cb
2 changed files with 35 additions and 33 deletions

View File

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

View File

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