Files
brain-of-reese/tests/unit/test_git_sync.py
T
ducoterra 644f7de696
Build and Push Containers / build-and-push-app (push) Successful in 1m42s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 186_git_log_streaming_dates
## Final report — phase 186 `git_log_streaming_dates`

**State found:** all three task files were already implemented and in `.agents/phases/complete/186_git_log_streaming_dates/`, so this run was the final verification + documentation pass.

### Verification (all green)
| Gate | Result |
|---|---|
| `pytest --cov=app --cov-report=term-missing` | **4531 passed, exit 0** · coverage `TOTAL 6872 stmts → 99%` (gate >90%) |
| `tests/e2e/test_git_log_stream_dates.py -v --no-cov` (isolation) | **5 passed** |
| `ruff check .` | All checks passed |
| `pyright` | 0 errors |

Implementation shape confirmed against the phase contracts: `_parse_commit_dates(str | Iterable[str])` (existing string pins untouched — only 3 non-test lines removed from `test_git_sync.py`), `stream_git_lines` (`Popen` + daemon stderr drain + per-line stdout + terminate-on-abandon, `run_git`-shaped `GitSyncError`), `file_commit_dates` on the stream with byte-identical dates and the fail-soft funnel unchanged, `async_sync` propagating the `to_thread` errors to the existing `except`. A11 holds: two `subprocess.` sites, both in `scripts/git_sync.py`, both off-loop.

### Documentation-consistency pass
Verified (clean): all ~100 `BOR_*` README defaults against a bare `Settings()` (the local `.env` produced false positives — none real), `.env.example` completeness, every module/route/page/test path referenced, `compose.yaml`/`Containerfile`/`entrypoint.sh` claims, E2E port conventions (8901 / 8123), palette count (17), chip cap (90), token format, payload limits, retrieval battery + audit commands.

Fixed (both files, prose only):
- **Page-naming drift** — README/AGENTS called the sync/catalog page "the Sources page" in 5 places, contradicting the README's own convention (and `frontend/index.html`): the **Sync sources** button and the document catalogue are the **RAG** page (`/sources.html`); "Sources" is `/git-sources.html`. Now `RAG page (/sources.html)` in README:186, 376, 418, 682, 713, 745, 1006, 1057 and AGENTS:19, 38, 183.
- **Phase-186 AST-pin wording** — "fails if a third spawn appears anywhere in `app/` or `scripts/`" overstated it (the pin *enumerates* the spawns, including the non-git Valkey probe in `app/core/caching.py`); rewritten to describe the enumeration.
- Moved `00_phase.md` into `complete/186_git_log_streaming_dates/` (AGENTS rule 2 moves the whole directory); `todo/` now starts at 187.

⚠️ **One mistake to disclose:** while tidying line wraps I ran `git checkout README.md`, which discarded task 03's uncommitted phase-186 README bullet. I restored it verbatim from the full `git diff` captured earlier in this session (plus the two corrections above) and re-verified the section renders as intended — no other README content was uncommitted, and AGENTS.md was untouched by the checkout. A blanket line-length rewrap also reflowed 4 unrelated lines; all four were restored to their exact original text (`git diff` now shows only intended changes).

Nothing was committed or staged — the working tree is left for the harness: modified `scripts/git_sync.py`, `app/api/sync.py`, `app/rag/git_sources.py`, `tests/unit/test_git_sync.py`, `tests/integration/test_git_file_dates.py`, `README.md`, `AGENTS.md`; new `tests/e2e/test_git_log_stream_dates.py`, `.agents/phases/complete/186_git_log_streaming_dates/`, `.agents/reports/186_git_log_streaming_dates/`.
2026-10-16 07:50:16 -04:00

1244 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12;
phase 135 adds the branch pin; phase 136 replaces the existing-
checkout update with the UNIFIED ff-or-reset sequence — the argv
matrix).
``clone_or_pull`` dispatches to ``git clone`` (full history, fresh dest)
or the UNIFIED existing-checkout update (phase 136 — one sequence for
both branch shapes) with ``subprocess`` fully mocked — no real git CLI
is needed, so the tests run anywhere: ref resolution
(``rev-parse --abbrev-ref HEAD`` — the phase-135 HEAD guard for a
pinned ``branch``, the checkout's own branch + the detached-HEAD
fail-loud for ``branch=None``) → the shallow probe → optional
``git fetch --unshallow`` (the one-time D11 self-heal) →
``git fetch origin <ref>`` (a fetch failure STILL raises) →
``git merge --ff-only FETCH_HEAD`` → on a non-ff merge: the
``rev-parse HEAD`` / ``rev-parse FETCH_HEAD`` capture +
``git reset --hard FETCH_HEAD`` + one WARNING line, then RETURN (a
force push never fails a sync — phase 136's fix). The fresh-clone
argv (with/without ``--branch``) is the pre-phase one — the argv pins
below are the regression gate for every existing deployment.
Phase 186 adds the commit-date PARSER pins and the STREAMING git site.
``_parse_commit_dates`` accepts ``str | Iterable[str]`` so the walk can
stream off the subprocess pipe instead of buffering the whole ``git
log`` in RAM, and ``stream_git_lines`` is the A11 sibling of
``run_git`` — its success / error-shape / huge-stderr cases run the
REAL git CLI through the house ``_git`` helper (skipped, not failed,
without it, like ``tests/integration/test_git_file_dates.py``), while
the abandon-the-stream case uses a recorded ``Popen`` stand-in so the
terminate-and-reap arm is deterministic instead of a race. The
``test_only_two_git_spawn_sites`` pin is the AST version of the old A11
grep: exactly one ``subprocess`` spawn per shape, both inside
``git_sync.py``. The
memory pin's synthetic corpus is ~300,000 lines because that is the
size of the real thing on the incident box (2026-10-15, measured
read-only inside the app container): ``litellm`` 54,502 commits /
346,829 lines / 15,077,351 bytes; ``llama.cpp`` 11,659 commits /
69,922 lines / 1,867,046 bytes; ``gufo`` 9,155 lines;
``vllm-radiance`` 4,855 lines — i.e. the walk scales with a
repository's ENTIRE history, not its working tree, which is why the
corpus sits at 306,000 lines (3,000 commits × 100 path lines + the
header/blank pair per commit) rather than at a toy size.
"""
from __future__ import annotations
import ast
import io
import logging
import os
import shutil
import subprocess
import time
import tracemalloc
from collections.abc import Callable, Generator, Iterable, Iterator
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
import pytest
import scripts.git_sync as git_sync
from app.core import docs_push
from scripts.git_sync import (
GitSyncError,
_parse_commit_dates, # pyright: ignore[reportPrivateUsage]
clone_or_pull,
stream_git_lines,
)
class _FakeProc:
def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None:
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def _fake_run(
monkeypatch: pytest.MonkeyPatch,
returncode: int = 0,
stdout: str = "",
stderr: str = "",
missing_git: bool = False,
responses: dict[tuple[str, ...], tuple[int, str, str]] | None = None,
) -> list[dict[str, object]]:
"""Monkeypatch scripts.git_sync.subprocess.run; record each call.
``responses`` dispatches by the full argv (clone / shallow-probe /
``fetch --unshallow`` / pull) — a per-command
``(returncode, stdout, stderr)``; any argv not in the map gets the
default ``returncode``/``stdout``/``stderr``.
"""
calls: list[dict[str, object]] = []
def fake_run(argv: list[str], cwd: Path | None = None, **kwargs: object) -> _FakeProc:
calls.append({"argv": list(argv), "cwd": cwd, **kwargs})
if missing_git:
raise FileNotFoundError("git")
if responses is not None:
rc, out, err = responses.get(tuple(argv), (returncode, stdout, stderr))
return _FakeProc(rc, out, err)
return _FakeProc(returncode, stdout, stderr)
monkeypatch.setattr(subprocess, "run", fake_run)
return calls
def test_clone_or_pull_clones_when_dest_has_no_git_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""First run: dest absent → ``git clone`` (full history — NO
``--depth``, phase 107 D11) from the parent dir."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
calls = _fake_run(monkeypatch)
result = clone_or_pull(url, dest)
assert result == dest
assert len(calls) == 1
call = calls[0]
assert call["argv"] == ["git", "clone", url, str(dest)]
assert call["cwd"] == dest.parent
assert call["capture_output"] is True
assert call["text"] is True
def test_clone_or_pull_existing_checkout_ff_updates_the_tracked_ref(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 136 steady state (``branch=None``): dest/.git present →
the UNIFIED sequence — the checkout's OWN branch is resolved
(``rev-parse --abbrev-ref HEAD`` → ``main``), the shallow probe
says NOT shallow (``false``), and ``fetch origin main`` +
``merge --ff-only FETCH_HEAD`` ff-update the ref (the legacy
``git pull --ff-only`` is deliberately gone — every row gets the
ff-or-reset update). A SUCCESSFUL merge leaves NO ``reset`` argv
and NO ``rev-parse HEAD`` / ``rev-parse FETCH_HEAD`` capture pair
(the capture is only on failure)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "main"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""),
},
)
result = clone_or_pull("https://github.com/user/homelab.git", dest)
assert result == dest
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "main"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
for call in calls:
assert call["cwd"] == dest
# ff succeeded — the exact 4-step sequence above already excludes
# the reset tail (and the rev-parse HEAD/FETCH_HEAD capture pair,
# which is only on failure).
assert ["git", "reset", "--hard", "FETCH_HEAD"] not in [c["argv"] for c in calls]
def test_clone_or_pull_unshallows_existing_shallow_checkout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Existing shallow checkout (probe ``true`` — the one-time D11
self-heal of deployed checkouts) → ``git fetch --unshallow``
BETWEEN the ref-resolution probe and the unified update's
``fetch origin <ref>`` + ``merge --ff-only FETCH_HEAD`` (order
pinned — the self-heal still fires in front of the fetch), all in
dest."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
("git", "fetch", "--unshallow"): (0, "Fetching full history\n", ""),
("git", "fetch", "origin", "main"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""),
},
)
clone_or_pull("https://github.com/user/homelab.git", dest)
assert len(calls) == 5
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "--unshallow"],
["git", "fetch", "origin", "main"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
for call in calls:
assert call["cwd"] == dest
def test_clone_or_pull_unshallow_failure_propagates(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""D12 fail-loud: a failed ``git fetch --unshallow`` raises
:class:`GitSyncError` (with git's stderr) and the update NEVER
runs — continuing a failed self-heal would silently re-serve the
uniform tip date (the bug D11 fixes)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
("git", "fetch", "--unshallow"): (128, "", "fatal: could not fetch\n"),
},
)
with pytest.raises(
GitSyncError, match=r"git fetch --unshallow failed \(exit 128\): fatal: could not fetch"
):
clone_or_pull("https://github.com/user/homelab.git", dest)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "--unshallow"],
] # the fetch/merge/reset never ran
def test_clone_or_pull_creates_missing_parent_before_clone(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The (nested) parent of the destination is mkdir'd before git runs."""
dest = tmp_path / "nested" / "deeper" / "homelab"
assert not dest.parent.exists()
calls = _fake_run(monkeypatch)
clone_or_pull("https://github.com/user/homelab.git", dest)
assert dest.parent.is_dir()
assert calls[0]["argv"] == [
"git",
"clone",
"https://github.com/user/homelab.git",
str(dest),
]
def test_existing_dir_without_git_dir_is_treated_as_fresh(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A leftover non-repo dir at the dest still takes the clone path —
git fails loudly (non-zero exit) if the dir is not empty."""
dest = tmp_path / "homelab"
dest.mkdir()
calls = _fake_run(monkeypatch)
clone_or_pull("https://github.com/user/homelab.git", dest)
assert calls[0]["argv"] == [
"git",
"clone",
"https://github.com/user/homelab.git",
str(dest),
]
def test_failing_git_raises_error_carrying_stderr(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-zero exit → GitSyncError naming the subcommand, exit code, stderr."""
dest = tmp_path / "homelab"
_fake_run(monkeypatch, returncode=128, stderr="fatal: repository not found\n")
with pytest.raises(
GitSyncError,
match=r"git clone .* failed \(exit 128\): fatal: repository not found",
):
clone_or_pull("https://example.com/nope.git", dest)
# Same for the unified update path — but the reset fallback is
# scoped to the MERGE step: a genuinely broken FETCH (dead
# network, the branch deleted upstream, git missing) STILL fails
# loud, with no merge and no reset argv.
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
returncode=1,
stderr="fatal: couldn't find remote ref main",
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "main"): (
128, "", "fatal: couldn't find remote ref main\n"
),
},
)
with pytest.raises(
GitSyncError,
match=r"git fetch origin main failed \(exit 128\): fatal: couldn't find remote ref main",
):
clone_or_pull("https://example.com/homelab.git", dest)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "main"],
] # the merge (and therefore any reset) never ran
def test_missing_git_raises_named_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No git binary on PATH → GitSyncError telling the user to install it."""
_fake_run(monkeypatch, missing_git=True)
with pytest.raises(GitSyncError, match="git was not found on PATH"):
clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab")
def test_run_git_captures_and_returns_stdout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""run_git returns the captured stdout on success (git output is not lost)."""
calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main")
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
assert len(calls) == 1
def test_run_git_default_env_is_none_and_reaches_subprocess(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 173 (C5 byte-identity pin): the default ``env=None`` is
passed through verbatim to ``subprocess.run`` — the pre-phase call
shape, so every sync-path invocation is unchanged."""
calls = _fake_run(monkeypatch, stdout="ok")
assert git_sync.run_git(["git", "version"], cwd=tmp_path) == "ok"
assert len(calls) == 1
assert "env" in calls[0]
assert calls[0]["env"] is None
def test_run_git_explicit_env_reaches_subprocess_verbatim(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 173 (C2): an explicit env dict reaches ``subprocess.run``
verbatim — ``run_git`` neither merges over nor mutates the parent
environment (the env-replacement trap is the caller's contract;
the docs-push sequence does its own ``os.environ`` merge)."""
calls = _fake_run(monkeypatch, stdout="ok")
env = {"A": "b"}
assert git_sync.run_git(["git", "version"], cwd=tmp_path, env=env) == "ok"
assert len(calls) == 1
assert calls[0]["env"] is env # the SAME object — passed through verbatim
assert calls[0]["env"] == {"A": "b"}
# --- phase 135: the branch pin (the argv matrix) ----------------------------
def test_branch_none_argv_pins_the_unified_phase_136_sequence(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ``branch=None`` argv pins (explicit ``None`` is the call
sites' form for every row without a branch pin).
FRESH: one ``git clone`` without ``--branch`` — the pre-phase argv,
UNCHANGED (the regression gate for every existing deployment).
EXISTING: the legacy ``git pull --ff-only`` is deliberately GONE —
that is the fix (phase 136): every row, branched or not, gets the
ff-or-reset update. The pinned sequence: ref resolution (``rev-
parse --abbrev-ref HEAD``) → the shallow probe → (``fetch
--unshallow`` while shallow) → ``fetch origin <branch>`` →
``merge --ff-only FETCH_HEAD``. A future refactor that
re-introduces ``git pull`` (or re-splits the two branch shapes)
breaks this pin."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
# fresh (dest absent) — the pre-phase argv, byte-identical
calls = _fake_run(monkeypatch)
clone_or_pull(url, dest, None)
assert [call["argv"] for call in calls] == [["git", "clone", url, str(dest)]]
# existing, not shallow — the unified ff-or-reset sequence
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
},
)
clone_or_pull(url, dest, None)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "main"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
# existing, shallow (the one-time D11 self-heal between probe
# and fetch — still in front of the fetch)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
("git", "fetch", "--unshallow"): (0, "", ""),
},
)
clone_or_pull(url, dest, None)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "--unshallow"],
["git", "fetch", "origin", "main"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
def test_fresh_clone_with_branch_uses_clone_branch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135: fresh dest + pinned branch → ``git clone --branch <b>
url dest`` (the branch is checked out from the first commit — no
post-clone checkout; still full history, no ``--depth``)."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
calls = _fake_run(monkeypatch)
result = clone_or_pull(url, dest, "release")
assert result == dest
assert [call["argv"] for call in calls] == [
["git", "clone", "--branch", "release", url, str(dest)]
]
assert calls[0]["cwd"] == dest.parent
def test_existing_checkout_matching_branch_fetches_and_ff_merges_the_ref(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135: existing checkout ALREADY on the pinned branch → the
full sequence in order: the HEAD guard (``rev-parse --abbrev-ref
HEAD``) → the shallow probe → the single-fetch ``fetch origin <b>``
→ ``merge --ff-only FETCH_HEAD`` (not ``git pull`` — the default
path's argv is reserved for ``branch=None``)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "release"): (0, " * branch release -> FETCH_HEAD", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""),
},
)
result = clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert result == dest
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "release"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
for call in calls:
assert call["cwd"] == dest
def test_existing_shallow_checkout_matching_branch_unshallows_before_fetch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 + phase 107 D11: the branch path runs the SAME one-time
self-heal — a shallow matching checkout is unshallowed BETWEEN the
guard/probe and the branch fetch (order pinned)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
("git", "fetch", "--unshallow"): (0, "", ""),
("git", "fetch", "origin", "release"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""),
},
)
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "--unshallow"],
["git", "fetch", "origin", "release"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
def test_existing_checkout_branch_mismatch_fails_loud_before_any_fetch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 (ASSUMPTION B5, D12): a checkout on a DIFFERENT branch
raises the remove-and-re-add ``GitSyncError`` — naming the checkout
and BOTH branch names — and the guard is FIRST: no shallow probe,
no fetch, no merge argv was invoked (no silent branch switch)."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")},
)
with pytest.raises(GitSyncError) as excinfo:
clone_or_pull(url, dest, "release")
msg = str(excinfo.value)
assert f"checkout {dest}" in msg # the checkout is named
assert "'main'" in msg # the checkout's branch
assert "'release'" in msg # the source's pinned branch
assert "remove and re-add the source to switch branches" in msg
# credential safety: the message carries NO URL (the only place a
# token could ride) — even for a plain URL.
assert url not in msg
assert "github" not in msg
# the guard is first: exactly ONE argv (the guard probe) ran.
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"]
]
def test_branch_mismatch_message_never_contains_url_even_with_token(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 credential-safety: even when the CLONE URL carries the
injected token (the phase-121 ``clone_url_for`` form), the guard
message is URL-free — the checkout path and the two branch names
only."""
url = "https://x-access-token:ghp_supersecret@git.example.com/user/repo.git"
dest = tmp_path / "repo"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")},
)
with pytest.raises(GitSyncError) as excinfo:
clone_or_pull(url, dest, "release")
msg = str(excinfo.value)
assert url not in msg
assert "ghp_supersecret" not in msg
assert "git.example.com" not in msg
assert f"checkout {dest} is on branch 'main' but the source " \
"specifies branch 'release'" in msg
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"]
]
def test_existing_checkout_branch_fetch_failure_propagates_before_merge(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 D12 fail-loud: a failed ``fetch origin <b>`` raises
(with git's stderr) and the merge NEVER runs."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "release"): (128, "", "fatal: couldn't find remote ref\n"),
},
)
with pytest.raises(
GitSyncError,
match=r"git fetch origin release failed \(exit 128\): fatal: couldn't find remote ref",
):
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "release"],
] # the merge never ran
def test_existing_checkout_branch_non_ff_merge_resets_to_the_ref_head(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Phase 136: a non-ff ``merge --ff-only FETCH_HEAD`` (diverged /
force-pushed origin) NO LONGER fails the sync — the sequence
captures the old/new SHAs, resets the checkout hard to
``FETCH_HEAD`` (the branch's ref — the owner's verbatim "reset
origin/<branch>"), logs ONE WARNING line (PLAN §9), and RETURNS
the dest (no raise — the run continues). The WARNING is the only
observable side effect besides the checkout state: no user-facing
error, no status change."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
old, new = "a" * 40, "b" * 40
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "release"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (
1, "", "fatal: Not possible to fast-forward, aborting.\n"
),
("git", "rev-parse", "HEAD"): (0, old + "\n", ""),
("git", "rev-parse", "FETCH_HEAD"): (0, new + "\n", ""),
("git", "reset", "--hard", "FETCH_HEAD"): (0, "", ""),
},
)
with caplog.at_level(logging.WARNING, logger="scripts.git_sync"):
result = clone_or_pull(url, dest, "release") # NO raise — the run continues
assert result == dest
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "release"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
["git", "rev-parse", "HEAD"],
["git", "rev-parse", "FETCH_HEAD"],
["git", "reset", "--hard", "FETCH_HEAD"],
]
for call in calls:
assert call["cwd"] == dest
# ONE WARNING line (PLAN §9): the checkout PATH (never the URL —
# credential-safety), both short SHAs, the tracked ref, and the
# merge's stderr (diagnosability, log-only — never user-facing).
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
msg = warnings[0].getMessage()
assert msg == (
f"git sync: divergent remote head on {dest} (force push) — reset "
f"{old[:7]} → {new[:7]} to origin/release "
"(git merge --ff-only FETCH_HEAD failed (exit 1): "
"fatal: Not possible to fast-forward, aborting.)"
)
assert url not in msg # credential-safety: never the URL
assert "github" not in msg
def test_null_branch_non_ff_merge_resets_and_returns(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 136 (``branch=None`` — the legacy ``git pull --ff-only``
path, the owner's reported break): a non-ff merge of the
checkout's OWN branch takes the SAME reset tail —
``rev-parse HEAD`` → ``rev-parse FETCH_HEAD`` →
``reset --hard FETCH_HEAD`` — and RETURNS the dest (no raise;
pre-phase, this exact shape was the
``fatal: Not possible to fast-forward`` that failed the whole
sync). The reset targets the checkout's branch ref (``fetch
origin main``)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
old, new = "c" * 40, "d" * 40
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "main"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (
1, "", "fatal: Not possible to fast-forward, aborting.\n"
),
("git", "rev-parse", "HEAD"): (0, old + "\n", ""),
("git", "rev-parse", "FETCH_HEAD"): (0, new + "\n", ""),
("git", "reset", "--hard", "FETCH_HEAD"): (0, "", ""),
},
)
result = clone_or_pull("https://github.com/user/homelab.git", dest, None)
assert result == dest # the function RETURNS the dest (no raise)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "main"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
["git", "rev-parse", "HEAD"],
["git", "rev-parse", "FETCH_HEAD"],
["git", "reset", "--hard", "FETCH_HEAD"],
]
def test_null_branch_detached_head_fails_loud(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 136 D12 fail-loud: ``branch=None`` on a DETACHED checkout
(the ref resolution returns ``"HEAD"``) raises the remove-and-
re-add ``GitSyncError`` naming the checkout — the app never
CREATES a detached checkout (a fresh clone always lands on a
branch), so this is a genuinely broken checkout, not a force-push
case: no shallow probe, no fetch, no merge, NO reset argv."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "HEAD\n", "")},
)
with pytest.raises(GitSyncError) as excinfo:
clone_or_pull("https://github.com/user/homelab.git", dest, None)
msg = str(excinfo.value)
assert f"checkout {dest} is in a detached-HEAD state" in msg
assert "remove and re-add the source to rebuild it" in msg
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"]
] # the guard is first: exactly ONE argv ran (no fetch, no reset)
# --- phase 186: the parser takes a stream (semantics untouched) ------------
#: One commit header of the synthetic corpus, in the exact byte shape
#: ``git log --name-only --format=@@%cI`` emits (``%cI`` is ISO-strict).
_CORPUS_HEADER = "@@2026-01-01T00:00:00+00:00"
#: The synthetic corpus's size — see the module docstring for the
#: measured real-world line counts this is modelled on.
_CORPUS_COMMITS = 3_000
_CORPUS_PATHS_PER_COMMIT = 100
def _corpus_path(commit: int, slot: int) -> str:
"""One path line of the synthetic corpus.
The pool cycles (``(commit * 100 + slot) % 10_000``), so the walk
touches 10,000 distinct paths across 300,000 path lines — the same
shape as a real repo, where a handful of hot files reappear in
commit after commit while history's total path count stays a
fraction of the line count.
"""
return f"path_{(commit * _CORPUS_PATHS_PER_COMMIT + slot) % 10_000:05d}/file_{slot:03d}.md"
def _corpus_stream() -> Iterator[str]:
"""The corpus as a LAZY stream: nothing is materialised, each line
is built when pulled (the ``stream_git_lines`` shape of task 02)."""
for commit in range(_CORPUS_COMMITS):
yield _CORPUS_HEADER
yield ""
for slot in range(_CORPUS_PATHS_PER_COMMIT):
yield _corpus_path(commit, slot)
def _corpus_string() -> str:
"""The SAME corpus as one big ``str`` (what ``run_git`` returns
today) — built OUTSIDE any measured region."""
return "\n".join(_corpus_stream()) + "\n"
class _OneShot:
"""An iterable that refuses to be iterated twice.
``__iter__`` raises on the SECOND call, so a parser that
materialises its input and rescans it (or loops over ``lines``
twice) fails loudly instead of silently passing.
"""
def __init__(self, lines: Iterable[str]) -> None:
self._lines = lines
self.iter_calls = 0
def __iter__(self) -> Iterator[str]:
self.iter_calls += 1
if self.iter_calls > 1:
raise AssertionError("re-iterated")
return iter(self._lines)
class _CountingStream:
"""A stream that records how far the consumer pulled."""
def __init__(self, lines: list[str]) -> None:
self._lines = lines
self.pulled = 0
def __iter__(self) -> Iterator[str]:
for pulled, line in enumerate(self._lines, start=1):
self.pulled = pulled
yield line
def _peak_bytes[T](fn: Callable[[], T]) -> tuple[float, T]:
"""Run ``fn`` and return its tracemalloc PEAK in KiB (+ result).
``reset_peak`` runs after ``start`` so corpus construction (and any
earlier test's residue) is excluded — only what ``fn`` itself
allocates counts.
"""
import gc
gc.collect()
tracemalloc.start()
try:
tracemalloc.reset_peak()
result = fn()
peak = tracemalloc.get_traced_memory()[1]
finally:
tracemalloc.stop()
return peak / 1024, result
def test_parser_accepts_a_line_iterable() -> None:
"""Phase 186 D2: the SAME corpus fed as ``iter([...])`` yields the
identical dict as the string form — every parsing rule (the
``@@%cI`` header, the blank-line skip, the whitespace path split,
the ``\\``→``/`` normalisation, the leading-``/`` strip,
first-sighting-wins) is untouched."""
lines = [
"@@2024-06-15T10:00:00+00:00",
"",
"b.md",
"docs\\deep.md /rooted.md",
"@@2020-01-02T03:04:06+00:00",
"",
"a.md",
"b.md",
]
output = "\n".join(lines) + "\n"
expected = {
"b.md": datetime.fromisoformat("2024-06-15T10:00:00+00:00"),
"docs/deep.md": datetime.fromisoformat("2024-06-15T10:00:00+00:00"),
"rooted.md": datetime.fromisoformat("2024-06-15T10:00:00+00:00"),
"a.md": datetime.fromisoformat("2020-01-02T03:04:06+00:00"),
}
assert _parse_commit_dates(iter(lines)) == expected
assert _parse_commit_dates(output) == expected # the string form, unchanged
assert _parse_commit_dates(_OneShot(lines)) == expected
def test_parser_rejects_the_malformed_streams_too() -> None:
"""Both ``ValueError`` arms fire through the iterable path exactly as
through the string path (the caller's fail-soft is unchanged)."""
with pytest.raises(ValueError, match="before any commit header"):
_parse_commit_dates(iter(["stray.md", "@@2024-06-15T10:00:00+00:00"]))
with pytest.raises(ValueError):
_parse_commit_dates(iter(["@@yesterday", "b.md"]))
def test_parser_consumes_exactly_once() -> None:
"""Phase 186 D4: the parser walks its input in ONE pass.
The one-shot wrapper raises ``AssertionError("re-iterated")`` on a
second ``iter()``; the parse succeeds and the wrapper reports exactly
one ``__iter__`` call, so no materialise-then-rescan (and no
accidental ``list(lines)`` re-entering the loop) exists. The second
half is the stronger laziness pin: a corpus whose offending line
sits at index 3 raises there, having pulled EXACTLY 4 lines — a
parser that pre-materialised its input would have pulled the whole
tail first.
"""
corpus = [
"@@2024-06-15T10:00:00+00:00",
"",
"b.md",
"@@2020-01-02T03:04:06+00:00",
"a.md",
]
one_shot = _OneShot(corpus)
assert _parse_commit_dates(one_shot) == {
"b.md": datetime.fromisoformat("2024-06-15T10:00:00+00:00"),
"a.md": datetime.fromisoformat("2020-01-02T03:04:06+00:00"),
}
assert one_shot.iter_calls == 1
counting = _CountingStream(
[
"@@2024-06-15T10:00:00+00:00",
"",
"b.md",
"@@not-a-date",
"b.md",
*(["a.md"] * 1_000), # a long tail the parser must never reach
]
)
with pytest.raises(ValueError):
_parse_commit_dates(counting)
assert counting.pulled == 4 # stopped AT the bad line, no full read
def test_streamed_parse_alloculates_less_than_buffered() -> None:
"""Phase 186 D4: streaming the same corpus allocates materially less
than the buffered path, measured comparatively on THIS machine.
Both arms parse the identical 306,000-line corpus (3,000 commits ×
100 paths — see the module docstring for the measured real repos it
is modelled on). (a) is the buffered path exactly as ``run_git``
serves it today: one big ``str`` that the parser splits
(``splitlines()`` materialises a 306,001-entry list of fresh ``str``
objects). (b) is a generator building each line lazily. The
assertion is ``streamed < buffered * 0.6`` — a wide but not
tautological margin (``<=`` would prove nothing); the numbers below
are measured on this box (Linux 7.2.9-200.fc44.x86_64, CPython
3.13.13, glibc 2.43): buffered **21,504 KiB** peak (the 306,001-entry
``splitlines()`` list over a ~7 MB corpus) vs streamed **823 KiB**
— a ratio of **0.038**, i.e. the streamed arm allocates ~26× less.
The 823 KiB is the retained 10,000-entry result dict, which BOTH
arms pay, so the streaming arm is effectively allocation-free.
"""
corpus = _corpus_string()
buffered_kb, buffered = _peak_bytes(lambda: _parse_commit_dates(corpus))
streamed_kb, streamed = _peak_bytes(lambda: _parse_commit_dates(_corpus_stream()))
assert buffered_kb > 1_024, f"buffered peak {buffered_kb:,.0f} KiB is not a real corpus"
assert streamed_kb < buffered_kb * 0.6, (
f"streamed peak {streamed_kb:,.0f} KiB is not materially below the "
f"buffered {buffered_kb:,.0f} KiB (ratio "
f"{streamed_kb / buffered_kb:.3f}, budget 0.600)"
)
# memory is not bought by losing data: identical results, ~300k lines
assert streamed == buffered
assert len(buffered) == 10_000
# --- phase 186: the streaming git site (stream_git_lines) -------------------
#: The real-git cases below build a throwaway repo (the house ``_git``
#: helper) — skipped, not failed, without the git CLI, exactly like
#: ``tests/integration/test_git_file_dates.py``.
GIT = shutil.which("git") is not None
requires_git = pytest.mark.skipif(not GIT, reason="git CLI not available")
#: Lines spewed to stderr by the deadlock pin — ≈340 KB, i.e. five
#: times the ~64 KB pipe buffer, so a non-drained stderr blocks the
#: child long before the 10 s wall-clock guard trips.
_SPEW_LINES = 60_000
def _git(cwd: Path, *argv: str, when: str | None = None) -> None:
"""Run one git command for the test harness (fixture setup); a
non-zero exit fails the fixture, not the test under test."""
env = os.environ.copy()
if when is not None:
env["GIT_AUTHOR_DATE"] = when
env["GIT_COMMITTER_DATE"] = when
env["GIT_AUTHOR_NAME"] = "T"
env["GIT_AUTHOR_EMAIL"] = "t@example.com"
env["GIT_COMMITTER_NAME"] = "T"
env["GIT_COMMITTER_EMAIL"] = "t@example.com"
proc = subprocess.run(
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
)
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
def _scratch_repo(tmp_path: Path) -> Path:
"""A throwaway repo with ONE commit (``a.md``) dated 2020-01-02."""
repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-q")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "T")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "a.md").write_text("# A\n", encoding="utf-8")
_git(repo, "add", "-A", when="2020-01-02T03:04:06+00:00")
_git(repo, "commit", "-qm", "one", when="2020-01-02T03:04:06+00:00")
return repo
class _StreamProc:
"""A ``Popen`` stand-in for the abandon-the-stream pin.
Real pipes would make the case a race (the child has to still be
alive at ``close()``); this one holds an unbounded ``StringIO`` so
the generator is guaranteed to be parked at its ``yield`` — which is
exactly the state whose cleanup the pin checks: terminate, close
both pipes, join the stderr thread, reap.
"""
def __init__(self, out: str, err: str) -> None:
self.stdout = io.StringIO(out)
self.stderr = io.StringIO(err)
self.returncode: int | None = None
self.terminated = False
self.waits = 0
def terminate(self) -> None:
self.terminated = True
self.returncode = -15
def wait(self) -> int:
self.waits += 1
if self.returncode is None:
self.returncode = 0
return self.returncode
@requires_git
def test_stream_git_lines_yields_lines_and_raises_run_git_shaped_error(
tmp_path: Path,
) -> None:
"""The streaming site's two shapes against REAL git: success yields
the ``git log`` lines straight off the pipe (each one still
newline-terminated — nothing is buffered or re-split), and a failure
raises the ``run_git``-shaped ``GitSyncError`` (``git <subcommand>
failed (exit N): <stderr>``), so every caller's fail-soft text is
unchanged."""
repo = _scratch_repo(tmp_path)
argv = ["git", "log", "--name-only", "--format=@@%cI"]
lines = list(stream_git_lines(argv, cwd=repo))
stripped = [line.rstrip("\n") for line in lines]
assert stripped[1:] == ["", "a.md"]
assert stripped[0].startswith("@@") # the %cI commit header, unsplit
# (%cI prints UTC as "Z"; fromisoformat reads both forms)
assert datetime.fromisoformat(stripped[0][2:]) == datetime(2020, 1, 2, 3, 4, 6, tzinfo=UTC)
assert all(line.endswith("\n") for line in lines)
plain = tmp_path / "not-a-repo"
plain.mkdir()
with pytest.raises(
GitSyncError, match=r"^git log --name-only --format=@@%cI failed \(exit \d+\): "
) as excinfo:
list(stream_git_lines(argv, cwd=plain))
assert "not a git repository" in str(excinfo.value)
@requires_git
def test_stream_git_lines_reports_stderr_without_deadlocking(tmp_path: Path) -> None:
"""A2: a child spilling ≈340 KB on stderr while stdout is being
consumed neither deadlocks nor truncates.
The command is a git alias running a shell that writes
``seq 1 60_000`` (≈340 KB — five pipe buffers) to stderr and exits
3, so git's own stderr pipe is what must be drained. Without the
drain thread the child blocks on a full stderr pipe while the parent
blocks on stdout and neither ever finishes (the classic two-pipe
``Popen`` trap); here the call returns in well under the 10 s guard
and the ``GitSyncError`` carries git's FULL stderr, which is why
``stderr=DEVNULL`` is not the answer (and ``stderr=STDOUT`` would
corrupt the date parse).
"""
repo = _scratch_repo(tmp_path)
argv = ["git", "-c", f'alias.spew=!sh -c "seq 1 {_SPEW_LINES} >&2; exit 3"', "spew"]
started = time.monotonic()
with pytest.raises(GitSyncError) as excinfo:
list(stream_git_lines(argv, cwd=repo))
elapsed = time.monotonic() - started
msg = str(excinfo.value)
assert elapsed < 10, f"streaming a chatty stderr took {elapsed:.1f}s — deadlock?"
assert "(exit 3)" in msg
assert len(msg.encode()) > 100_000, "git's stderr was truncated, not drained"
assert msg.rstrip().endswith(str(_SPEW_LINES)) # the LAST line is there
def test_stream_git_lines_terminates_an_abandoned_stream(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Closing the generator early (``GeneratorExit``) — and an exception
thrown into it — never leaks a ``git`` child: the process is
terminated, both pipes closed, the drain thread joined, the child
reaped."""
proc = _StreamProc("".join(f"line {i}\n" for i in range(5_000)), "warning: noisy stderr\n")
monkeypatch.setattr(subprocess, "Popen", lambda *a, **k: proc)
# The public shape is ``Iterator[str]`` (the parser's contract); this
# pin exercises the generator protocol behind it — close()/throw().
gen = cast(
"Generator[str, None, None]",
stream_git_lines(["git", "log", "--name-only", "--format=@@%cI"], cwd=tmp_path),
)
assert next(gen) == "line 0\n" # parked at the yield, 4,999 lines unread
gen.close()
assert proc.terminated is True
assert proc.returncode == -15 # wait() reaped it — no zombie, no orphan
assert proc.waits >= 1
assert proc.stdout.closed and proc.stderr.closed
# the same arm fires for a consumer that dies on its own exception
proc2 = _StreamProc("line 0\nline 1\n", "")
monkeypatch.setattr(subprocess, "Popen", lambda *a, **k: proc2)
gen2 = cast("Generator[str, None, None]", stream_git_lines(["git", "log"], cwd=tmp_path))
assert next(gen2) == "line 0\n"
with pytest.raises(RuntimeError, match="the parser blew up"):
gen2.throw(RuntimeError("the parser blew up"))
assert proc2.terminated is True
def test_stream_git_lines_missing_git_raises_named_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing git binary is the same ``GitSyncError`` as through
:func:`run_git` — the walk's fail-soft never sees a bare
``FileNotFoundError``."""
def boom(*args: object, **kwargs: object) -> None:
raise FileNotFoundError("git")
monkeypatch.setattr(subprocess, "Popen", boom)
with pytest.raises(GitSyncError, match="git was not found on PATH"):
list(stream_git_lines(["git", "log"], cwd=tmp_path))
# …and the walk's fail-soft keeps working on it: {} + the mtime
# fallback, never a bare FileNotFoundError out of the sync.
assert git_sync.file_commit_dates(tmp_path) == {}
def test_file_commit_dates_fails_soft_on_a_mid_stream_git_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Phase 186 D3: a failure AFTER some lines were parsed still yields
``{}`` + the same warning (the D2 contract is about the RESULT).
Pre-streaming, ``run_git`` could only fail before a single line was
seen; the stream can fail half-way through a huge history. The
importer must still fall back to mtimes — a date walk must never
break a sync — and the partial map must be DROPPED, not returned.
"""
def half_then_boom(argv: list[str], cwd: Path) -> Iterator[str]:
yield "@@2024-06-15T10:00:00+00:00"
yield "a.md"
raise GitSyncError("git log --name-only --format=@@%cI failed (exit 128): fatal: boom")
monkeypatch.setattr(git_sync, "stream_git_lines", half_then_boom)
with caplog.at_level(logging.WARNING, logger="scripts.git_sync"):
assert git_sync.file_commit_dates(tmp_path) == {}
warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert warnings[0].startswith(f"file_commit_dates: git log failed for {tmp_path}: ")
assert "fall back to file mtimes" in warnings[0]
#: Child-process spawns by name (``subprocess.run``/``Popen``/… and the
#: ``os`` equivalents) — what an A11 audit has to catch, not just the
#: literal string "subprocess".
_SUBPROCESS_SPAWNS = frozenset(
{"Popen", "run", "call", "check_call", "check_output", "getoutput", "getstatusoutput"}
)
_OS_SPAWNS = frozenset(
{"system", "popen", "execl", "execle", "execlp", "execv", "execve", "execvp", "posix_spawn"}
)
class _SpawnVisitor(ast.NodeVisitor):
"""Collects ``(enclosing function, spawn call)`` for every child process."""
def __init__(self) -> None:
self.stack: list[str] = []
self.sites: list[tuple[str, str]] = []
@property
def _scope(self) -> str:
return ".".join(self.stack) if self.stack else "<module>"
def _visit_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
self.stack.append(node.name)
self.generic_visit(node)
self.stack.pop()
visit_FunctionDef = _visit_func
visit_AsyncFunctionDef = _visit_func
def visit_Call(self, node: ast.Call) -> None:
fn = node.func
if isinstance(fn, ast.Attribute) and isinstance(fn.value, ast.Name):
if fn.value.id == "subprocess" and fn.attr in _SUBPROCESS_SPAWNS:
self.sites.append((self._scope, f"subprocess.{fn.attr}"))
elif fn.value.id == "os" and fn.attr in _OS_SPAWNS:
self.sites.append((self._scope, f"os.{fn.attr}"))
self.generic_visit(node)
def _spawn_sites(path: Path) -> list[tuple[str, str]]:
"""Every real spawn in one module, tagged with its function.
Parsing the AST (not grepping text) keeps docstrings that MENTION
``subprocess.run`` out of the result — this module has several.
"""
visitor = _SpawnVisitor()
visitor.visit(ast.parse(path.read_text(encoding="utf-8")))
return visitor.sites
def test_only_two_git_spawn_sites() -> None:
"""A11, phase-186 shape: exactly TWO spawn sites, one per shape,
both inside ``scripts/git_sync.py`` — the buffered
``run_git`` (``subprocess.run``) and the streaming
``stream_git_lines`` (``subprocess.Popen``).
The repo-wide half asserts no NEW spawn appears in ``app/`` or
``scripts/``. It enumerates the ONE pre-existing spawn outside the
module: ``app/core/caching.py``'s boot-time asset-version probe (a
5-second-timeouted ``git rev-parse --short HEAD`` that never raises
and is not part of any sync path) — the honest state of A11, which
the module docstring now states too, instead of a pin that quietly
ignores it. ``app/core/docs_push.py`` passes by construction: it
calls ``run_git``, so it never appears here.
"""
module = Path(git_sync.__file__)
assert _spawn_sites(module) == [
("run_git", "subprocess.run"),
("stream_git_lines", "subprocess.Popen"),
]
repo_root = module.parents[1]
offenders: dict[str, list[tuple[str, str]]] = {}
for package in ("app", "scripts"):
for py in sorted((repo_root / package).rglob("*.py")):
if py == module:
continue
found = _spawn_sites(py)
if found:
offenders[str(py.relative_to(repo_root))] = found
assert offenders == {"app/core/caching.py": [("_git_short_sha", "subprocess.run")]}
# docs-push rides the buffered site rather than spawning its own —
# the reason it is absent from `offenders`.
assert docs_push.run_git is git_sync.run_git