feat(rag): git-based import sources — BOR_GIT_SOURCES repos cloned (first run, --depth 1) or pulled (--ff-only) into BOR_SOURCES_DIR/<repo>/ then indexed; --source still wins; a failed sync aborts before importing anything
This commit is contained in:
@@ -92,6 +92,53 @@ def test_import_extensions_rejects_empty(monkeypatch) -> None:
|
||||
_settings()
|
||||
|
||||
|
||||
def test_git_sources_default_empty_and_sources_dir_default() -> None:
|
||||
"""Phase 28: no git sources by default (backwards-compatible with the
|
||||
``--source`` / ``DEFAULT_SOURCES`` fallback); the clone location stays
|
||||
a raw string (``~`` is expanded by the import script, not the setting)."""
|
||||
s = _settings()
|
||||
assert s.git_sources == ""
|
||||
assert s.git_source_list == []
|
||||
assert s.sources_dir == "~/bor-sources"
|
||||
|
||||
|
||||
def test_git_sources_env_override_parses_comma_separated_list(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``BOR_GIT_SOURCES`` is a raw CSV: entries are trimmed and empty
|
||||
entries dropped; URLs are stored untouched (no scheme parsing here)."""
|
||||
monkeypatch.setenv(
|
||||
"BOR_GIT_SOURCES",
|
||||
"https://github.com/user/homelab.git, "
|
||||
" git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git ",
|
||||
)
|
||||
s = _settings()
|
||||
# The raw CSV string is preserved untouched (no parsing in the setting).
|
||||
assert s.git_sources == (
|
||||
"https://github.com/user/homelab.git, "
|
||||
" git@github.com:user/deployments.git ,, https://git.reeseapps.com/x/y.git "
|
||||
)
|
||||
assert s.git_source_list == [
|
||||
"https://github.com/user/homelab.git",
|
||||
"git@github.com:user/deployments.git",
|
||||
"https://git.reeseapps.com/x/y.git",
|
||||
]
|
||||
|
||||
|
||||
def test_git_sources_whitespace_only_yields_empty_list(monkeypatch) -> None:
|
||||
"""A configured-but-blank value behaves the same as unset: no git
|
||||
sources, so the script falls back to its legacy local defaults."""
|
||||
monkeypatch.setenv("BOR_GIT_SOURCES", " , , ")
|
||||
s = _settings()
|
||||
assert s.git_source_list == []
|
||||
|
||||
|
||||
def test_sources_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BOR_SOURCES_DIR", "/data/bor/sources")
|
||||
s = _settings()
|
||||
assert s.sources_dir == "/data/bor/sources"
|
||||
|
||||
|
||||
def test_suggestions_default_is_three_plus_real_questions() -> None:
|
||||
s = _settings()
|
||||
assert len(s.suggestions) >= 3
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Unit tests: git clone/pull utility (phase 28).
|
||||
|
||||
``clone_or_pull`` dispatches to ``git clone --depth 1`` (fresh dest) or
|
||||
``git pull --ff-only`` (existing checkout) with ``subprocess`` fully
|
||||
mocked — the real git CLI is never invoked, so the tests run anywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import scripts.git_sync as git_sync
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
|
||||
|
||||
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,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Monkeypatch scripts.git_sync.subprocess.run; record each call."""
|
||||
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")
|
||||
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 --depth 1`` 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", "--depth", "1", url, str(dest)]
|
||||
assert call["cwd"] == dest.parent
|
||||
assert call["capture_output"] is True
|
||||
assert call["text"] is True
|
||||
|
||||
|
||||
def test_clone_or_pull_pulls_when_git_dir_exists(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Subsequent run: dest/.git present → ``git pull --ff-only`` in-place."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(monkeypatch)
|
||||
|
||||
result = clone_or_pull("https://github.com/user/homelab.git", dest)
|
||||
|
||||
assert result == dest
|
||||
assert calls[0]["argv"] == ["git", "pull", "--ff-only"]
|
||||
assert calls[0]["cwd"] == dest
|
||||
|
||||
|
||||
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()
|
||||
_fake_run(monkeypatch)
|
||||
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest)
|
||||
|
||||
assert dest.parent.is_dir()
|
||||
|
||||
|
||||
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",
|
||||
"--depth",
|
||||
"1",
|
||||
"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 --depth 1 .* failed \(exit 128\): fatal: repository not found",
|
||||
):
|
||||
clone_or_pull("https://example.com/nope.git", dest)
|
||||
|
||||
# Same for the pull path (broken checkout, e.g. diverged history).
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
_fake_run(monkeypatch, returncode=1, stderr="error: cannot pull with rebase")
|
||||
|
||||
with pytest.raises(
|
||||
GitSyncError, match=r"git pull --ff-only failed \(exit 1\): error: cannot pull with rebase"
|
||||
):
|
||||
clone_or_pull("https://example.com/homelab.git", dest)
|
||||
|
||||
|
||||
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_captures_and_returns_stdout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""_run 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", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert len(calls) == 1
|
||||
Reference in New Issue
Block a user