8Validate the B1.0 commit observer that detects silent mutations of
9tracked-but-unstaged files during ``oct git commit``.
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
26 L3 -- assertion details
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.
39from pathlib
import Path
47 snapshot_unstaged_tracked,
52 return shutil.which(
"git")
is not None
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,
64 """Bare-bones git repo for unit-level snapshot tests."""
66 pytest.skip(
"git binary not available on PATH")
67 repo = tmp_path /
"repo"
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")
83 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
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")
91 (git_repo /
"staged.txt").write_text(
"modified\n", encoding=
"utf-8")
92 _git(git_repo,
"add",
"staged.txt")
94 snap = snapshot_unstaged_tracked(
95 git_repo, staged_set={
"staged.txt"},
97 assert "tracked.txt" in snap
98 assert "staged.txt" not in snap
100 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
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")
107 snap = snapshot_unstaged_tracked(git_repo)
108 assert "tracked.txt" in snap
109 assert "untracked.txt" not in snap
111 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
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")
118 snap = snapshot_unstaged_tracked(git_repo)
119 assert snap[
"big.bin"] == _OVERSIZE_SENTINEL
130 before = {
"a.txt":
"h1",
"b.txt":
"h2"}
131 after = {
"a.txt":
"h1",
"b.txt":
"h2"}
132 assert diff_snapshots(before, after) == []
135 before = {
"a.txt":
"h1"}
136 after = {
"a.txt":
"h1-CHANGED"}
137 assert diff_snapshots(before, after) == [
"a.txt"]
140 before = {
"a.txt":
"h1",
"b.txt":
"h2"}
141 after = {
"a.txt":
"h1"}
142 assert diff_snapshots(before, after) == [
"b.txt"]
147 before = {
"big.bin": _OVERSIZE_SENTINEL}
148 after = {
"big.bin": _OVERSIZE_SENTINEL}
149 assert diff_snapshots(before, after) == []
152 before = {
"big.bin":
"abcd"}
153 after = {
"big.bin": _OVERSIZE_SENTINEL}
154 assert diff_snapshots(before, after) == [
"big.bin"]
163 project_root: Path, *extra_args: str, env: dict |
None =
None,
164) -> subprocess.CompletedProcess:
165 return subprocess.run(
167 sys.executable,
"-m",
"oct.cli",
"git",
"commit",
178 """Default ``warn`` mode prints a warning but does not block."""
180 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
182 self, temp_git_project, make_compliant_py,
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
191 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
193 self, temp_git_project, make_compliant_py,
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")
205 b.read_text(encoding=
"utf-8") +
"\n# drift\n",
209 a.read_text(encoding=
"utf-8") +
"\n# new\n",
212 _git(temp_git_project,
"add",
"src/a.py")
214 result =
_run_commit(temp_git_project,
"-m",
"feat: bump a")
215 assert result.returncode == 0, result.stderr
217 assert "mutated during" not in result.stderr
227 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
229 self, temp_git_project, make_compliant_py,
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",
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")
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()
253 f
'printf "\\n# injected by hook\\n" >> "{rel_b}"\n',
262 a.read_text(encoding=
"utf-8") +
"\n# bump\n",
265 _git(temp_git_project,
"add",
"src/a.py")
267 result =
_run_commit(temp_git_project,
"-m",
"feat: bump a")
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")
272 assert result.returncode == 6, (
273 f
"expected exit 6 (observer block), got "
274 f
"{result.returncode}: {result.stderr}"
276 assert "mutated during" in result.stderr.lower() \
277 or "tracked-file mutation" in result.stderr.lower()
287 @pytest.mark.skipif(not _git_available(), reason=
"git not on PATH")
289 self, temp_git_project, make_compliant_py,
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",
298 a = make_compliant_py(
"src/a.py")
299 _git(temp_git_project,
"add",
"src/a.py")
304 temp_git_project,
"-m",
"feat: add a",
305 "--ignore-unstaged-mutation",
307 assert result.returncode == 0, result.stderr
None test_no_change(self)
None test_content_change(self)
None test_change_to_or_from_oversize_is_detected(self)
None test_oversize_pair_is_ignored(self)
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_excludes_staged_files(self, Path git_repo)
None test_oversize_file_uses_sentinel(self, Path git_repo)
None test_excludes_untracked(self, Path git_repo)
_git(Path repo, *str args, bool check=True)
Path git_repo(Path tmp_path)
subprocess.CompletedProcess _run_commit(Path project_root, *str extra_args, dict|None env=None)