Add build hook system

Hook scripts in .slam/hooks/ are executed at each stage of the build
pipeline. If a script exists and is executable, it runs; a non-zero
exit stops the build.

Available hooks:
  pre_format / post_format
  pre_install / post_install
  pre_test / post_test
  pre_build / post_build

The init command now creates .slam/hooks/ with a README documenting
usage and examples, and includes a default pre_format hook that
initializes any git submodules that are present.
This commit is contained in:
gsb
2026-04-29 00:08:07 +00:00
parent 70e0a5db42
commit 1b4b4b51a5
4 changed files with 165 additions and 1 deletions
+86
View File
@@ -45,3 +45,89 @@ def test_install(monkeypatch):
def test_test(monkeypatch):
monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(out=b"out", err=b"err", code=0))
assert BuildTool(verbose=True).test([]) == 0
def test_run_hook_executes_script(monkeypatch, tmp_path):
"""Hook script exists and is executable — it should be run."""
hooks_dir = tmp_path / ".slam" / "hooks"
hooks_dir.mkdir(parents=True)
hook = hooks_dir / "pre_build"
hook.write_text("#!/bin/sh\nexit 0\n")
hook.chmod(0o755)
mock_sub = result_factory()
monkeypatch.setattr("poetry_slam.build_tool.subprocess", mock_sub)
bt = BuildTool(project_root=tmp_path)
bt.run_hook("pre_build")
mock_sub.run.assert_called_once()
def test_run_hook_skips_missing(monkeypatch, tmp_path):
"""No hook script — run_hook should do nothing."""
mock_sub = result_factory()
monkeypatch.setattr("poetry_slam.build_tool.subprocess", mock_sub)
bt = BuildTool(project_root=tmp_path)
bt.run_hook("pre_build")
mock_sub.run.assert_not_called()
def test_run_hook_non_executable_raises(monkeypatch, tmp_path):
"""Hook script exists but is not executable — should raise BuildError."""
hooks_dir = tmp_path / ".slam" / "hooks"
hooks_dir.mkdir(parents=True)
hook = hooks_dir / "pre_build"
hook.write_text("#!/bin/sh\nexit 0\n")
hook.chmod(0o644)
monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(err=b"permission denied", code=126))
bt = BuildTool(project_root=tmp_path)
with pytest.raises(BuildError):
bt.run_hook("pre_build")
def test_run_hook_raises_on_failure(monkeypatch, tmp_path):
"""Hook script fails — should raise BuildError."""
hooks_dir = tmp_path / ".slam" / "hooks"
hooks_dir.mkdir(parents=True)
hook = hooks_dir / "pre_build"
hook.write_text("#!/bin/sh\nexit 1\n")
hook.chmod(0o755)
monkeypatch.setattr("poetry_slam.build_tool.subprocess", result_factory(err=b"hook failed", code=1))
bt = BuildTool(project_root=tmp_path)
with pytest.raises(BuildError):
bt.run_hook("pre_build")
def test_build_runs_hooks(monkeypatch, tmp_path):
"""Build pipeline should call run_hook for each stage."""
mock_sub = result_factory()
monkeypatch.setattr("poetry_slam.build_tool.subprocess", mock_sub)
bt = BuildTool(project_root=tmp_path)
hook_calls = []
original_run_hook = bt.run_hook
def tracking_run_hook(stage):
hook_calls.append(stage)
original_run_hook(stage)
bt.run_hook = tracking_run_hook
bt.build()
assert hook_calls == [
"pre_format",
"post_format",
"pre_install",
"post_install",
"pre_test",
"post_test",
"pre_build",
"post_build",
]
def test_hooks_dir_property(tmp_path):
"""hooks_dir should point to .slam/hooks/ under project_root."""
bt = BuildTool(project_root=tmp_path)
assert bt.hooks_dir == tmp_path / ".slam" / "hooks"