Option C Tools
Loading...
Searching...
No Matches
test_git_commit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_git_commit.py
4
5"""
6Purpose
7-------
8Integration tests for ``oct git commit`` (Phase 4D — G-D3, G-D4, G-D6, G-D7).
9
10Responsibilities
11----------------
12- Verify exit codes 0-4 for commit scenarios.
13- Verify the non-bypassable secrets invariant (G-D4).
14- Verify --force, --no-verify, --json flags.
15- Verify protected-branch guards (G-D7).
16- Verify large-file advisory warnings (G-D6).
17- Verify audit trail.
18
19Diagnostics
20-----------
21Domain: GIT-TESTS
22Levels:
23 L2 — test lifecycle
24 L3 — assertion details
25 L4 — deep tracing
26
27Contracts
28---------
29- All tests use subprocess invocation (same pattern as test_git_check.py).
30- All tests use tmp_path fixtures so they are automatically cleaned up.
31"""
32
33import json
34import shutil
35import subprocess
36import sys
37from pathlib import Path
38
39import pytest
40
41from tests.tests_git.conftest import _git, commit_file
42
43
44# =====================================================================
45# Helpers
46# =====================================================================
47
48
49def _git_available() -> bool:
50 return shutil.which("git") is not None
51
52
53def _run_commit(root: Path, *extra_args: str) -> subprocess.CompletedProcess:
54 """Run ``oct git commit`` in a subprocess."""
55 return subprocess.run(
56 [sys.executable, "-m", "oct.cli", "git", "commit", *extra_args],
57 cwd=root,
58 capture_output=True,
59 text=True,
60 )
61
62
63def _run_commit_json(root: Path, *extra_args: str) -> dict:
64 """Run ``oct git commit --json`` and parse JSON output."""
65 result = _run_commit(root, "--json", *extra_args)
66 return json.loads(result.stdout)
67
68
69def _latest_audit_record(root: Path) -> dict | None:
70 """Read the last JSONL line from the newest audit file."""
71 audit_files = sorted(
72 (root / "logs").glob("oct_git_audit*.jsonl"),
73 key=lambda p: p.stat().st_mtime,
74 )
75 if not audit_files:
76 return None
77 lines = audit_files[-1].read_text(encoding="utf-8").strip().splitlines()
78 if not lines:
79 return None
80 return json.loads(lines[-1])
81
82
83# =====================================================================
84# Exit codes
85# =====================================================================
86
87
89
90 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
91 def test_exit_0_clean_commit(self, temp_git_project, make_compliant_py):
92 """Staged compliant file + valid message → exit 0, commit made."""
93 _git(temp_git_project, "checkout", "-b", "feature/clean")
94 path = make_compliant_py("src/good.py")
95 _git(temp_git_project, "add", "src/good.py")
96 result = _run_commit(
97 temp_git_project, "-m", "feat: add good module",
98 )
99 assert result.returncode == 0
100 # Verify commit was actually made.
101 log = _git(temp_git_project, "log", "--oneline", "-1")
102 assert "add good module" in log.stdout
103
104 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
105 def test_exit_1_lint_failure(self, temp_git_project, make_noncompliant_py):
106 """Staged file with lint violations → exit 1."""
107 _git(temp_git_project, "checkout", "-b", "feature/lint")
108 path = make_noncompliant_py("src/bad.py", "missing_header")
109 _git(temp_git_project, "add", "src/bad.py")
110 result = _run_commit(
111 temp_git_project, "-m", "feat: add bad module",
112 )
113 assert result.returncode == 1
114
115 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
116 def test_exit_2_secrets(self, temp_git_project, make_noncompliant_py):
117 """Staged file with hardcoded secret → exit 2."""
118 _git(temp_git_project, "checkout", "-b", "feature/secrets")
119 path = make_noncompliant_py("src/secret.py", "hardcoded_secret")
120 _git(temp_git_project, "add", "src/secret.py")
121 result = _run_commit(
122 temp_git_project, "-m", "feat: add secret module",
123 )
124 assert result.returncode == 2
125
126 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
127 def test_exit_3_invalid_message(self, temp_git_project, make_compliant_py):
128 """Invalid commit message → exit 3."""
129 _git(temp_git_project, "checkout", "-b", "feature/msg")
130 path = make_compliant_py("src/good.py")
131 _git(temp_git_project, "add", "src/good.py")
132 result = _run_commit(
133 temp_git_project, "-m", "bad message no conventional format",
134 )
135 assert result.returncode == 3
136
137 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
138 def test_exit_4_nothing_staged(self, temp_git_project):
139 """Nothing staged → exit 4."""
140 _git(temp_git_project, "checkout", "-b", "feature/empty")
141 result = _run_commit(
142 temp_git_project, "-m", "feat: nothing here",
143 )
144 assert result.returncode == 4
145
146
147# =====================================================================
148# Secrets non-bypassable (G-D4 invariant)
149# =====================================================================
150
151
153
154 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
155 def test_force_does_not_bypass_secrets(self, temp_git_project, make_noncompliant_py):
156 _git(temp_git_project, "checkout", "-b", "feature/sec1")
157 path = make_noncompliant_py("src/secret.py", "hardcoded_secret")
158 _git(temp_git_project, "add", "src/secret.py")
159 result = _run_commit(
160 temp_git_project, "-m", "feat: bad", "--force",
161 )
162 assert result.returncode == 2
163
164 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
165 def test_no_verify_does_not_bypass_secrets(self, temp_git_project, make_noncompliant_py):
166 _git(temp_git_project, "checkout", "-b", "feature/sec2")
167 path = make_noncompliant_py("src/secret.py", "hardcoded_secret")
168 _git(temp_git_project, "add", "src/secret.py")
169 result = _run_commit(
170 temp_git_project, "-m", "feat: bad", "--no-verify",
171 )
172 assert result.returncode == 2
173
174 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
176 self, temp_git_project, make_noncompliant_py,
177 ):
178 _git(temp_git_project, "checkout", "-b", "feature/sec3")
179 path = make_noncompliant_py("src/secret.py", "hardcoded_secret")
180 _git(temp_git_project, "add", "src/secret.py")
181 result = _run_commit(
182 temp_git_project, "-m", "feat: bad", "--force", "--no-verify",
183 )
184 assert result.returncode == 2
185
186
187# =====================================================================
188# --force
189# =====================================================================
190
191
193
194 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
195 def test_force_bypasses_lint(self, temp_git_project, make_noncompliant_py):
196 """--force with lint violations → commit succeeds."""
197 _git(temp_git_project, "checkout", "-b", "feature/force1")
198 path = make_noncompliant_py("src/bad.py", "missing_header")
199 _git(temp_git_project, "add", "src/bad.py")
200 result = _run_commit(
201 temp_git_project, "-m", "feat: forced", "--force",
202 )
203 assert result.returncode == 0
204 log = _git(temp_git_project, "log", "--oneline", "-1")
205 assert "forced" in log.stdout
206
207 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
208 def test_force_bypasses_message_validation(self, temp_git_project, make_compliant_py):
209 """--force with invalid message → commit succeeds."""
210 _git(temp_git_project, "checkout", "-b", "feature/force2")
211 path = make_compliant_py("src/good.py")
212 _git(temp_git_project, "add", "src/good.py")
213 result = _run_commit(
214 temp_git_project, "-m", "bad message", "--force",
215 )
216 assert result.returncode == 0
217
218
219# =====================================================================
220# --no-verify
221# =====================================================================
222
223
225
226 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
227 def test_no_verify_prints_warning(self, temp_git_project, make_compliant_py):
228 _git(temp_git_project, "checkout", "-b", "feature/noverify")
229 path = make_compliant_py("src/good.py")
230 _git(temp_git_project, "add", "src/good.py")
231 result = _run_commit(
232 temp_git_project, "-m", "feat: test no verify", "--no-verify",
233 )
234 assert result.returncode == 0
235 assert "Skipping git hooks" in result.stderr
236
237
238# =====================================================================
239# Protected branch (G-D7)
240# =====================================================================
241
242
244
245 @staticmethod
246 def _disable_auto_branch(root: Path) -> None:
247 """Overwrite octrc to disable auto-branching for block tests."""
248 import json as _json
249 octrc_path = root / ".octrc.json"
250 octrc = _json.loads(octrc_path.read_text(encoding="utf-8"))
251 octrc.setdefault("git", {})["auto_branch_on_protected"] = False
252 octrc_path.write_text(_json.dumps(octrc, indent=2), encoding="utf-8")
253
254 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
255 def test_main_strict_blocks(self, temp_git_project, make_compliant_py):
256 """On main with strict profile and auto-branch disabled → blocked."""
257 self._disable_auto_branch(temp_git_project)
258 path = make_compliant_py("src/good.py")
259 _git(temp_git_project, "add", "src/good.py")
260 result = _run_commit(
261 temp_git_project, "-m", "feat: on main",
262 "--profile", "strict",
263 )
264 # Protected branch in strict mode blocks.
265 assert result.returncode == 1
266 assert "protected branch" in result.stderr.lower()
267
268 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
269 def test_feature_branch_no_block(self, temp_git_project, make_compliant_py):
270 """On a feature branch → no protected-branch block."""
271 _git(temp_git_project, "checkout", "-b", "feature/test")
272 path = make_compliant_py("src/good.py")
273 _git(temp_git_project, "add", "src/good.py")
274 result = _run_commit(
275 temp_git_project, "-m", "feat: on feature",
276 )
277 assert result.returncode == 0
278
279 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
280 def test_main_compact_warns_but_proceeds(self, temp_git_project, make_compliant_py):
281 """On main with compact profile and auto-branch disabled → blocked.
282
283 Note: resolve_effective_profile forces strict on protected branches,
284 so the profile override is ignored. The test verifies that the
285 commit is blocked because the effective profile is always strict
286 on main.
287 """
288 self._disable_auto_branch(temp_git_project)
289 path = make_compliant_py("src/good.py")
290 _git(temp_git_project, "add", "src/good.py")
291 result = _run_commit(
292 temp_git_project, "-m", "feat: on main",
293 "--profile", "compact",
294 )
295 # Protected branch forces strict, so this is blocked.
296 assert result.returncode == 1
297
298
299# =====================================================================
300# Large file advisory (G-D6)
301# =====================================================================
302
303
305
306 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
307 def test_large_file_warning(self, temp_git_project):
308 """Staged file > threshold → advisory warning printed."""
309 _git(temp_git_project, "checkout", "-b", "feature/bigfile")
310 big = temp_git_project / "src" / "bigfile.bin"
311 # Write 11 MB file.
312 big.write_bytes(b"\x00" * (11 * 1024 * 1024))
313 _git(temp_git_project, "add", "src/bigfile.bin")
314 result = _run_commit(
315 temp_git_project, "-m", "feat: big file", "--force",
316 )
317 # Should have a warning about the large file.
318 assert "Warning:" in result.stderr and ("MB" in result.stderr or "LFS" in result.stderr)
319
320
321# =====================================================================
322# Conventional message
323# =====================================================================
324
325
327
328 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
329 def test_valid_message_accepted(self, temp_git_project, make_compliant_py):
330 _git(temp_git_project, "checkout", "-b", "feature/msg")
331 path = make_compliant_py("src/good.py")
332 _git(temp_git_project, "add", "src/good.py")
333 result = _run_commit(
334 temp_git_project, "-m", "feat(auth): add validation",
335 )
336 assert result.returncode == 0
337
338 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
339 def test_invalid_message_rejected(self, temp_git_project, make_compliant_py):
340 _git(temp_git_project, "checkout", "-b", "feature/badmsg")
341 path = make_compliant_py("src/good.py")
342 _git(temp_git_project, "add", "src/good.py")
343 _git(temp_git_project, "add", "tests/test_good.py")
344 result = _run_commit(
345 temp_git_project, "-m", "Updated the thing.",
346 )
347 assert result.returncode == 3
348 assert "validation failed" in result.stderr.lower() or "Conventional Commits" in result.stderr
349
350
351# =====================================================================
352# Audit trail
353# =====================================================================
354
355
357
358 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
359 def test_success_audit(self, temp_git_project, make_compliant_py):
360 _git(temp_git_project, "checkout", "-b", "feature/audit")
361 path = make_compliant_py("src/good.py")
362 _git(temp_git_project, "add", "src/good.py")
363 result = _run_commit(
364 temp_git_project, "-m", "feat: audited commit",
365 )
366 assert result.returncode == 0
367 record = _latest_audit_record(temp_git_project)
368 if record:
369 assert record["command"] == "oct git commit"
370 assert record["exit_code"] == 0
371
372 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
373 def test_failure_audit(self, temp_git_project):
374 result = _run_commit(
375 temp_git_project, "-m", "feat: nothing staged",
376 )
377 assert result.returncode == 4
378 record = _latest_audit_record(temp_git_project)
379 if record:
380 assert record["command"] == "oct git commit"
381 assert record["exit_code"] == 4
382
383
384# =====================================================================
385# JSON output
386# =====================================================================
387
388
390
391 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
392 def test_json_on_success(self, temp_git_project, make_compliant_py):
393 _git(temp_git_project, "checkout", "-b", "feature/json")
394 path = make_compliant_py("src/good.py")
395 _git(temp_git_project, "add", "src/good.py")
396 data = _run_commit_json(
397 temp_git_project, "-m", "feat: json test",
398 )
399 assert data["exit_code"] == 0
400 assert data["message"] == "feat: json test"
401 assert "branch" in data
402
403 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
404 def test_json_on_failure(self, temp_git_project):
405 data = _run_commit_json(
406 temp_git_project, "-m", "feat: nothing staged",
407 )
408 assert data["exit_code"] == 4
409 assert len(data["errors"]) > 0
410
411
412# =====================================================================
413# L4: repeated -m for multi-paragraph commit messages
414# =====================================================================
415
416
418
419 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
421 self, temp_git_project, make_compliant_py,
422 ):
423 _git(temp_git_project, "checkout", "-b", "feature/multi-m")
424 make_compliant_py("src/multi.py")
425 _git(temp_git_project, "add", "src/multi.py")
426 result = _run_commit(
427 temp_git_project,
428 "-m", "feat: multi-paragraph subject",
429 "-m", "Body paragraph one explains the why.",
430 "-m", "Body paragraph two adds detail.",
431 )
432 assert result.returncode == 0, result.stderr
433
434 # Inspect the actual commit message via git log.
435 proc = subprocess.run(
436 ["git", "log", "-1", "--pretty=format:%B"],
437 cwd=temp_git_project, check=True,
438 capture_output=True, text=True,
439 )
440 body = proc.stdout
441 assert "feat: multi-paragraph subject" in body
442 assert "Body paragraph one explains the why." in body
443 assert "Body paragraph two adds detail." in body
444 # Ensure paragraphs are separated by blank lines (not just newlines).
445 assert "subject\n\nBody paragraph one" in body
446 assert "one explains the why.\n\nBody paragraph two" in body
test_success_audit(self, temp_git_project, make_compliant_py)
test_valid_message_accepted(self, temp_git_project, make_compliant_py)
test_invalid_message_rejected(self, temp_git_project, make_compliant_py)
test_exit_0_clean_commit(self, temp_git_project, make_compliant_py)
test_exit_2_secrets(self, temp_git_project, make_noncompliant_py)
test_exit_4_nothing_staged(self, temp_git_project)
test_exit_1_lint_failure(self, temp_git_project, make_noncompliant_py)
test_exit_3_invalid_message(self, temp_git_project, make_compliant_py)
test_force_bypasses_message_validation(self, temp_git_project, make_compliant_py)
test_force_bypasses_lint(self, temp_git_project, make_noncompliant_py)
test_json_on_success(self, temp_git_project, make_compliant_py)
test_large_file_warning(self, temp_git_project)
test_no_verify_prints_warning(self, temp_git_project, make_compliant_py)
test_main_strict_blocks(self, temp_git_project, make_compliant_py)
test_feature_branch_no_block(self, temp_git_project, make_compliant_py)
test_main_compact_warns_but_proceeds(self, temp_git_project, make_compliant_py)
test_repeated_m_joins_with_blank_line(self, temp_git_project, make_compliant_py)
test_force_and_no_verify_does_not_bypass_secrets(self, temp_git_project, make_noncompliant_py)
test_force_does_not_bypass_secrets(self, temp_git_project, make_noncompliant_py)
test_no_verify_does_not_bypass_secrets(self, temp_git_project, make_noncompliant_py)
subprocess.CompletedProcess _run_commit(Path root, *str extra_args)
dict|None _latest_audit_record(Path root)
dict _run_commit_json(Path root, *str extra_args)