Option C Tools
Loading...
Searching...
No Matches
test_git_hooks.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_git_hooks.py
4
5"""
6Purpose
7-------
8Integration tests for ``oct git hooks`` subgroup (Phase 4D — G-D8..G-D11).
9
10Responsibilities
11----------------
12- Verify ``oct git hooks install`` creates .pre-commit-config.yaml.
13- Verify ``oct git hooks status`` reports correct hook states.
14- Verify ``oct git hooks remove`` removes OCT hooks.
15- Verify deprecation shim for ``oct install-hooks``.
16
17Diagnostics
18-----------
19Domain: GIT-TESTS
20Levels:
21 L2 — test lifecycle
22 L3 — assertion details
23 L4 — deep tracing
24
25Contracts
26---------
27- All tests use subprocess invocation and tmp_path fixtures.
28"""
29
30import json
31import shutil
32import subprocess
33import sys
34from pathlib import Path
35
36import pytest
37
38
39# =====================================================================
40# Helpers
41# =====================================================================
42
43
44def _git_available() -> bool:
45 return shutil.which("git") is not None
46
47
48def _git(repo: Path, *args: str, check: bool = True):
49 return subprocess.run(
50 ["git", *args],
51 cwd=repo,
52 check=check,
53 capture_output=True,
54 text=True,
55 )
56
57
58def _run_hooks(root: Path, *extra_args: str) -> subprocess.CompletedProcess:
59 """Run ``oct git hooks <subcmd>`` in a subprocess."""
60 return subprocess.run(
61 [sys.executable, "-m", "oct.cli", "git", "hooks", *extra_args],
62 cwd=root,
63 capture_output=True,
64 text=True,
65 )
66
67
68def _run_install_hooks(root: Path, *extra_args: str) -> subprocess.CompletedProcess:
69 """Run the deprecated ``oct install-hooks``."""
70 return subprocess.run(
71 [sys.executable, "-m", "oct.cli", "install-hooks", *extra_args],
72 cwd=root,
73 capture_output=True,
74 text=True,
75 )
76
77
78def _make_option_c_git_project(tmp_path: Path, name: str = "hookproj") -> Path:
79 """Create a git repo with Option C project structure."""
80 root = tmp_path / name
81 root.mkdir()
82 (root / "docs").mkdir()
83 (root / "tests").mkdir()
84 (root / "oc_diagnostics").mkdir()
85 (root / "logs").mkdir()
86 (root / "src").mkdir()
87 (root / "pyproject.toml").write_text(
88 f'[project]\nname = "{name}"\n', encoding="utf-8",
89 )
90 _git(root, "init", "--quiet", "-b", "main")
91 _git(root, "config", "user.email", "test@example.com")
92 _git(root, "config", "user.name", "Test")
93 _git(root, "config", "commit.gpgsign", "false")
94 _git(root, "add", "-A")
95 _git(root, "commit", "-m", "initial", "--quiet")
96 return root
97
98
99# =====================================================================
100# Install
101# =====================================================================
102
103
105
106 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
107 def test_creates_config(self, tmp_path):
108 root = _make_option_c_git_project(tmp_path)
109 result = _run_hooks(root, "install")
110 assert result.returncode == 0
111 config = root / ".pre-commit-config.yaml"
112 assert config.is_file()
113 content = config.read_text(encoding="utf-8")
114 assert "oct-quality-gate" in content
115 assert "oct-secrets-check" in content
116 assert "oct-commit-msg" in content
117 assert "oct-pre-push" in content
118
119 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
121 root = _make_option_c_git_project(tmp_path)
122 _run_hooks(root, "install")
123 result = _run_hooks(root, "install")
124 assert result.returncode == 1
125 assert "already exists" in result.stderr
126
127 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
128 def test_force_overwrites(self, tmp_path):
129 root = _make_option_c_git_project(tmp_path)
130 _run_hooks(root, "install")
131 # Modify the file.
132 config = root / ".pre-commit-config.yaml"
133 config.write_text("# custom content\n", encoding="utf-8")
134 result = _run_hooks(root, "install", "--force")
135 assert result.returncode == 0
136 content = config.read_text(encoding="utf-8")
137 assert "oct-quality-gate" in content
138
139 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
140 def test_yaml_content_valid(self, tmp_path):
141 """Config file should have proper YAML structure."""
142 root = _make_option_c_git_project(tmp_path)
143 _run_hooks(root, "install")
144 content = (root / ".pre-commit-config.yaml").read_text(encoding="utf-8")
145 assert "repos:" in content
146 assert "- repo: local" in content
147 assert "hooks:" in content
148
149
150# =====================================================================
151# Status
152# =====================================================================
153
154
156
157 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
158 def test_all_installed(self, tmp_path):
159 root = _make_option_c_git_project(tmp_path)
160 _run_hooks(root, "install")
161 result = _run_hooks(root, "status")
162 assert result.returncode == 0
163 for hook_id in ("oct-quality-gate", "oct-secrets-check",
164 "oct-commit-msg", "oct-pre-push"):
165 assert f"[INSTALLED] {hook_id}" in result.stdout
166
167 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
168 def test_none_installed(self, tmp_path):
169 root = _make_option_c_git_project(tmp_path)
170 result = _run_hooks(root, "status")
171 assert result.returncode == 0
172 for hook_id in ("oct-quality-gate", "oct-secrets-check",
173 "oct-commit-msg", "oct-pre-push"):
174 assert f"[MISSING] {hook_id}" in result.stdout
175
176 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
177 def test_partial_hooks(self, tmp_path):
178 root = _make_option_c_git_project(tmp_path)
179 # Write a config with only some hooks.
180 config = root / ".pre-commit-config.yaml"
181 config.write_text(
182 "repos:\n - repo: local\n hooks:\n"
183 " - id: oct-quality-gate\n"
184 " name: test\n"
185 " entry: echo\n"
186 " language: system\n",
187 encoding="utf-8",
188 )
189 result = _run_hooks(root, "status")
190 assert "[INSTALLED] oct-quality-gate" in result.stdout
191 assert "[MISSING] oct-secrets-check" in result.stdout
192
193
194# =====================================================================
195# Remove
196# =====================================================================
197
198
200
201 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
202 def test_remove_deletes_file(self, tmp_path):
203 """When only OCT hooks exist, remove deletes the file."""
204 root = _make_option_c_git_project(tmp_path)
205 _run_hooks(root, "install")
206 result = _run_hooks(root, "remove")
207 assert result.returncode == 0
208 assert not (root / ".pre-commit-config.yaml").is_file()
209
210 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
211 def test_remove_no_file(self, tmp_path):
212 """Remove when no config file exists → graceful."""
213 root = _make_option_c_git_project(tmp_path)
214 result = _run_hooks(root, "remove")
215 assert result.returncode == 0
216
217 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
219 """Non-OCT hooks should survive removal."""
220 root = _make_option_c_git_project(tmp_path)
221 config = root / ".pre-commit-config.yaml"
222 config.write_text(
223 "repos:\n - repo: local\n hooks:\n"
224 " - id: custom-hook\n"
225 " name: My Custom Hook\n"
226 " entry: echo custom\n"
227 " language: system\n"
228 " - id: oct-quality-gate\n"
229 " name: Option C Quality Gate\n"
230 " entry: oct git check --staged-only\n"
231 " language: system\n",
232 encoding="utf-8",
233 )
234 result = _run_hooks(root, "remove")
235 assert result.returncode == 0
236 # File should still exist with custom hook.
237 assert config.is_file()
238 content = config.read_text(encoding="utf-8")
239 assert "custom-hook" in content
240 assert "oct-quality-gate" not in content
241
242
243# =====================================================================
244# Deprecation shim (G-D11)
245# =====================================================================
246
247
249
250 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
251 def test_deprecated_command_works(self, tmp_path):
252 root = _make_option_c_git_project(tmp_path)
253 result = _run_install_hooks(root)
254 assert result.returncode == 0
255 assert "deprecated" in result.stderr.lower()
256 # Should still create the config file.
257 assert (root / ".pre-commit-config.yaml").is_file()
258
259 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
260 def test_deprecated_with_force(self, tmp_path):
261 root = _make_option_c_git_project(tmp_path)
263 result = _run_install_hooks(root, "--force")
264 assert result.returncode == 0
265 assert "deprecated" in result.stderr.lower()
266
267
268# =====================================================================
269# Audit trail
270# =====================================================================
271
272
274
275 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
276 def test_install_audit(self, tmp_path):
277 root = _make_option_c_git_project(tmp_path)
278 result = _run_hooks(root, "install")
279 assert result.returncode == 0
280 audit_files = list((root / "logs").glob("oct_git_audit*.jsonl"))
281 if audit_files:
282 content = audit_files[-1].read_text(encoding="utf-8")
283 assert "oct git hooks install" in content
284
285 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
286 def test_remove_audit(self, tmp_path):
287 root = _make_option_c_git_project(tmp_path)
288 _run_hooks(root, "install")
289 result = _run_hooks(root, "remove")
290 assert result.returncode == 0
291 audit_files = list((root / "logs").glob("oct_git_audit*.jsonl"))
292 if audit_files:
293 content = audit_files[-1].read_text(encoding="utf-8")
294 assert "oct git hooks remove" in content
subprocess.CompletedProcess _run_hooks(Path root, *str extra_args)
_git(Path repo, *str args, bool check=True)
Path _make_option_c_git_project(Path tmp_path, str name="hookproj")
subprocess.CompletedProcess _run_install_hooks(Path root, *str extra_args)