Option C Tools
Loading...
Searching...
No Matches
test_git.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_core/test_git.py
4
5"""
6Purpose
7-------
8Validate the V4.0 Phase 4A Git integration foundation in
9``oct.core.git``: exception hierarchy, subprocess discipline,
10credential redaction, and the 11 read-only state query functions.
11
12Responsibilities
13----------------
14- Confirm the ``GitError`` hierarchy (G-A1).
15- Confirm ``git_run`` enforces argv-only, timeout, text mode, utf-8
16 with ``errors="replace"``, and translates subprocess failures to
17 ``GitNotFoundError`` / ``GitTimeoutError`` / ``GitCommandError``
18 (G-A2).
19- Confirm every query function behaves correctly against a real git
20 repo, raising the right error on missing repo / missing binary
21 (G-A3).
22- Confirm path-escape filtering in ``git_changed_files`` and
23 ``git_staged_files`` (G-A3 + G-A4 defense-in-depth).
24- Confirm ``_redact_git_url`` scrubs http(s) credentials while leaving
25 SSH URLs intact (G-A5).
26- Confirm ``_git_changed_files`` shim in the linter continues to
27 return ``[]`` on all ``GitError`` subtypes (G-A7).
28
29Diagnostics
30-----------
31Domain: CORE-TESTS
32Levels:
33 L2 — test lifecycle
34 L3 — per-case setup
35 L4 — fine-grained assertions
36
37Contracts
38---------
39- Tests never write outside of ``tmp_path`` / pytest-managed temp dirs.
40- Tests that require a real ``git`` binary are skipped via the
41 ``git_repo`` fixture when git is missing.
42- Symlink tests skip when the platform cannot create symlinks.
43"""
44
45import os
46import subprocess
47import tempfile
48from pathlib import Path
49from unittest.mock import patch
50
51import pytest
52
53
55 """Probe whether this process can create a real OS symlink.
56
57 Equivalent to the session-scoped ``can_symlink`` fixture in
58 ``tests_core/conftest.py`` but evaluated at module import so the
59 result is usable in ``@pytest.mark.skipif`` decorators (which run
60 at collection time and cannot consume runtime fixtures). On
61 Windows without admin elevation or developer-mode this returns
62 ``False``; on POSIX it returns ``True`` unless the filesystem
63 refuses the syscall (e.g. some FAT mounts).
64 """
65 try:
66 with tempfile.TemporaryDirectory() as d:
67 target = Path(d) / "target.txt"
68 link = Path(d) / "link.txt"
69 target.write_text("x", encoding="utf-8")
70 os.symlink(target, link)
71 return link.exists()
72 except (OSError, NotImplementedError):
73 return False
74
75
76_CAN_SYMLINK: bool = _platform_can_symlink()
77_SYMLINK_SKIP_REASON: str = (
78 "Platform cannot create symlinks without elevation "
79 "(Windows non-admin / non-developer-mode, or FS refuses the syscall)."
80)
81
82from oct.core import git as git_mod
83from oct.core.git import (
84 DEFAULT_GIT_TIMEOUT,
85 GitCommandError,
86 GitError,
87 GitNotARepoError,
88 GitNotFoundError,
89 GitTimeoutError,
90 _redact_argv,
91 _redact_git_url,
92 current_branch,
93 get_repo_root,
94 git_changed_files,
95 git_run,
96 git_staged_files,
97 has_uncommitted_changes,
98 has_untracked_files,
99 is_detached_head,
100 is_git_repo,
101 last_commit_message,
102 last_commit_sha,
103 remote_urls,
104)
105from oct.core.project_root import is_within_project_root
106
107
108# ============================================================
109# G-A1: Exception hierarchy
110# ============================================================
111
112
114 assert issubclass(GitNotFoundError, GitError)
115 assert issubclass(GitNotARepoError, GitError)
116 assert issubclass(GitTimeoutError, GitError)
117 assert issubclass(GitCommandError, GitError)
118
119
121 err = GitCommandError(
122 ["git", "status"], returncode=128,
123 stdout="out", stderr="fatal: not a repo",
124 )
125 assert err.returncode == 128
126 assert err.stdout == "out"
127 assert err.stderr == "fatal: not a repo"
128 assert err.argv == ["git", "status"]
129 assert "128" in str(err)
130 assert "status" in str(err)
131
132
133# ============================================================
134# G-A5: Credential redaction
135# ============================================================
136
137
139 out = _redact_git_url("clone http://user:pass@example.com/r.git ok")
140 assert "user:pass" not in out
141 assert "http://***@example.com/r.git" in out
142
143
145 out = _redact_git_url("https://alice:s3cr3t@github.com/x/y.git")
146 assert "alice" not in out
147 assert "s3cr3t" not in out
148 assert "https://***@github.com/x/y.git" in out
149
150
152 url = "git@github.com:user/repo.git"
153 assert _redact_git_url(url) == url
154
155
157 assert _redact_git_url("") == ""
158
159
161 # None-like falsy case: empty string is the only contract; None
162 # would not come from subprocess stdout but we still guard it.
163 assert _redact_git_url("") == ""
164
165
167 text = (
168 "origin\thttps://u:t@github.com/x/y.git (fetch)\n"
169 "origin\thttps://u:t@github.com/x/y.git (push)\n"
170 )
171 out = _redact_git_url(text)
172 assert "u:t" not in out
173 assert out.count("***@github.com") == 2
174
175
177 text = "some random commit message mentioning @user and email@host"
178 # No scheme prefix → nothing to redact.
179 assert _redact_git_url(text) == text
180
181
182# ============================================================
183# OI-507: _redact_argv + credential-safe _dbg/TimeoutExpired/GitCommandError
184# ============================================================
185
186
187_TOKEN_URL = "https://oauth2:ghp_SUPERSECRET@github.com/foo/bar.git"
188
189
191 argv = ["git", "push", _TOKEN_URL]
192 out = _redact_argv(argv)
193 assert "ghp_SUPERSECRET" not in " ".join(out)
194 assert "oauth2" not in " ".join(out)
195 assert "***@github.com" in out[-1]
196
197
199 argv = ["git", "status", "--porcelain"]
200 assert _redact_argv(argv) == argv
201
202
203def test_git_run_dbg_does_not_leak_token(tmp_path, monkeypatch, caplog):
204 """_dbg logs argv before the subprocess runs; it must be redacted."""
205 captured: dict[str, str] = {}
206
207 def fake_dbg(domain, func, msg, level, *args, **kwargs):
208 captured.setdefault(f"{domain}:{func}:{level}", "")
209 captured[f"{domain}:{func}:{level}"] += str(msg) + "\n"
210
211 def fake_run(*a, **kw):
212 return _FakeCompleted(returncode=0, stdout="", stderr="")
213
214 monkeypatch.setattr(git_mod, "_dbg", fake_dbg)
215 monkeypatch.setattr(subprocess, "run", fake_run)
216 git_run(["push", _TOKEN_URL], cwd=tmp_path)
217
218 combined = "\n".join(captured.values())
219 assert "ghp_SUPERSECRET" not in combined
220 assert "oauth2" not in combined
221
222
224 err = GitCommandError(
225 ["git", "push", _TOKEN_URL],
226 returncode=1,
227 stdout="",
228 stderr="fatal: auth failed",
229 )
230 rendered = str(err)
231 assert "ghp_SUPERSECRET" not in rendered
232 assert "oauth2" not in rendered
233 # .argv attribute must also be redacted.
234 assert "ghp_SUPERSECRET" not in " ".join(err.argv)
235
236
237def test_timeout_message_redacts_token(tmp_path, monkeypatch):
238 def fake_run(*a, **kw):
239 raise subprocess.TimeoutExpired(cmd="git", timeout=1)
240
241 monkeypatch.setattr(subprocess, "run", fake_run)
242 with pytest.raises(GitTimeoutError) as exc_info:
243 git_run(["push", _TOKEN_URL], cwd=tmp_path, timeout=1)
244
245 rendered = str(exc_info.value)
246 assert "ghp_SUPERSECRET" not in rendered
247 assert "oauth2" not in rendered
248
249
250# ============================================================
251# G-A2: git_run subprocess wrapper (mocked)
252# ============================================================
253
254
256 def __init__(self, returncode=0, stdout="", stderr=""):
257 self.returncode = returncode
258 self.stdout = stdout
259 self.stderr = stderr
260
261
263 with pytest.raises(GitError):
264 git_run("status", cwd=tmp_path) # type: ignore[arg-type]
265
266
267def test_git_run_happy_path(tmp_path, monkeypatch):
268 calls = {}
269
270 def fake_run(argv, **kwargs):
271 calls["argv"] = argv
272 calls["kwargs"] = kwargs
273 return _FakeCompleted(returncode=0, stdout="main\n", stderr="")
274
275 monkeypatch.setattr(subprocess, "run", fake_run)
276 proc = git_run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=tmp_path)
277 assert proc.stdout == "main\n"
278 assert calls["argv"][0] == "git"
279 assert calls["kwargs"]["shell"] is False
280 assert calls["kwargs"]["text"] is True
281 assert calls["kwargs"]["encoding"] == "utf-8"
282 assert calls["kwargs"]["errors"] == "replace"
283 assert calls["kwargs"]["timeout"] == DEFAULT_GIT_TIMEOUT
284
285
286def test_git_run_raises_git_not_found(tmp_path, monkeypatch):
287 def fake_run(*a, **kw):
288 raise FileNotFoundError("git")
289 monkeypatch.setattr(subprocess, "run", fake_run)
290 with pytest.raises(GitNotFoundError):
291 git_run(["status"], cwd=tmp_path)
292
293
294def test_git_run_raises_timeout(tmp_path, monkeypatch):
295 def fake_run(*a, **kw):
296 raise subprocess.TimeoutExpired(cmd="git", timeout=1)
297 monkeypatch.setattr(subprocess, "run", fake_run)
298 with pytest.raises(GitTimeoutError):
299 git_run(["status"], cwd=tmp_path, timeout=1)
300
301
302def test_git_run_raises_on_nonzero_exit(tmp_path, monkeypatch):
303 def fake_run(*a, **kw):
304 return _FakeCompleted(returncode=128, stdout="", stderr="fatal: boom")
305 monkeypatch.setattr(subprocess, "run", fake_run)
306 with pytest.raises(GitCommandError) as exc_info:
307 git_run(["status"], cwd=tmp_path)
308 assert exc_info.value.returncode == 128
309 assert "boom" in exc_info.value.stderr
310
311
312def test_git_run_nocheck_returns_process(tmp_path, monkeypatch):
313 def fake_run(*a, **kw):
314 return _FakeCompleted(returncode=1, stdout="", stderr="")
315 monkeypatch.setattr(subprocess, "run", fake_run)
316 proc = git_run(["rev-parse"], cwd=tmp_path, check=False)
317 assert proc.returncode == 1
318
319
320def test_git_run_redacts_stderr_on_error(tmp_path, monkeypatch):
321 def fake_run(*a, **kw):
322 return _FakeCompleted(
323 returncode=1, stdout="",
324 stderr="fatal: could not read https://bob:token@host/x.git\n",
325 )
326 monkeypatch.setattr(subprocess, "run", fake_run)
327 with pytest.raises(GitCommandError) as exc_info:
328 git_run(["fetch"], cwd=tmp_path)
329 assert "bob" not in exc_info.value.stderr
330 assert "token" not in exc_info.value.stderr
331 assert "***@host" in exc_info.value.stderr
332
333
334def test_git_run_redacts_stdout_on_success(tmp_path, monkeypatch):
335 def fake_run(*a, **kw):
336 return _FakeCompleted(
337 returncode=0,
338 stdout="origin\thttps://u:p@github.com/x.git (fetch)\n",
339 stderr="",
340 )
341 monkeypatch.setattr(subprocess, "run", fake_run)
342 proc = git_run(["remote", "-v"], cwd=tmp_path)
343 assert "u:p" not in proc.stdout
344 assert "***@github.com" in proc.stdout
345
346
347# ============================================================
348# G-A3: is_git_repo (never-raise)
349# ============================================================
350
351
353 assert is_git_repo(git_repo) is True
354
355
357 assert is_git_repo(tmp_path) is False
358
359
361 def fake_run(*a, **kw):
362 raise FileNotFoundError
363 monkeypatch.setattr(subprocess, "run", fake_run)
364 assert is_git_repo(tmp_path) is False
365
366
368 def fake_run(*a, **kw):
369 raise OSError("permission denied")
370 monkeypatch.setattr(subprocess, "run", fake_run)
371 assert is_git_repo(tmp_path) is False
372
373
374# ============================================================
375# G-A3: query functions (real git integration)
376# ============================================================
377
378
380 assert get_repo_root(git_repo) == git_repo
381
382
384 with pytest.raises(GitNotARepoError):
385 get_repo_root(tmp_path)
386
387
389 from tests.tests_core.conftest import commit_file
390 commit_file(git_repo, "a.txt", "hello", "init")
391 assert current_branch(git_repo) == "main"
392
393
395 from tests.tests_core.conftest import commit_file
396 commit_file(git_repo, "a.txt", "hello", "init")
397 assert is_detached_head(git_repo) is False
398
399
401 from tests.tests_core.conftest import commit_file
402 commit_file(git_repo, "a.txt", "hello", "init")
403 sha = last_commit_sha(git_repo)
404 subprocess.run(
405 ["git", "checkout", "--quiet", sha],
406 cwd=git_repo, check=True, capture_output=True, text=True,
407 )
408 assert is_detached_head(git_repo) is True
409
410
412 from tests.tests_core.conftest import commit_file
413 commit_file(git_repo, "a.txt", "hello", "init")
414 assert has_uncommitted_changes(git_repo) is False
415
416
418 from tests.tests_core.conftest import commit_file
419 commit_file(git_repo, "a.txt", "hello", "init")
420 (git_repo / "a.txt").write_text("changed", encoding="utf-8")
421 assert has_uncommitted_changes(git_repo) is True
422
423
425 assert has_untracked_files(git_repo) is False
426
427
429 (git_repo / "untracked.txt").write_text("x", encoding="utf-8")
430 assert has_untracked_files(git_repo) is True
431
432
434 (git_repo / "new.py").write_text("print()", encoding="utf-8")
435 result = git_changed_files(git_repo)
436 assert len(result) >= 1
437 for p in result:
438 assert p.is_absolute()
439
440
442 (git_repo / "new.py").write_text("print()", encoding="utf-8")
443 (git_repo / "other.txt").write_text("x", encoding="utf-8")
444 py_only = git_changed_files(git_repo, extensions={".py"})
445 assert any(p.suffix == ".py" for p in py_only)
446 assert not any(p.suffix == ".txt" for p in py_only)
447
448
450 from tests.tests_core.conftest import commit_file
451 commit_file(git_repo, "a.txt", "hello", "init")
452 assert git_changed_files(git_repo) == []
453
454
456 (git_repo / "staged.py").write_text("print()", encoding="utf-8")
457 (git_repo / "unstaged.py").write_text("print()", encoding="utf-8")
458 subprocess.run(
459 ["git", "add", "staged.py"],
460 cwd=git_repo, check=True, capture_output=True, text=True,
461 )
462 staged = git_staged_files(git_repo)
463 names = {p.name for p in staged}
464 assert "staged.py" in names
465 assert "unstaged.py" not in names
466
467
469 assert last_commit_sha(git_repo) == ""
470
471
473 from tests.tests_core.conftest import commit_file
474 commit_file(git_repo, "a.txt", "hello", "init")
475 full = last_commit_sha(git_repo)
476 short = last_commit_sha(git_repo, short=True)
477 assert len(full) == 40
478 assert len(short) < len(full)
479 assert full.startswith(short)
480
481
483 assert last_commit_message(git_repo) == ""
484
485
487 from tests.tests_core.conftest import commit_file
488 commit_file(git_repo, "a.txt", "hello", "initial commit")
489 assert "initial commit" in last_commit_message(git_repo)
490
491
493 assert remote_urls(git_repo) == {}
494
495
497 subprocess.run(
498 [
499 "git", "remote", "add", "origin",
500 "https://user:token@example.com/x/y.git",
501 ],
502 cwd=git_repo, check=True, capture_output=True, text=True,
503 )
504 urls = remote_urls(git_repo)
505 assert "origin" in urls
506 assert "user" not in urls["origin"]
507 assert "token" not in urls["origin"]
508 assert "***@example.com" in urls["origin"]
509
510
511# ============================================================
512# G-A4: is_within_project_root predicate
513# ============================================================
514
515
517 child = tmp_path / "sub" / "file.txt"
518 child.parent.mkdir()
519 child.write_text("x", encoding="utf-8")
520 assert is_within_project_root(child, tmp_path) is True
521
522
524 assert is_within_project_root(tmp_path, tmp_path) is True
525
526
528 sibling = tmp_path.parent / "outside-of-tmp"
529 assert is_within_project_root(sibling, tmp_path) is False
530
531
533 # Nonexistent children of tmp_path still resolve as inside.
534 ghost = tmp_path / "does" / "not" / "exist.py"
535 assert is_within_project_root(ghost, tmp_path) is True
536
537
538@pytest.mark.skipif(not _CAN_SYMLINK, reason=_SYMLINK_SKIP_REASON)
540 """Symlinks pointing outside the project root must not be accepted.
541
542 Skipped on platforms without symlink capability — this is an
543 environmental constraint, not a defect in the tested behaviour.
544 """
545 repo = tmp_path / "repo"
546 repo.mkdir()
547 outside = tmp_path / "outside.txt"
548 outside.write_text("secret", encoding="utf-8")
549 link = repo / "link.txt"
550 os.symlink(outside, link)
551 assert is_within_project_root(link, repo) is False
552
553
554@pytest.mark.skipif(not _CAN_SYMLINK, reason=_SYMLINK_SKIP_REASON)
556 """``git_changed_files`` must filter symlinks that escape the repo.
557
558 Skipped on platforms without symlink capability — this is an
559 environmental constraint, not a defect in the tested behaviour.
560 """
561 outside = tmp_path / "outside_secret.txt"
562 outside.write_text("secret", encoding="utf-8")
563 link = git_repo / "link.txt"
564 os.symlink(outside, link)
565 result = git_changed_files(git_repo)
566 assert all("outside_secret" not in str(p) for p in result)
567
568
569# ============================================================
570# G-A7: legacy shim (linter wrapper)
571# ============================================================
572
573
575 from oct.linter import oct_lint
576 from oct.core import git as core_git
577
578 def fake_git_changed_files(*a, **kw):
579 raise GitNotFoundError("no git")
580
581 monkeypatch.setattr(core_git, "git_changed_files", fake_git_changed_files)
582 assert oct_lint._git_changed_files(tmp_path) == []
583
584
586 from oct.linter import oct_lint
587 from oct.core import git as core_git
588
589 def fake_git_changed_files(*a, **kw):
590 raise GitNotARepoError("nope")
591
592 monkeypatch.setattr(core_git, "git_changed_files", fake_git_changed_files)
593 assert oct_lint._git_changed_files(tmp_path) == []
594
595
597 from oct.linter import oct_lint
598 from oct.core import git as core_git
599
600 def fake_git_changed_files(*a, **kw):
601 raise GitCommandError(
602 ["git", "diff"], returncode=128, stdout="", stderr="boom",
603 )
604
605 monkeypatch.setattr(core_git, "git_changed_files", fake_git_changed_files)
606 assert oct_lint._git_changed_files(tmp_path) == []
607
608
610 from oct.linter import oct_lint
611 from oct.core import git as core_git
612
613 received = {}
614
615 def fake_git_changed_files(repo_root, extensions=None):
616 received["extensions"] = extensions
617 return []
618
619 monkeypatch.setattr(core_git, "git_changed_files", fake_git_changed_files)
620 oct_lint._git_changed_files(tmp_path)
621 assert received["extensions"] == {".py"}
__init__(self, returncode=0, stdout="", stderr="")
Definition test_git.py:256
test_get_repo_root_raises_not_a_repo(tmp_path)
Definition test_git.py:383
test_git_command_error_str_redacts_token_in_argv()
Definition test_git.py:223
test_git_run_redacts_stderr_on_error(tmp_path, monkeypatch)
Definition test_git.py:320
test_last_commit_message_returns_subject(git_repo)
Definition test_git.py:486
test_is_git_repo_true_inside_repo(git_repo)
Definition test_git.py:352
test_redact_handles_none_safely()
Definition test_git.py:160
test_git_changed_files_filters_out_path_escapes(git_repo, tmp_path)
Definition test_git.py:555
test_last_commit_sha_empty_on_fresh_repo(git_repo)
Definition test_git.py:468
test_git_changed_files_empty_on_clean_repo(git_repo)
Definition test_git.py:449
test_get_repo_root_returns_absolute_path(git_repo)
Definition test_git.py:379
test_git_run_raises_timeout(tmp_path, monkeypatch)
Definition test_git.py:294
test_is_git_repo_false_on_any_exception(tmp_path, monkeypatch)
Definition test_git.py:367
test_has_untracked_files_false_on_fresh_repo(git_repo)
Definition test_git.py:424
test_is_within_project_root_false_for_sibling(tmp_path)
Definition test_git.py:527
test_has_uncommitted_changes_false_on_clean_repo(git_repo)
Definition test_git.py:411
test_remote_urls_redacts_http_creds(git_repo)
Definition test_git.py:496
test_is_within_project_root_false_for_symlink_escape(tmp_path)
Definition test_git.py:539
test_legacy_shim_passes_py_extension_filter(tmp_path, monkeypatch)
Definition test_git.py:609
test_git_changed_files_returns_absolute_paths(git_repo)
Definition test_git.py:433
test_legacy_shim_returns_empty_on_command_error(tmp_path, monkeypatch)
Definition test_git.py:596
test_is_within_project_root_handles_nonexistent_paths(tmp_path)
Definition test_git.py:532
test_is_within_project_root_true_for_child(tmp_path)
Definition test_git.py:516
test_git_staged_files_only_returns_staged(git_repo)
Definition test_git.py:455
test_last_commit_message_empty_on_fresh_repo(git_repo)
Definition test_git.py:482
test_is_within_project_root_true_for_self(tmp_path)
Definition test_git.py:523
test_is_git_repo_false_outside_repo(tmp_path)
Definition test_git.py:356
test_legacy_shim_returns_empty_on_missing_git(tmp_path, monkeypatch)
Definition test_git.py:574
bool _platform_can_symlink()
Definition test_git.py:54
test_redact_argv_noop_for_plain_argv()
Definition test_git.py:198
test_git_run_happy_path(tmp_path, monkeypatch)
Definition test_git.py:267
test_remote_urls_empty_on_fresh_repo(git_repo)
Definition test_git.py:492
test_redact_argv_redacts_url_elements()
Definition test_git.py:190
test_is_git_repo_false_when_git_missing(tmp_path, monkeypatch)
Definition test_git.py:360
test_has_uncommitted_changes_true_after_edit(git_repo)
Definition test_git.py:417
test_timeout_message_redacts_token(tmp_path, monkeypatch)
Definition test_git.py:237
test_is_detached_head_true_after_checkout_sha(git_repo)
Definition test_git.py:400
test_redact_preserves_non_url_content()
Definition test_git.py:176
test_current_branch_returns_name(git_repo)
Definition test_git.py:388
test_git_run_rejects_non_list_argv(tmp_path)
Definition test_git.py:262
test_last_commit_sha_full_and_short(git_repo)
Definition test_git.py:472
test_git_run_redacts_stdout_on_success(tmp_path, monkeypatch)
Definition test_git.py:334
test_git_error_is_base_of_all_subclasses()
Definition test_git.py:113
test_legacy_shim_returns_empty_on_not_a_repo(tmp_path, monkeypatch)
Definition test_git.py:585
test_has_untracked_files_true_for_new_file(git_repo)
Definition test_git.py:428
test_git_run_raises_git_not_found(tmp_path, monkeypatch)
Definition test_git.py:286
test_git_command_error_preserves_context()
Definition test_git.py:120
test_git_changed_files_filters_by_extension(git_repo)
Definition test_git.py:441
test_git_run_raises_on_nonzero_exit(tmp_path, monkeypatch)
Definition test_git.py:302
test_git_run_nocheck_returns_process(tmp_path, monkeypatch)
Definition test_git.py:312
test_redact_handles_empty_string()
Definition test_git.py:156
test_git_run_dbg_does_not_leak_token(tmp_path, monkeypatch, caplog)
Definition test_git.py:203
test_is_detached_head_false_on_branch(git_repo)
Definition test_git.py:394