Option C Tools
Loading...
Searching...
No Matches
test_commit_observer.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_commit_observer.py
4
5"""
6Purpose
7-------
8Validate the B1.0 commit observer that detects silent mutations of
9tracked-but-unstaged files during ``oct git commit``.
10
11Responsibilities
12----------------
13- Unit tests for ``snapshot_unstaged_tracked`` and ``diff_snapshots``
14 in ``oct.git.commit_observer``: snapshot excludes staged files,
15 detects content drift, handles oversized files via sentinel.
16- Integration tests for the warn/block modes wired into
17 ``git_commit_cmd``: a synthetic mutation between snapshot points
18 produces the expected stderr warning, and the block mode aborts
19 with exit code 6.
20
21Diagnostics
22-----------
23Domain: GIT-TESTS
24Levels:
25 L2 -- test lifecycle
26 L3 -- assertion details
27
28Contracts
29---------
30- All tests use subprocess invocation against ``oct.cli`` for
31 integration-level checks; unit tests import the helper directly.
32- All tests use tmp_path fixtures so they are automatically cleaned up.
33"""
34
35import json
36import shutil
37import subprocess
38import sys
39from pathlib import Path
40
41import pytest
42
43from oct.git.commit_observer import (
44 _MAX_HASH_BYTES,
45 _OVERSIZE_SENTINEL,
46 diff_snapshots,
47 snapshot_unstaged_tracked,
48)
49
50
51def _git_available() -> bool:
52 return shutil.which("git") is not None
53
54
55def _git(repo: Path, *args: str, check: bool = True):
56 return subprocess.run(
57 ["git", *args], cwd=repo, check=check,
58 capture_output=True, text=True,
59 )
60
61
62@pytest.fixture
63def git_repo(tmp_path: Path) -> Path:
64 """Bare-bones git repo for unit-level snapshot tests."""
65 if not _git_available():
66 pytest.skip("git binary not available on PATH")
67 repo = tmp_path / "repo"
68 repo.mkdir()
69 _git(repo, "init", "--quiet", "-b", "main")
70 _git(repo, "config", "user.email", "test@example.com")
71 _git(repo, "config", "user.name", "Test")
72 _git(repo, "config", "commit.gpgsign", "false")
73 return repo.resolve()
74
75
76# =====================================================================
77# snapshot_unstaged_tracked
78# =====================================================================
79
80
82
83 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
84 def test_excludes_staged_files(self, git_repo: Path) -> None:
85 (git_repo / "tracked.txt").write_text("hello\n", encoding="utf-8")
86 (git_repo / "staged.txt").write_text("world\n", encoding="utf-8")
87 _git(git_repo, "add", "tracked.txt", "staged.txt")
88 _git(git_repo, "commit", "-m", "init", "--quiet")
89
90 # Re-stage one file (modify + stage).
91 (git_repo / "staged.txt").write_text("modified\n", encoding="utf-8")
92 _git(git_repo, "add", "staged.txt")
93
94 snap = snapshot_unstaged_tracked(
95 git_repo, staged_set={"staged.txt"},
96 )
97 assert "tracked.txt" in snap
98 assert "staged.txt" not in snap
99
100 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
101 def test_excludes_untracked(self, git_repo: Path) -> None:
102 (git_repo / "tracked.txt").write_text("x\n", encoding="utf-8")
103 _git(git_repo, "add", "tracked.txt")
104 _git(git_repo, "commit", "-m", "init", "--quiet")
105 (git_repo / "untracked.txt").write_text("y\n", encoding="utf-8")
106
107 snap = snapshot_unstaged_tracked(git_repo)
108 assert "tracked.txt" in snap
109 assert "untracked.txt" not in snap
110
111 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
112 def test_oversize_file_uses_sentinel(self, git_repo: Path) -> None:
113 big = git_repo / "big.bin"
114 big.write_bytes(b"x" * (_MAX_HASH_BYTES + 1))
115 _git(git_repo, "add", "big.bin")
116 _git(git_repo, "commit", "-m", "init", "--quiet")
117
118 snap = snapshot_unstaged_tracked(git_repo)
119 assert snap["big.bin"] == _OVERSIZE_SENTINEL
120
121
122# =====================================================================
123# diff_snapshots
124# =====================================================================
125
126
128
129 def test_no_change(self) -> None:
130 before = {"a.txt": "h1", "b.txt": "h2"}
131 after = {"a.txt": "h1", "b.txt": "h2"}
132 assert diff_snapshots(before, after) == []
133
134 def test_content_change(self) -> None:
135 before = {"a.txt": "h1"}
136 after = {"a.txt": "h1-CHANGED"}
137 assert diff_snapshots(before, after) == ["a.txt"]
138
139 def test_deletion(self) -> None:
140 before = {"a.txt": "h1", "b.txt": "h2"}
141 after = {"a.txt": "h1"}
142 assert diff_snapshots(before, after) == ["b.txt"]
143
145 # Two oversize files that we cannot compare must NOT register
146 # as a mutation.
147 before = {"big.bin": _OVERSIZE_SENTINEL}
148 after = {"big.bin": _OVERSIZE_SENTINEL}
149 assert diff_snapshots(before, after) == []
150
152 before = {"big.bin": "abcd"}
153 after = {"big.bin": _OVERSIZE_SENTINEL}
154 assert diff_snapshots(before, after) == ["big.bin"]
155
156
157# =====================================================================
158# Integration: warn mode on a real oct git commit
159# =====================================================================
160
161
163 project_root: Path, *extra_args: str, env: dict | None = None,
164) -> subprocess.CompletedProcess:
165 return subprocess.run(
166 [
167 sys.executable, "-m", "oct.cli", "git", "commit",
168 *extra_args,
169 ],
170 cwd=project_root,
171 capture_output=True,
172 text=True,
173 env=env,
174 )
175
176
178 """Default ``warn`` mode prints a warning but does not block."""
179
180 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
182 self, temp_git_project, make_compliant_py,
183 ) -> None:
184 path = make_compliant_py("src/clean.py")
185 _git(temp_git_project, "add", "src/clean.py")
186 result = _run_commit(temp_git_project, "-m", "feat: add clean")
187 assert result.returncode == 0, result.stderr
188 assert "mutated during" not in result.stderr
189 assert "tracked file mutation" not in result.stderr
190
191 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
193 self, temp_git_project, make_compliant_py,
194 ) -> None:
195 # Stage one compliant file, leave another tracked file modified
196 # but NOT staged. The observer should NOT trip because the
197 # mutation pre-existed the commit; it only flags mutations that
198 # happen *between* the start and end snapshots.
199 a = make_compliant_py("src/a.py")
200 b = make_compliant_py("src/b.py")
201 _git(temp_git_project, "add", "src/a.py", "src/b.py")
202 _git(temp_git_project, "commit", "-m", "init", "--quiet")
203
204 b.write_text(
205 b.read_text(encoding="utf-8") + "\n# drift\n",
206 encoding="utf-8",
207 )
208 a.write_text(
209 a.read_text(encoding="utf-8") + "\n# new\n",
210 encoding="utf-8",
211 )
212 _git(temp_git_project, "add", "src/a.py")
213
214 result = _run_commit(temp_git_project, "-m", "feat: bump a")
215 assert result.returncode == 0, result.stderr
216 # Pre-existing drift on b.py is NOT a mid-commit mutation.
217 assert "mutated during" not in result.stderr
218
219
220# =====================================================================
221# Integration: block mode aborts with exit 6 on a manufactured mutation
222# =====================================================================
223
224
226
227 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
229 self, temp_git_project, make_compliant_py,
230 ) -> None:
231 # Configure block mode in octrc.
232 octrc_path = temp_git_project / ".octrc.json"
233 cfg = json.loads(octrc_path.read_text(encoding="utf-8"))
234 cfg.setdefault("git", {})["observe_unstaged_mutation"] = "block"
235 octrc_path.write_text(
236 json.dumps(cfg, indent=2), encoding="utf-8",
237 )
238
239 # Build two committed files; stage A; mutate B mid-commit via
240 # a pre-commit hook that touches it.
241 a = make_compliant_py("src/a.py")
242 b = make_compliant_py("src/b.py")
243 _git(temp_git_project, "add", "src/a.py", "src/b.py")
244 _git(temp_git_project, "commit", "-m", "init", "--quiet")
245
246 # Install a real git pre-commit hook that mutates b.py.
247 hooks_dir = temp_git_project / ".git" / "hooks"
248 hooks_dir.mkdir(parents=True, exist_ok=True)
249 hook = hooks_dir / "pre-commit"
250 rel_b = (b.relative_to(temp_git_project)).as_posix()
251 hook.write_text(
252 "#!/bin/sh\n"
253 f'printf "\\n# injected by hook\\n" >> "{rel_b}"\n',
254 encoding="utf-8",
255 )
256 try:
257 hook.chmod(0o755)
258 except OSError:
259 pass
260
261 a.write_text(
262 a.read_text(encoding="utf-8") + "\n# bump\n",
263 encoding="utf-8",
264 )
265 _git(temp_git_project, "add", "src/a.py")
266
267 result = _run_commit(temp_git_project, "-m", "feat: bump a")
268 # On Windows, /bin/sh hooks may not execute; tolerate that case.
269 if result.returncode == 0 and "mutated during" not in result.stderr:
270 pytest.skip("git pre-commit hook did not fire on this platform")
271
272 assert result.returncode == 6, (
273 f"expected exit 6 (observer block), got "
274 f"{result.returncode}: {result.stderr}"
275 )
276 assert "mutated during" in result.stderr.lower() \
277 or "tracked-file mutation" in result.stderr.lower()
278
279
280# =====================================================================
281# Override: --ignore-unstaged-mutation downgrades block to warn
282# =====================================================================
283
284
286
287 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
289 self, temp_git_project, make_compliant_py,
290 ) -> None:
291 octrc_path = temp_git_project / ".octrc.json"
292 cfg = json.loads(octrc_path.read_text(encoding="utf-8"))
293 cfg.setdefault("git", {})["observe_unstaged_mutation"] = "block"
294 octrc_path.write_text(
295 json.dumps(cfg, indent=2), encoding="utf-8",
296 )
297
298 a = make_compliant_py("src/a.py")
299 _git(temp_git_project, "add", "src/a.py")
300 # No mid-commit mutation here — flag should be a no-op when the
301 # observer didn't fire. Just verify it parses and the commit
302 # still succeeds.
303 result = _run_commit(
304 temp_git_project, "-m", "feat: add a",
305 "--ignore-unstaged-mutation",
306 )
307 assert result.returncode == 0, result.stderr
None test_block_mode_via_octrc(self, temp_git_project, make_compliant_py)
None test_flag_downgrades_block_to_warn(self, temp_git_project, make_compliant_py)
None test_no_block_on_warn_mode(self, temp_git_project, make_compliant_py)
None test_clean_commit_no_warning(self, temp_git_project, make_compliant_py)
None test_oversize_file_uses_sentinel(self, Path git_repo)
_git(Path repo, *str args, bool check=True)
subprocess.CompletedProcess _run_commit(Path project_root, *str extra_args, dict|None env=None)