Skip to content
Open
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
4 changes: 2 additions & 2 deletions interlocks/hook_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import TypeVar

from interlocks.runner import ok
from interlocks.setup_state import is_post_edit_command
from interlocks.setup_state import _git_hooks_dir, is_post_edit_command

_Container = TypeVar("_Container", dict[str, object], list[object])

Expand Down Expand Up @@ -54,7 +54,7 @@ def install_hooks(project_root: Path | None = None) -> None:
root = project_root or Path.cwd()
python = shlex.quote(sys.executable)

hook = root / ".git" / "hooks" / "pre-commit"
hook = _git_hooks_dir(root) / "pre-commit"
hook.parent.mkdir(parents=True, exist_ok=True)
script = f"#!/bin/sh\nexec {python} -m interlocks.cli pre-commit\n"
hook.write_text(script, encoding="utf-8")
Expand Down
24 changes: 23 additions & 1 deletion interlocks/setup_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,31 @@ def is_post_edit_command(command: object) -> bool:
)


def _git_hooks_dir(project_root: Path) -> Path:
"""Return the git hooks directory for *project_root*.

In a linked worktree ``.git`` is a file with content
``gitdir: <path>/.git/worktrees/<name>``. The actual hooks directory
lives two levels up in the common git dir, not inside the per-worktree
pseudo-repo.
"""
git_path = project_root / ".git"
if not git_path.is_file():
return git_path / "hooks"
text = git_path.read_text(encoding="utf-8").strip()
if not text.startswith("gitdir:"):
return git_path / "hooks"
gitdir = Path(text.split(":", 1)[1].strip())
if not gitdir.is_absolute():
gitdir = (project_root / gitdir).resolve()
if gitdir.parent.name == "worktrees":
return gitdir.parent.parent / "hooks"
return gitdir / "hooks"


def pre_commit_hook_installed(project_root: Path) -> bool:
"""True when ``.git/hooks/pre-commit`` exists and invokes ``interlocks pre-commit``."""
hook = project_root / ".git" / "hooks" / "pre-commit"
hook = _git_hooks_dir(project_root) / "pre-commit"
try:
body = hook.read_text(encoding="utf-8")
except OSError:
Expand Down
38 changes: 38 additions & 0 deletions tests/stages/test_setup_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,44 @@ def test_ensure_stop_hook_normalizes_duplicate_post_edit_hooks(self) -> None:
],
)

def test_keep_existing_hook_preserves_non_command_hook(self) -> None:
"""Matcher and other non-command hook objects are always kept."""
from interlocks.hook_setup import _keep_existing_hook

matcher = {"type": "matcher", "pattern": ".*error.*"}
assert _keep_existing_hook(matcher, "python -m interlocks.cli post-edit") is True

def test_keep_existing_hook_preserves_non_dict_hook(self) -> None:
"""Non-dict hook values are treated as unknown and kept unchanged."""
from interlocks.hook_setup import _keep_existing_hook

result = _keep_existing_hook("run-some-script.sh", "python -m interlocks.cli post-edit")
assert result is True

def test_ensure_stop_hook_three_calls_produces_single_entry(self) -> None:
"""Calling _ensure_stop_hook three times results in exactly one managed entry."""
command = "python -m interlocks.cli post-edit"
settings: dict[str, object] = {}
_ensure_stop_hook(settings, command)
_ensure_stop_hook(settings, command)
_ensure_stop_hook(settings, command)

hooks = settings["hooks"]["Stop"][0]["hooks"] # pyright: ignore[reportIndexIssue]
managed = [h for h in hooks if isinstance(h, dict) and h.get("command") == command]
assert len(managed) == 1

def test_ensure_stop_hook_preserves_unmanaged_command_alongside_managed(self) -> None:
"""An unrecognised command hook coexists with the managed post-edit entry."""
settings: dict[str, object] = {
"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "my-linter --fix"}]}]}
}
_ensure_stop_hook(settings, "python -m interlocks.cli post-edit")

hooks = settings["hooks"]["Stop"][0]["hooks"] # pyright: ignore[reportIndexIssue]
commands = [h["command"] for h in hooks if isinstance(h, dict)]
assert "my-linter --fix" in commands
assert "python -m interlocks.cli post-edit" in commands

def test_cmd_hooks_writes_hook_file_and_settings(self) -> None:
from interlocks.stages.setup_hooks import cmd_hooks

Expand Down
59 changes: 59 additions & 0 deletions tests/stages/test_setup_hooks_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,65 @@ def test_setup_hooks_installs_pre_commit_and_stop_hook(tmp_project: Path) -> Non
assert any(h["type"] == "command" and h["command"].endswith(suffix) for h in hooks)


def _init_repo_for_worktree(root: Path) -> None:
"""Initialise a git repo with an initial commit (required before adding worktrees)."""
_git(root, "init", "-q", "-b", "main")
_git(root, "config", "user.email", "t@e.co")
_git(root, "config", "user.name", "t")
_git(root, "config", "commit.gpgsign", "false")
(root / "placeholder.txt").write_text("init\n", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-q", "-m", "init"],
cwd=root,
check=True,
capture_output=True,
)


def _make_worktree_pair(tmp_path: Path, branch: str) -> tuple[Path, Path]:
"""Return (main, linked) after creating a repo and adding a linked worktree."""
main = tmp_path / "main"
main.mkdir()
_init_repo_for_worktree(main)
linked = tmp_path / "linked"
subprocess.run(
["git", "worktree", "add", "-b", branch, str(linked), "HEAD"],
cwd=main,
check=True,
capture_output=True,
)
return main, linked


def test_install_hooks_in_linked_worktree(tmp_path: Path) -> None:
"""install_hooks writes the pre-commit hook to the main repo from a linked worktree."""
from interlocks.hook_setup import install_hooks

main, linked = _make_worktree_pair(tmp_path, "feature")
assert (linked / ".git").is_file(), "linked worktree .git must be a file"

install_hooks(linked)

hook = main / ".git" / "hooks" / "pre-commit"
assert hook.exists()
assert os.access(hook, os.X_OK)
assert "-m interlocks.cli pre-commit" in hook.read_text(encoding="utf-8")


def test_pre_commit_hook_installed_detects_linked_worktree(tmp_path: Path) -> None:
"""pre_commit_hook_installed returns True for a hook in the common git dir."""
from interlocks.hook_setup import install_hooks
from interlocks.setup_state import pre_commit_hook_installed

main, linked = _make_worktree_pair(tmp_path, "detect-test")

install_hooks(linked)

assert pre_commit_hook_installed(linked)
assert pre_commit_hook_installed(main)


def test_setup_hooks_is_idempotent(tmp_project: Path) -> None:
assert _run_setup_hooks(tmp_project).returncode == 0
assert _run_setup_hooks(tmp_project).returncode == 0
Expand Down
40 changes: 40 additions & 0 deletions tests/tasks/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,46 @@ def test_setup_check_succeeds_after_setup(
assert "Local integrations are installed and current." in out


def test_setup_state_no_duplicate_artifacts_in_check_output(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""setup_artifact_statuses returns no duplicate labels after two install runs."""
from interlocks.setup_state import setup_artifact_statuses

_write_pyproject(tmp_path)
_run_setup(monkeypatch, tmp_path)
_run_setup(monkeypatch, tmp_path)
capsys.readouterr()

statuses = setup_artifact_statuses(tmp_path)
labels = [s.label for s in statuses]
assert len(labels) == len(set(labels)), f"duplicate artifact labels: {labels}"


def test_git_hooks_dir_returns_dot_git_hooks_for_plain_repo(tmp_path: Path) -> None:
"""_git_hooks_dir returns <root>/.git/hooks for a normal (non-worktree) repo."""
from interlocks.setup_state import _git_hooks_dir

# Plain repo: .git is a directory
(tmp_path / ".git").mkdir()
assert _git_hooks_dir(tmp_path) == tmp_path / ".git" / "hooks"


def test_git_hooks_dir_resolves_linked_worktree(tmp_path: Path) -> None:
"""_git_hooks_dir follows a gitdir file to the common hooks directory."""
from interlocks.setup_state import _git_hooks_dir

main = tmp_path / "main"
(main / ".git" / "worktrees" / "feat").mkdir(parents=True)
# Write a .git file as git would in the linked worktree
linked = tmp_path / "linked"
linked.mkdir()
gitdir = main / ".git" / "worktrees" / "feat"
(linked / ".git").write_text(f"gitdir: {gitdir}\n", encoding="utf-8")

assert _git_hooks_dir(linked) == main / ".git" / "hooks"


def _write_pyproject_with_preset(project: Path, preset: str | None) -> None:
body = '[project]\nname = "probe"\nversion = "0.0.0"\nrequires-python = ">=3.11"\n'
if preset is not None:
Expand Down
33 changes: 33 additions & 0 deletions tests/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,36 @@ def test_src_test_prefixes_empty_src_with_tests_matches_all(
"""Empty src with a configured test dir still matches everything (no narrowing)."""
_stub_cfg(monkeypatch, "", "tests")
assert git_mod._src_test_prefixes() == ("",)


def test_changed_py_files_vs_works_in_linked_worktree(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""changed_py_files_vs works when invoked from a linked git worktree.

In a linked worktree git commands operate on the checked-out branch normally;
``changed_py_files_vs`` must return files relative to the worktree root.
"""
main = tmp_path / "main"
main.mkdir()
_init_repo(main)
(main / "pyproject.toml").write_text(
'[tool.interlocks]\nsrc_dir = "interlocks"\ntest_dir = "tests"\n',
encoding="utf-8",
)
(main / "interlocks").mkdir()
(main / "interlocks" / "base.py").write_text("x = 1\n", encoding="utf-8")
_commit_all(main, "base")

linked = tmp_path / "linked"
_git("worktree", "add", "-b", "feature", str(linked), "HEAD", cwd=main)

# .git in a linked worktree is a file, not a directory
assert (linked / ".git").is_file()

# Write an untracked .py file in the linked worktree
(linked / "interlocks" / "new_feature.py").write_text("y = 2\n", encoding="utf-8")

monkeypatch.chdir(linked)
result = changed_py_files_vs("HEAD")
assert result == {"interlocks/new_feature.py"}
Loading