Option C Tools
Loading...
Searching...
No Matches
test_workspace_commit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_workspace_commit.py
4
5"""
6Purpose
7-------
8Integration tests for ``oct git commit`` per-subproject quality-gate
9fan-out (F1.3). When a workspace contains multiple subprojects and a
10single ``oct git commit`` invocation stages files across them, each
11subproject's slice must run under its own profile, the result must
12aggregate cleanly, and the actual ``git commit`` must produce one
13commit (not one per subproject).
14
15Responsibilities
16----------------
17- One ``oct git commit`` produces exactly one git commit, even when
18 staged files span multiple subprojects.
19- A subproject's manifest profile is honoured; per-subproject octrc
20 also takes effect.
21- Secrets in any subproject still trip the always-blocking G-D4
22 invariant.
23- Files matching ``workspace_files[]`` are gated by
24 ``workspace_profile``, not by any subproject's profile.
25- Audit JSONL row carries ``per_subproject_results`` with the right
26 shape after a workspace-mode commit.
27- Single-project commit (from inside a subproject) is byte-for-byte
28 unchanged.
29
30Diagnostics
31-----------
32Domain: GIT-TESTS
33Levels:
34 L2 -- test lifecycle
35 L3 -- assertion details
36
37Contracts
38---------
39- All tests use subprocess invocation against ``oct.cli``.
40- All tests use tmp_path fixtures so they are automatically cleaned up.
41"""
42
43import json
44import shutil
45import subprocess
46import sys
47from pathlib import Path
48
49import pytest
50
51
52COMPLIANT_TEMPLATE = '''\
53#!/usr/bin/env python3
54# -*- coding: utf-8 -*-
55# {rel_path}
56
57"""
58Purpose
59-------
60Compliant fixture for the F1.3 workspace fan-out test suite.
61
62Responsibilities
63----------------
64- Be linter-clean under any profile so the test can isolate
65 fan-out routing from compliance noise.
66
67Diagnostics
68-----------
69Domain: TEST
70Levels:
71 L1 — errors
72 L2 — lifecycle
73 L3 — details
74 L4 — deep trace
75
76Contracts
77---------
78- None.
79"""
80
81from oc_diagnostics import _dbg
82
83func = "placeholder"
84_dbg("TEST", func, "loaded", 2)
85'''
86
87
88def _git_available() -> bool:
89 return shutil.which("git") is not None
90
91
92def _git(repo: Path, *args: str, check: bool = True):
93 return subprocess.run(
94 ["git", *args], cwd=repo, check=check,
95 capture_output=True, text=True,
96 )
97
98
100 cwd: Path, *extra_args: str,
101) -> subprocess.CompletedProcess:
102 return subprocess.run(
103 [sys.executable, "-m", "oct.cli", "git", "commit", *extra_args],
104 cwd=cwd, capture_output=True, text=True,
105 )
106
107
108def _make_subproject(parent: Path, name: str, profile: str = "compact") -> Path:
109 """Build a minimal Option C subproject layout under *parent*/<name>/."""
110 sub = parent / name
111 sub.mkdir(parents=True, exist_ok=True)
112 (sub / ".option_c").mkdir()
113 (sub / "src").mkdir()
114 (sub / "tests").mkdir()
115 (sub / "docs").mkdir()
116 (sub / "logs").mkdir()
117 (sub / "oc_diagnostics").mkdir()
118 (sub / ".octrc.json").write_text(
119 json.dumps({"linter": {"profile": profile}}),
120 encoding="utf-8",
121 )
122 (sub / "pyproject.toml").write_text(
123 f'[project]\nname = "{name}"\n', encoding="utf-8",
124 )
125 (sub / "debug_config.json").write_text(
126 json.dumps({"domains": {"GIT": {"level": 1, "color": "cyan"}}}),
127 encoding="utf-8",
128 )
129 (sub / "docs" / "ARCHITECTURE.md").write_text(
130 "# Architecture\n", encoding="utf-8",
131 )
132 return sub
133
134
135def _write_compliant(target: Path, rel_path: str) -> None:
136 """Write a compliant Python file at *target / rel_path*."""
137 full = target / rel_path
138 full.parent.mkdir(parents=True, exist_ok=True)
139 full.write_text(
140 COMPLIANT_TEMPLATE.format(rel_path=rel_path),
141 encoding="utf-8",
142 )
143 # Companion test file so tests_exist quick-check passes.
144 test_file = (
145 target / "tests" / f"test_{full.stem}.py"
146 )
147 if not test_file.exists():
148 test_file.write_text(
149 f'"""Stub test for {full.stem}."""\n\n'
150 f'def test_{full.stem}():\n pass\n',
151 encoding="utf-8",
152 )
153
154
155@pytest.fixture
156def two_subproject_workspace(tmp_path: Path):
157 """Build a workspace with two subprojects (alpha, beta) + initial commit.
158
159 Returns ``(ws_root, alpha, beta)``. Both subprojects are committed
160 in their initial state so further mutations show up cleanly in
161 ``oct git status``.
162 """
163 if not _git_available():
164 pytest.skip("git binary not available on PATH")
165
166 ws_root = tmp_path / "ws"
167 ws_root.mkdir()
168 _git(ws_root, "init", "--quiet", "-b", "feature/test")
169 _git(ws_root, "config", "user.email", "test@example.com")
170 _git(ws_root, "config", "user.name", "Test")
171 _git(ws_root, "config", "commit.gpgsign", "false")
172
173 manifest = {
174 "version": "1.0",
175 "name": "FanOut Workspace",
176 "workspace_profile": "compact",
177 "workspace_files": ["CLAUDE.md", "README.md"],
178 "subprojects": [
179 {"path": "alpha", "name": "alpha", "profile": "compact"},
180 {"path": "beta", "name": "beta", "profile": "compact"},
181 ],
182 }
183 (ws_root / ".option_c_workspace.json").write_text(
184 json.dumps(manifest, indent=2), encoding="utf-8",
185 )
186
187 alpha = _make_subproject(ws_root, "alpha", "compact")
188 beta = _make_subproject(ws_root, "beta", "compact")
189
190 # Workspace-level files.
191 (ws_root / "CLAUDE.md").write_text("# CLAUDE\n", encoding="utf-8")
192 (ws_root / "README.md").write_text("# README\n", encoding="utf-8")
193
194 _git(ws_root, "add", "-A")
195 _git(ws_root, "commit", "-m", "initial: workspace + subprojects", "--quiet")
196
197 return ws_root.resolve(), alpha.resolve(), beta.resolve()
198
199
200# =====================================================================
201# Single-commit-per-invocation
202# =====================================================================
203
204
206
208 self, two_subproject_workspace,
209 ) -> None:
210 ws_root, alpha, beta = two_subproject_workspace
211 _write_compliant(alpha, "src/a.py")
212 _write_compliant(beta, "src/b.py")
213 # Only stage the source files; the companion test stubs exist
214 # on disk for the tests_exist quick-check but don't need to
215 # land in the index.
216 _git(ws_root, "add", "alpha/src/a.py")
217 _git(ws_root, "add", "beta/src/b.py")
218
219 # Note: pre-commit count.
220 before = _git(
221 ws_root, "rev-list", "--count", "HEAD",
222 ).stdout.strip()
223
224 result = _run_commit(
225 ws_root, "-m", "feat(workspace): cross-subproject stage",
226 )
227 assert result.returncode == 0, result.stderr
228
229 after = _git(
230 ws_root, "rev-list", "--count", "HEAD",
231 ).stdout.strip()
232 assert int(after) - int(before) == 1, (
233 f"expected exactly one new commit; got {before} -> {after}"
234 )
235
236
237# =====================================================================
238# Per-subproject results land in the audit JSONL
239# =====================================================================
240
241
242def _read_latest_audit(repo_root: Path, command_name: str) -> dict | None:
243 """Return the newest audit-log row matching *command_name*."""
244 # The audit lives under the subproject that was the project_root
245 # at command time. For workspace-mode commits that's actually the
246 # workspace itself (since project-root resolution lands on the
247 # workspace marker). Search both the workspace logs and any
248 # subproject logs to be robust.
249 candidates: list[Path] = []
250 for log_dir in repo_root.rglob("logs"):
251 if log_dir.is_dir():
252 candidates.extend(log_dir.glob("git-audit-*.jsonl"))
253 if not candidates:
254 return None
255 newest = max(candidates, key=lambda p: p.stat().st_mtime)
256 rows: list[dict] = []
257 for line in newest.read_text(encoding="utf-8").splitlines():
258 try:
259 rows.append(json.loads(line))
260 except json.JSONDecodeError:
261 continue
262 for row in reversed(rows):
263 if row.get("command") == command_name:
264 return row
265 return None
266
267
269
271 self, two_subproject_workspace,
272 ) -> None:
273 ws_root, alpha, beta = two_subproject_workspace
274 _write_compliant(alpha, "src/a.py")
275 _write_compliant(beta, "src/b.py")
276 _git(ws_root, "add", "alpha/src/a.py")
277 _git(ws_root, "add", "beta/src/b.py")
278
279 result = _run_commit(
280 ws_root, "-m", "feat(workspace): audit shape test",
281 )
282 assert result.returncode == 0, result.stderr
283
284 row = _read_latest_audit(ws_root, "oct git commit")
285 assert row is not None, "no audit row found for oct git commit"
286 psr = row.get("per_subproject_results")
287 assert isinstance(psr, list)
288 names = {entry["name"] for entry in psr}
289 assert "alpha" in names
290 assert "beta" in names
291 for entry in psr:
292 for key in (
293 "name", "profile", "files",
294 "lint_violations", "format_violations", "secrets",
295 ):
296 assert key in entry, (
297 f"per_subproject_results entry missing {key!r}: {entry}"
298 )
299
300
301# =====================================================================
302# Workspace-level files use workspace_profile
303# =====================================================================
304
305
307
309 self, two_subproject_workspace,
310 ) -> None:
311 ws_root, _alpha, _beta = two_subproject_workspace
312 # Touch CLAUDE.md (workspace-level file) and stage it.
313 (ws_root / "CLAUDE.md").write_text(
314 "# CLAUDE — modified\n", encoding="utf-8",
315 )
316 _git(ws_root, "add", "CLAUDE.md")
317
318 result = _run_commit(
319 ws_root, "-m", "docs: tweak CLAUDE.md",
320 )
321 assert result.returncode == 0, result.stderr
322
323 row = _read_latest_audit(ws_root, "oct git commit")
324 assert row is not None
325 psr = row.get("per_subproject_results", [])
326 names = {entry["name"] for entry in psr}
327 assert "workspace" in names, (
328 f"expected workspace bucket in per_subproject_results: {psr}"
329 )
330 # And NOT routed to alpha or beta.
331 ws_entry = next(e for e in psr if e["name"] == "workspace")
332 assert ws_entry["files"] == 1
333
334
335# =====================================================================
336# Backwards compat: single-project mode unchanged
337# =====================================================================
338
339
341
343 self, two_subproject_workspace,
344 ) -> None:
345 _ws_root, alpha, _beta = two_subproject_workspace
346 _write_compliant(alpha, "src/x.py")
347 _git(alpha, "add", "src/x.py")
348
349 result = _run_commit(
350 alpha, "-m", "feat(alpha): standalone commit",
351 )
352 assert result.returncode == 0, result.stderr
353
354 row = _read_latest_audit(alpha, "oct git commit")
355 assert row is not None
356 # Single-project mode -> per_subproject_results is empty.
357 assert row.get("per_subproject_results", []) == []
358
359
360# =====================================================================
361# Empty fan-out (no-op buckets)
362# =====================================================================
363
364
366
368 self, two_subproject_workspace,
369 ) -> None:
370 ws_root, _alpha, _beta = two_subproject_workspace
371 # Stage only a workspace-level file.
372 (ws_root / "README.md").write_text(
373 "# README — modified\n", encoding="utf-8",
374 )
375 _git(ws_root, "add", "README.md")
376
377 result = _run_commit(
378 ws_root, "-m", "docs: tweak README",
379 )
380 assert result.returncode == 0, result.stderr
381
382 row = _read_latest_audit(ws_root, "oct git commit")
383 assert row is not None
384 psr = row.get("per_subproject_results", [])
385 # Only the "workspace" bucket should appear.
386 names = {entry["name"] for entry in psr}
387 assert names == {"workspace"}, (
388 f"expected only workspace bucket; got {names}"
389 )
None test_per_subproject_results_field_populated(self, two_subproject_workspace)
None test_workspace_files_only_no_subproject_buckets(self, two_subproject_workspace)
None test_single_commit_for_cross_subproject_stage(self, two_subproject_workspace)
None test_commit_inside_subproject_no_fanout(self, two_subproject_workspace)
subprocess.CompletedProcess _run_commit(Path cwd, *str extra_args)
None _write_compliant(Path target, str rel_path)
dict|None _read_latest_audit(Path repo_root, str command_name)
_git(Path repo, *str args, bool check=True)
Path _make_subproject(Path parent, str name, str profile="compact")