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.
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``
19- Confirm every query function behaves correctly against a real git
20 repo, raising the right error on missing repo / missing binary
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).
35 L4 — fine-grained assertions
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.
48from pathlib
import Path
49from unittest.mock
import patch
55 """Probe whether this process can create a real OS symlink.
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).
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)
72 except (OSError, NotImplementedError):
77_SYMLINK_SKIP_REASON: str = (
78 "Platform cannot create symlinks without elevation "
79 "(Windows non-admin / non-developer-mode, or FS refuses the syscall)."
82from oct.core
import git
as git_mod
83from oct.core.git
import (
97 has_uncommitted_changes,
105from oct.core.project_root
import is_within_project_root
114 assert issubclass(GitNotFoundError, GitError)
115 assert issubclass(GitNotARepoError, GitError)
116 assert issubclass(GitTimeoutError, GitError)
117 assert issubclass(GitCommandError, GitError)
121 err = GitCommandError(
122 [
"git",
"status"], returncode=128,
123 stdout=
"out", stderr=
"fatal: not a repo",
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)
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
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
152 url =
"git@github.com:user/repo.git"
153 assert _redact_git_url(url) == url
157 assert _redact_git_url(
"") ==
""
163 assert _redact_git_url(
"") ==
""
168 "origin\thttps://u:t@github.com/x/y.git (fetch)\n"
169 "origin\thttps://u:t@github.com/x/y.git (push)\n"
171 out = _redact_git_url(text)
172 assert "u:t" not in out
173 assert out.count(
"***@github.com") == 2
177 text =
"some random commit message mentioning @user and email@host"
179 assert _redact_git_url(text) == text
187_TOKEN_URL =
"https://oauth2:ghp_SUPERSECRET@github.com/foo/bar.git"
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]
199 argv = [
"git",
"status",
"--porcelain"]
200 assert _redact_argv(argv) == argv
204 """_dbg logs argv before the subprocess runs; it must be redacted."""
205 captured: dict[str, str] = {}
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"
211 def fake_run(*a, **kw):
214 monkeypatch.setattr(git_mod,
"_dbg", fake_dbg)
215 monkeypatch.setattr(subprocess,
"run", fake_run)
216 git_run([
"push", _TOKEN_URL], cwd=tmp_path)
218 combined =
"\n".join(captured.values())
219 assert "ghp_SUPERSECRET" not in combined
220 assert "oauth2" not in combined
224 err = GitCommandError(
225 [
"git",
"push", _TOKEN_URL],
228 stderr=
"fatal: auth failed",
231 assert "ghp_SUPERSECRET" not in rendered
232 assert "oauth2" not in rendered
234 assert "ghp_SUPERSECRET" not in " ".join(err.argv)
238 def fake_run(*a, **kw):
239 raise subprocess.TimeoutExpired(cmd=
"git", timeout=1)
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)
245 rendered = str(exc_info.value)
246 assert "ghp_SUPERSECRET" not in rendered
247 assert "oauth2" not in rendered
256 def __init__(self, returncode=0, stdout="", stderr=""):
263 with pytest.raises(GitError):
264 git_run(
"status", cwd=tmp_path)
270 def fake_run(argv, **kwargs):
272 calls[
"kwargs"] = kwargs
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
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)
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)
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
313 def fake_run(*a, **kw):
315 monkeypatch.setattr(subprocess,
"run", fake_run)
316 proc = git_run([
"rev-parse"], cwd=tmp_path, check=
False)
317 assert proc.returncode == 1
321 def fake_run(*a, **kw):
323 returncode=1, stdout=
"",
324 stderr=
"fatal: could not read https://bob:token@host/x.git\n",
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
335 def fake_run(*a, **kw):
338 stdout=
"origin\thttps://u:p@github.com/x.git (fetch)\n",
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
353 assert is_git_repo(git_repo)
is True
357 assert is_git_repo(tmp_path)
is False
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
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
380 assert get_repo_root(git_repo) == git_repo
384 with pytest.raises(GitNotARepoError):
385 get_repo_root(tmp_path)
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"
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
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)
405 [
"git",
"checkout",
"--quiet", sha],
406 cwd=git_repo, check=
True, capture_output=
True, text=
True,
408 assert is_detached_head(git_repo)
is True
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
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
425 assert has_untracked_files(git_repo)
is False
429 (git_repo /
"untracked.txt").write_text(
"x", encoding=
"utf-8")
430 assert has_untracked_files(git_repo)
is True
434 (git_repo /
"new.py").write_text(
"print()", encoding=
"utf-8")
435 result = git_changed_files(git_repo)
436 assert len(result) >= 1
438 assert p.is_absolute()
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)
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) == []
456 (git_repo /
"staged.py").write_text(
"print()", encoding=
"utf-8")
457 (git_repo /
"unstaged.py").write_text(
"print()", encoding=
"utf-8")
459 [
"git",
"add",
"staged.py"],
460 cwd=git_repo, check=
True, capture_output=
True, text=
True,
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
469 assert last_commit_sha(git_repo) ==
""
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)
483 assert last_commit_message(git_repo) ==
""
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)
493 assert remote_urls(git_repo) == {}
499 "git",
"remote",
"add",
"origin",
500 "https://user:token@example.com/x/y.git",
502 cwd=git_repo, check=
True, capture_output=
True, text=
True,
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"]
517 child = tmp_path /
"sub" /
"file.txt"
519 child.write_text(
"x", encoding=
"utf-8")
520 assert is_within_project_root(child, tmp_path)
is True
524 assert is_within_project_root(tmp_path, tmp_path)
is True
528 sibling = tmp_path.parent /
"outside-of-tmp"
529 assert is_within_project_root(sibling, tmp_path)
is False
534 ghost = tmp_path /
"does" /
"not" /
"exist.py"
535 assert is_within_project_root(ghost, tmp_path)
is True
538@pytest.mark.skipif(not _CAN_SYMLINK, reason=_SYMLINK_SKIP_REASON)
540 """Symlinks pointing outside the project root must not be accepted.
542 Skipped on platforms without symlink capability — this is an
543 environmental constraint, not a defect in the tested behaviour.
545 repo = tmp_path /
"repo"
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
554@pytest.mark.skipif(not _CAN_SYMLINK, reason=_SYMLINK_SKIP_REASON)
556 """``git_changed_files`` must filter symlinks that escape the repo.
558 Skipped on platforms without symlink capability — this is an
559 environmental constraint, not a defect in the tested behaviour.
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)
576 from oct.core
import git
as core_git
578 def fake_git_changed_files(*a, **kw):
579 raise GitNotFoundError(
"no git")
581 monkeypatch.setattr(core_git,
"git_changed_files", fake_git_changed_files)
582 assert oct_lint._git_changed_files(tmp_path) == []
587 from oct.core
import git
as core_git
589 def fake_git_changed_files(*a, **kw):
590 raise GitNotARepoError(
"nope")
592 monkeypatch.setattr(core_git,
"git_changed_files", fake_git_changed_files)
593 assert oct_lint._git_changed_files(tmp_path) == []
598 from oct.core
import git
as core_git
600 def fake_git_changed_files(*a, **kw):
601 raise GitCommandError(
602 [
"git",
"diff"], returncode=128, stdout=
"", stderr=
"boom",
605 monkeypatch.setattr(core_git,
"git_changed_files", fake_git_changed_files)
606 assert oct_lint._git_changed_files(tmp_path) == []
611 from oct.core
import git
as core_git
615 def fake_git_changed_files(repo_root, extensions=None):
616 received[
"extensions"] = extensions
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="")
test_get_repo_root_raises_not_a_repo(tmp_path)
test_git_command_error_str_redacts_token_in_argv()
test_git_run_redacts_stderr_on_error(tmp_path, monkeypatch)
test_last_commit_message_returns_subject(git_repo)
test_is_git_repo_true_inside_repo(git_repo)
test_redact_handles_none_safely()
test_git_changed_files_filters_out_path_escapes(git_repo, tmp_path)
test_last_commit_sha_empty_on_fresh_repo(git_repo)
test_git_changed_files_empty_on_clean_repo(git_repo)
test_get_repo_root_returns_absolute_path(git_repo)
test_redact_strips_http_creds()
test_git_run_raises_timeout(tmp_path, monkeypatch)
test_is_git_repo_false_on_any_exception(tmp_path, monkeypatch)
test_has_untracked_files_false_on_fresh_repo(git_repo)
test_is_within_project_root_false_for_sibling(tmp_path)
test_redact_preserves_ssh_urls()
test_has_uncommitted_changes_false_on_clean_repo(git_repo)
test_remote_urls_redacts_http_creds(git_repo)
test_is_within_project_root_false_for_symlink_escape(tmp_path)
test_legacy_shim_passes_py_extension_filter(tmp_path, monkeypatch)
test_git_changed_files_returns_absolute_paths(git_repo)
test_legacy_shim_returns_empty_on_command_error(tmp_path, monkeypatch)
test_is_within_project_root_handles_nonexistent_paths(tmp_path)
test_is_within_project_root_true_for_child(tmp_path)
test_git_staged_files_only_returns_staged(git_repo)
test_last_commit_message_empty_on_fresh_repo(git_repo)
test_is_within_project_root_true_for_self(tmp_path)
test_redact_strips_https_creds()
test_is_git_repo_false_outside_repo(tmp_path)
test_legacy_shim_returns_empty_on_missing_git(tmp_path, monkeypatch)
bool _platform_can_symlink()
test_redact_multiline_output()
test_redact_argv_noop_for_plain_argv()
test_git_run_happy_path(tmp_path, monkeypatch)
test_remote_urls_empty_on_fresh_repo(git_repo)
test_redact_argv_redacts_url_elements()
test_is_git_repo_false_when_git_missing(tmp_path, monkeypatch)
test_has_uncommitted_changes_true_after_edit(git_repo)
test_timeout_message_redacts_token(tmp_path, monkeypatch)
test_is_detached_head_true_after_checkout_sha(git_repo)
test_redact_preserves_non_url_content()
test_current_branch_returns_name(git_repo)
test_git_run_rejects_non_list_argv(tmp_path)
test_last_commit_sha_full_and_short(git_repo)
test_git_run_redacts_stdout_on_success(tmp_path, monkeypatch)
test_git_error_is_base_of_all_subclasses()
test_legacy_shim_returns_empty_on_not_a_repo(tmp_path, monkeypatch)
test_has_untracked_files_true_for_new_file(git_repo)
test_git_run_raises_git_not_found(tmp_path, monkeypatch)
test_git_command_error_preserves_context()
test_git_changed_files_filters_by_extension(git_repo)
test_git_run_raises_on_nonzero_exit(tmp_path, monkeypatch)
test_git_run_nocheck_returns_process(tmp_path, monkeypatch)
test_redact_handles_empty_string()
test_git_run_dbg_does_not_leak_token(tmp_path, monkeypatch, caplog)
test_is_detached_head_false_on_branch(git_repo)