diff --git a/interlocks/hook_setup.py b/interlocks/hook_setup.py index 3e0aa3e..4b5f6c0 100644 --- a/interlocks/hook_setup.py +++ b/interlocks/hook_setup.py @@ -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]) @@ -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") diff --git a/interlocks/setup_state.py b/interlocks/setup_state.py index 66c7ae0..b9b74a6 100644 --- a/interlocks/setup_state.py +++ b/interlocks/setup_state.py @@ -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: /.git/worktrees/``. 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: diff --git a/tests/stages/test_setup_hooks.py b/tests/stages/test_setup_hooks.py index a72c840..be6aaeb 100644 --- a/tests/stages/test_setup_hooks.py +++ b/tests/stages/test_setup_hooks.py @@ -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 diff --git a/tests/stages/test_setup_hooks_integration.py b/tests/stages/test_setup_hooks_integration.py index d4402a6..5741df5 100644 --- a/tests/stages/test_setup_hooks_integration.py +++ b/tests/stages/test_setup_hooks_integration.py @@ -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 diff --git a/tests/tasks/test_setup.py b/tests/tasks/test_setup.py index 9f9bd28..28231b1 100644 --- a/tests/tasks/test_setup.py +++ b/tests/tasks/test_setup.py @@ -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 /.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: diff --git a/tests/test_git.py b/tests/test_git.py index d2aebc5..6b0e463 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -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"}