Option C Tools
Loading...
Searching...
No Matches
test_git_add.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_git_add.py
4
5"""
6Purpose
7-------
8Integration tests for ``oct git add`` (Phase 4G).
9
10Responsibilities
11----------------
12- Verify whole-file staging stages the requested paths.
13- Verify ``--update`` and ``--all`` behave like ``git add -u`` / ``-A``.
14- Verify ``--dry-run`` does not modify the index.
15- Verify ``--patch`` constructs the correct argv (passthrough is mocked).
16- Verify mutual exclusivity rules.
17- Verify JSON output schema.
18- Verify the post-stage quality gate advisory.
19
20Diagnostics
21-----------
22Domain: GIT-TESTS
23Levels:
24 L2 -- test lifecycle
25 L3 -- assertion details
26 L4 -- deep tracing
27
28Contracts
29---------
30- All tests use subprocess invocation against ``oct.cli``.
31- All tests use tmp_path fixtures so they are automatically cleaned up.
32"""
33
34import json
35import shutil
36import subprocess
37import sys
38from pathlib import Path
39
40import pytest
41
42from tests.tests_git.conftest import _git
43
44
45def _git_available() -> bool:
46 return shutil.which("git") is not None
47
48
49def _run_add(root: Path, *extra_args: str) -> subprocess.CompletedProcess:
50 """Run ``oct git add`` in a subprocess."""
51 return subprocess.run(
52 [sys.executable, "-m", "oct.cli", "git", "add", *extra_args],
53 cwd=root,
54 capture_output=True,
55 text=True,
56 )
57
58
59def _staged_names(repo: Path) -> list[str]:
60 """Return file names currently staged (index)."""
61 result = subprocess.run(
62 ["git", "diff", "--cached", "--name-only"],
63 cwd=repo, check=True, capture_output=True, text=True,
64 )
65 return [
66 line.strip() for line in result.stdout.splitlines() if line.strip()
67 ]
68
69
70# =====================================================================
71# Whole-file staging
72# =====================================================================
73
74
76
77 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
78 def test_stages_one_file(self, temp_git_project: Path) -> None:
79 (temp_git_project / "src" / "a.py").write_text(
80 "x = 1\n", encoding="utf-8",
81 )
82 result = _run_add(temp_git_project, "src/a.py")
83 assert result.returncode == 0, result.stderr
84 assert "src/a.py" in _staged_names(temp_git_project)
85
86
88
89 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
90 def test_stages_three_files(self, temp_git_project: Path) -> None:
91 for name in ("a.py", "b.py", "c.py"):
92 (temp_git_project / "src" / name).write_text(
93 "x = 1\n", encoding="utf-8",
94 )
95 result = _run_add(
96 temp_git_project, "src/a.py", "src/b.py", "src/c.py",
97 )
98 assert result.returncode == 0, result.stderr
99 names = _staged_names(temp_git_project)
100 for name in ("src/a.py", "src/b.py", "src/c.py"):
101 assert name in names
102
103
105
106 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
108 self, temp_git_project: Path,
109 ) -> None:
110 # Commit a tracked file first.
111 (temp_git_project / "src" / "tracked.py").write_text(
112 "x = 1\n", encoding="utf-8",
113 )
114 _git(temp_git_project, "add", "src/tracked.py")
115 _git(temp_git_project, "commit", "-m", "add tracked", "--quiet")
116
117 # Modify it.
118 (temp_git_project / "src" / "tracked.py").write_text(
119 "x = 2\n", encoding="utf-8",
120 )
121 # Add an untracked file.
122 (temp_git_project / "src" / "untracked.py").write_text(
123 "x = 3\n", encoding="utf-8",
124 )
125
126 result = _run_add(temp_git_project, "--update")
127 assert result.returncode == 0, result.stderr
128 names = _staged_names(temp_git_project)
129 assert "src/tracked.py" in names
130 assert "src/untracked.py" not in names
131
132
134
135 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
136 def test_all_stages_untracked(self, temp_git_project: Path) -> None:
137 (temp_git_project / "src" / "new.py").write_text(
138 "x = 1\n", encoding="utf-8",
139 )
140 result = _run_add(temp_git_project, "--all")
141 assert result.returncode == 0, result.stderr
142 assert "src/new.py" in _staged_names(temp_git_project)
143
144
145# =====================================================================
146# Dry-run
147# =====================================================================
148
149
151
152 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
153 def test_dry_run_does_not_stage(self, temp_git_project: Path) -> None:
154 (temp_git_project / "src" / "a.py").write_text(
155 "x = 1\n", encoding="utf-8",
156 )
157 result = _run_add(temp_git_project, "--dry-run", "src/a.py")
158 assert result.returncode == 0, result.stderr
159 assert "Would stage" in result.stdout
160 # Index should be empty.
161 assert _staged_names(temp_git_project) == []
162
163
164# =====================================================================
165# Path scope
166# =====================================================================
167
168
170
171 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
173 self, temp_git_project: Path, tmp_path: Path,
174 ) -> None:
175 # Create a file *outside* the project root.
176 outside = tmp_path / "outside.py"
177 outside.write_text("x = 1\n", encoding="utf-8")
178 result = _run_add(temp_git_project, str(outside))
179 # All paths are skipped; nothing left to stage and no flags -> exit 1.
180 assert result.returncode == 1
181 assert "outside project scope" in result.stderr.lower()
182
183
184# =====================================================================
185# JSON
186# =====================================================================
187
188
190
191 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
192 def test_json_schema(self, temp_git_project: Path) -> None:
193 (temp_git_project / "src" / "a.py").write_text(
194 "x = 1\n", encoding="utf-8",
195 )
196 result = _run_add(temp_git_project, "--json", "src/a.py")
197 assert result.returncode == 0, result.stderr
198 data = json.loads(result.stdout)
199 for key in ("operation", "paths_staged", "paths_skipped",
200 "post_check", "exit_code", "errors"):
201 assert key in data, f"missing key: {key}"
202 assert data["operation"] == "add"
203 assert data["exit_code"] == 0
204
205
206# =====================================================================
207# Mutual exclusivity
208# =====================================================================
209
210
212
213 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
215 self, temp_git_project: Path,
216 ) -> None:
217 result = _run_add(temp_git_project, "--patch", "--all")
218 assert result.returncode == 1
219 assert "cannot be combined" in result.stderr.lower()
220
221 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
223 self, temp_git_project: Path,
224 ) -> None:
225 result = _run_add(temp_git_project, "--update", "--all")
226 assert result.returncode == 1
227 assert "mutually exclusive" in result.stderr.lower()
228
229
230# =====================================================================
231# No-op safety
232# =====================================================================
233
234
236
237 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
239 self, temp_git_project: Path,
240 ) -> None:
241 result = _run_add(temp_git_project)
242 assert result.returncode == 1
243 assert "no paths to stage" in result.stderr.lower()
244
245
246# =====================================================================
247# L2: harmless git warnings should still exit 0
248# =====================================================================
249
250
252
253 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
255 self, temp_git_project: Path,
256 ) -> None:
257 # Track a file, then add the parent directory to .gitignore.
258 # When we re-stage the tracked file, git emits the "ignored
259 # path" warning and exits 1 — but the file IS staged.
260 ignored_dir = temp_git_project / "src" / "build_artefacts"
261 ignored_dir.mkdir(parents=True, exist_ok=True)
262 f = ignored_dir / "tracked.txt"
263 f.write_text("x\n", encoding="utf-8")
264 _git(temp_git_project, "add", "src/build_artefacts/tracked.txt")
265 _git(
266 temp_git_project, "commit", "-m", "track artefact", "--quiet",
267 )
268
269 # Now mark the dir as ignored AND modify the tracked file.
270 gitignore = temp_git_project / ".gitignore"
271 existing = (
272 gitignore.read_text(encoding="utf-8")
273 if gitignore.is_file() else ""
274 )
275 gitignore.write_text(
276 existing + "\nsrc/build_artefacts/\n",
277 encoding="utf-8",
278 )
279 f.write_text("modified\n", encoding="utf-8")
280
281 result = _run_add(
282 temp_git_project, "src/build_artefacts/tracked.txt",
283 )
284 # L2: even though git warns, the file was staged -> exit 0.
285 assert result.returncode == 0, (
286 f"L2: expected exit 0 on staged-with-warning; "
287 f"got {result.returncode}\nstderr: {result.stderr}"
288 )
289 # File should be staged (path, not basename).
290 assert "src/build_artefacts/tracked.txt" in _staged_names(
291 temp_git_project,
292 )
293
294
295# =====================================================================
296# Audit trail
297# =====================================================================
298
299
301
302 @pytest.mark.skipif(not _git_available(), reason="git not on PATH")
303 def test_audit_record_written(self, temp_git_project: Path) -> None:
304 (temp_git_project / "src" / "a.py").write_text(
305 "x = 1\n", encoding="utf-8",
306 )
307 result = _run_add(temp_git_project, "src/a.py")
308 assert result.returncode == 0, result.stderr
309
310 # Audit log should exist with at least one "oct git add" entry.
311 audit_dir = temp_git_project / "logs"
312 assert audit_dir.is_dir()
313 audit_files = list(audit_dir.glob("git-audit-*.jsonl"))
314 assert audit_files, "no audit file written"
315
316 found = False
317 for af in audit_files:
318 for line in af.read_text(encoding="utf-8").splitlines():
319 try:
320 rec = json.loads(line)
321 except json.JSONDecodeError:
322 continue
323 if rec.get("command") == "oct git add":
324 found = True
325 break
326 if found:
327 break
328 assert found, "no 'oct git add' audit record found"
None test_all_stages_untracked(self, Path temp_git_project)
None test_audit_record_written(self, Path temp_git_project)
None test_dry_run_does_not_stage(self, Path temp_git_project)
None test_tracked_file_in_ignored_dir_exits_zero(self, Path temp_git_project)
None test_json_schema(self, Path temp_git_project)
None test_stages_three_files(self, Path temp_git_project)
None test_update_with_all_rejected(self, Path temp_git_project)
None test_patch_with_all_rejected(self, Path temp_git_project)
None test_no_paths_no_flags_errors(self, Path temp_git_project)
None test_outside_path_skipped_with_warning(self, Path temp_git_project, Path tmp_path)
None test_stages_one_file(self, Path temp_git_project)
None test_update_only_stages_tracked(self, Path temp_git_project)
subprocess.CompletedProcess _run_add(Path root, *str extra_args)
list[str] _staged_names(Path repo)