Files
stack/tests/sem/test_hooks.py
kert 1e2865acbd fix(hooks): run pre-commit pytest without git's repo-locating env
git exports GIT_DIR and GIT_INDEX_FILE to hooks. The pre-commit pytest
step inherited them, and tests/dev/test_gen_config_deploy.py's scratch
repos (git init --bare / clone / commit / push in tmp dirs) therefore
ran against the repository being committed to: on 2026-10-27 a commit
that staged infra files left its branch on nine "base / docs only / mc
config" commits by author `t`, swapped the index for the fixture's
tree, and wrote uploadpack.allowAnySHA1InWant into the .git/config that
every worktree shares. It only stopped there because `git push origin`
found no `origin` remote.

- sem.hooks: the pytest step gets the hook env minus GIT_DIR,
  GIT_INDEX_FILE, GIT_WORK_TREE, GIT_PREFIX, GIT_COMMON_DIR and the
  object-dir variables.
- test_gen_config_deploy: its git helper and detector runner drop all
  GIT_* from os.environ, so any wrapper that exports them is safe too.
- Regression test: a nested `git init` under a hook-style GIT_DIR lands
  in its own directory.

Refs #1365
2026-10-27 10:58:22 -04:00

275 lines
9.4 KiB
Python

"""Tests for sem.hooks — pre-commit smart test selection."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from sem.hooks import (
_changed_modules,
_changed_test_dirs,
_classify,
_staged_files,
_test_env,
_top_level_tests,
check_syntax,
compute_test_targets,
main,
run_step,
)
class TestClassify:
def test_src_files(self):
cats = _classify(["src/aco/express/foo.py", "src/sem/parse.py"])
assert cats["src"] == ["src/aco/express/foo.py", "src/sem/parse.py"]
assert cats["tests"] == []
def test_test_files(self):
cats = _classify(["tests/aco/test_foo.py"])
assert cats["tests"] == ["tests/aco/test_foo.py"]
def test_notebooks(self):
cats = _classify(["notebooks/pfs_calcs.py"])
assert cats["notebooks"] == ["notebooks/pfs_calcs.py"]
def test_infra_conftest(self):
cats = _classify(["tests/conftest.py"])
assert "tests/conftest.py" in cats["infra"]
def test_infra_pyproject(self):
cats = _classify(["pyproject.toml"])
assert "pyproject.toml" in cats["infra"]
def test_config_stack_toml(self):
cats = _classify(["stack.toml"])
assert cats["config"] == ["stack.toml"]
def test_non_python_ignored(self):
cats = _classify(["README.md", "compose.yml", "src/aco/data.json"])
assert all(not v for v in cats.values())
class TestChangedModules:
def test_extracts_top_level(self):
assert _changed_modules(["src/aco/express/foo.py"]) == ["aco"]
assert _changed_modules(["src/sem/parse.py"]) == ["sem"]
def test_deduplicates(self):
result = _changed_modules(
[
"src/aco/express/foo.py",
"src/aco/pipe/bar.py",
]
)
assert result == ["aco"]
def test_multiple_modules(self):
result = _changed_modules(
[
"src/aco/foo.py",
"src/sem/bar.py",
"src/bib/baz.py",
]
)
assert result == ["aco", "bib", "sem"]
class TestChangedTestDirs:
def test_extracts_dirs(self):
result = _changed_test_dirs(["tests/aco/test_foo.py"])
assert result == ["tests/aco"]
def test_deduplicates(self):
result = _changed_test_dirs(
[
"tests/aco/test_a.py",
"tests/aco/test_b.py",
]
)
assert result == ["tests/aco"]
class TestTopLevelTests:
def test_finds_top_level(self):
result = _top_level_tests(
[
"tests/test_ast_coverage.py",
"tests/aco/test_foo.py",
]
)
assert result == ["tests/test_ast_coverage.py"]
class TestCheckSyntax:
def test_valid_file(self, tmp_path):
f = tmp_path / "good.py"
f.write_text("x = 1\n")
assert check_syntax([str(f)]) == []
def test_invalid_file(self, tmp_path):
f = tmp_path / "bad.py"
f.write_text("def f(\n")
errors = check_syntax([str(f)])
assert len(errors) == 1
assert "bad.py" in errors[0]
def test_missing_file_skipped(self):
assert check_syntax(["nonexistent_file_xyz.py"]) == []
class TestComputeTargets:
def test_full_on_infra_change(self):
cats = _classify(["pyproject.toml", "src/sem/parse.py"])
targets, reason = compute_test_targets(cats)
assert targets == ["tests/"]
assert "infrastructure" in reason
def test_full_on_force(self):
cats = _classify(["src/sem/parse.py"])
targets, reason = compute_test_targets(cats, force_full=True)
assert targets == ["tests/"]
def test_targeted_on_src_change(self):
cats = _classify(["src/sem/parse.py"])
targets, reason = compute_test_targets(cats)
# Should include tests/sem/ and test_ast_coverage.py
assert any("tests/sem/" in t for t in targets)
assert any("test_ast_coverage" in t for t in targets)
def test_empty_on_no_python(self):
cats = _classify(["README.md"])
targets, reason = compute_test_targets(cats)
assert targets == []
assert "no testable" in reason
def test_test_file_changes_add_test_dirs(self):
"""Changed test files add their parent dir to targets."""
cats = _classify(["tests/aco/test_foo.py", "tests/bib/test_bar.py"])
targets, reason = compute_test_targets(cats)
assert any("tests/aco" in t for t in targets)
assert any("tests/bib" in t for t in targets)
def test_top_level_test_file_added(self):
"""Top-level test files are added directly."""
cats = _classify(["tests/test_something.py"])
targets, reason = compute_test_targets(cats)
assert "tests/test_something.py" in targets
def test_deduplication(self):
"""Targets from src and test don't duplicate."""
cats = _classify(["src/aco/foo.py", "tests/aco/test_foo.py"])
targets, reason = compute_test_targets(cats)
aco_targets = [t for t in targets if "aco" in t]
assert len(aco_targets) == 1
class TestStagedFiles:
@patch("sem.hooks.subprocess.run")
def test_parses_git_output(self, mock_run):
mock_run.return_value = MagicMock(
stdout="src/aco/foo.py\ntests/aco/test_foo.py\n",
returncode=0,
)
result = _staged_files()
assert result == ["src/aco/foo.py", "tests/aco/test_foo.py"]
@patch("sem.hooks.subprocess.run")
def test_empty_output(self, mock_run):
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = _staged_files()
assert result == []
@patch("sem.hooks.subprocess.run")
def test_filters_empty_lines(self, mock_run):
mock_run.return_value = MagicMock(stdout="foo.py\n\nbar.py\n", returncode=0)
result = _staged_files()
assert result == ["foo.py", "bar.py"]
class TestRunStep:
def test_returns_exit_code(self):
rc = run_step("echo test", ["python", "-c", "pass"])
assert rc == 0
def test_nonzero_exit_code(self):
rc = run_step("fail", ["python", "-c", "import sys; sys.exit(1)"])
assert rc == 1
class TestTestEnv:
def test_strips_repo_locating_git_vars(self, monkeypatch):
monkeypatch.setenv("GIT_DIR", "/repo/.git")
monkeypatch.setenv("GIT_INDEX_FILE", "/repo/.git/index")
monkeypatch.setenv("GIT_AUTHOR_NAME", "kept")
env = _test_env()
assert "GIT_DIR" not in env
assert "GIT_INDEX_FILE" not in env
assert env["GIT_AUTHOR_NAME"] == "kept"
def test_nested_git_sees_its_own_repo(self, tmp_path, monkeypatch):
"""Under a hook, `git init` in a tmp dir must not land in GIT_DIR."""
import subprocess
monkeypatch.setenv("GIT_DIR", str(tmp_path / "hooked.git"))
scratch = tmp_path / "scratch"
scratch.mkdir()
subprocess.run(["git", "init", "-q"], cwd=scratch, env=_test_env(), check=True)
assert (scratch / ".git").is_dir()
assert not (tmp_path / "hooked.git").exists()
class TestMain:
@patch("sem.hooks._staged_files", return_value=[])
def test_no_staged_files_returns_zero(self, mock_staged):
assert main() == 0
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["src/aco/foo.py"])
def test_src_change_runs_lint_and_pytest(
self, mock_staged, mock_subproc, mock_step
):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0
# Should have called run_step for ruff check, format, and pytest
step_labels = [c[0][0] for c in mock_step.call_args_list]
assert any("ruff check" in l for l in step_labels)
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["stack.toml"])
def test_config_change_triggers_regen(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0
step_labels = [c[0][0] for c in mock_step.call_args_list]
assert any("config" in l or "regen" in l for l in step_labels)
@patch("sem.hooks.run_step")
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["src/aco/foo.py"])
def test_lint_failure_aborts(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
mock_step.return_value = 1 # lint fails
result = main()
assert result == 1
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["notebooks/pfs_calcs.py"])
def test_notebook_change_runs_marimo(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0
step_labels = [c[0][0] for c in mock_step.call_args_list]
assert any("marimo" in l or "notebook" in l for l in step_labels)
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["README.md"])
def test_non_python_change_skips_tests(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0