from __future__ import annotations import logging from pathlib import Path from unittest.mock import MagicMock import pytest from git import GitCommandError, InvalidGitRepositoryError, Repo from tenbackward.blog import ( BACKOFF_BASE_SECONDS, BlogRepoError, DEFAULT_BLOG_SUBDIR, DEFAULT_REPO_URL, blog_dir, blog_repo_url, ensure_repo, ) def _git_err(stderr: str = "boom") -> GitCommandError: return GitCommandError(["git"], stderr=stderr, status=1) def test_blog_dir_default_subdir(tmp_path: Path) -> None: assert blog_dir(tmp_path) == tmp_path / DEFAULT_BLOG_SUBDIR def test_blog_repo_url_default(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("BLOG_REPO_URL", raising=False) assert blog_repo_url() == DEFAULT_REPO_URL def test_blog_repo_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BLOG_REPO_URL", "https://example.com/repo.git") assert blog_repo_url() == "https://example.com/repo.git" def test_ensure_repo_clones_when_missing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: target = tmp_path / "blog" fake_repo = MagicMock(spec=Repo) calls: list[tuple[str, str]] = [] def fake_clone_from(url: str, path: str) -> Repo: calls.append((url, path)) target.mkdir(parents=True) (target / ".git").mkdir() return fake_repo monkeypatch.setattr("tenbackward.blog.Repo.clone_from", fake_clone_from) sleeps: list[float] = [] result = ensure_repo( tmp_path, repo_url="https://example.com/repo.git", blog_path=target, max_retries=2, sleep=sleeps.append, ) assert result is fake_repo assert calls == [("https://example.com/repo.git", str(target))] assert sleeps == [] def test_ensure_repo_pulls_when_clone_exists( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: target = tmp_path / "blog" target.mkdir() (target / ".git").mkdir() fake_repo = MagicMock(spec=Repo) monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo)) fetch_impl = MagicMock() pull_impl = MagicMock() sleeps: list[float] = [] result = ensure_repo( tmp_path, repo_url="https://example.com/repo.git", blog_path=target, max_retries=3, sleep=sleeps.append, fetch_impl=lambda r: fetch_impl, pull_impl=lambda r: pull_impl, ) assert result is fake_repo fetch_impl.assert_called_once_with() pull_impl.assert_called_once_with() assert sleeps == [] def test_ensure_repo_aborts_on_local_modifications( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: target = tmp_path / "blog" target.mkdir() (target / ".git").mkdir() fake_repo = MagicMock(spec=Repo) monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo)) fetch_impl = MagicMock() pull_impl = MagicMock( side_effect=_git_err("Your local changes to 'foo' would be overwritten by merge") ) sleeps: list[float] = [] with caplog.at_level(logging.ERROR): with pytest.raises(BlogRepoError, match="local modifications"): ensure_repo( tmp_path, repo_url="https://example.com/repo.git", blog_path=target, max_retries=5, sleep=sleeps.append, fetch_impl=lambda r: fetch_impl, pull_impl=lambda r: pull_impl, ) assert fetch_impl.call_count == 1 assert pull_impl.call_count == 1 assert sleeps == [] def test_ensure_repo_retries_transient_error_then_succeeds( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: target = tmp_path / "blog" target.mkdir() (target / ".git").mkdir() fake_repo = MagicMock(spec=Repo) monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo)) fetch_impl = MagicMock(side_effect=[_git_err("Could not resolve host"), None]) pull_impl = MagicMock() sleeps: list[float] = [] ensure_repo( tmp_path, repo_url="https://example.com/repo.git", blog_path=target, max_retries=5, sleep=sleeps.append, fetch_impl=lambda r: fetch_impl, pull_impl=lambda r: pull_impl, ) assert fetch_impl.call_count == 2 pull_impl.assert_called_once_with() assert sleeps == [BACKOFF_BASE_SECONDS ** 1] def test_ensure_repo_raises_after_exhausted_retries( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: target = tmp_path / "blog" target.mkdir() (target / ".git").mkdir() fake_repo = MagicMock(spec=Repo) monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo)) fetch_impl = MagicMock(side_effect=_git_err("Could not resolve host")) pull_impl = MagicMock() sleeps: list[float] = [] with pytest.raises(BlogRepoError, match="blog fetch/pull failed"): ensure_repo( tmp_path, repo_url="https://example.com/repo.git", blog_path=target, max_retries=2, sleep=sleeps.append, fetch_impl=lambda r: fetch_impl, pull_impl=lambda r: pull_impl, ) assert fetch_impl.call_count == 3 pull_impl.assert_not_called() assert sleeps == [BACKOFF_BASE_SECONDS ** 1, BACKOFF_BASE_SECONDS ** 2] def test_ensure_repo_invalid_clone_dir_raises( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: target = tmp_path / "blog" target.mkdir() (target / "not-a-repo.txt").write_text("hi") def fake_repo(_path: str) -> Repo: raise InvalidGitRepositoryError(f"{_path} is not a repo") monkeypatch.setattr("tenbackward.blog.Repo", fake_repo) with pytest.raises(BlogRepoError, match="not a valid git repository"): ensure_repo( tmp_path, repo_url="https://example.com/repo.git", blog_path=target, max_retries=0, )