"""Unit tests for remote git service-repo resolution. No network; only the bytecode tests run a real git.""" from __future__ import annotations import subprocess import time from pathlib import Path import pytest from unshackle.core import service_repo @pytest.mark.parametrize( "value, expected", [ ("https://github.com/owner/repo", True), ("http://example.com/me/svc", True), ("git@gitlab.com:me/my-services.git", True), ("ssh://git@host/me/svc", True), ("me/svc.git", True), ("owner/repo", True), ("owner/repo@main", True), ("/abs/local/path", False), ("~/my-local-services", False), (123, False), ], ) def test_is_repo_spec(value, expected): assert service_repo.is_repo_spec(value) is expected def test_is_repo_spec_existing_path_wins(tmp_path): # an owner/repo-looking value that exists locally is a path, not a repo local = tmp_path / "owner" / "repo" local.mkdir(parents=True) assert service_repo.is_repo_spec(str(local)) is False @pytest.mark.parametrize( "spec, url, branch, dest", [ ("owner/repo@main", "https://github.com/owner/repo", "main", "github.com__owner__repo"), ("owner/repo", "https://github.com/owner/repo", None, "github.com__owner__repo"), ("git@gitlab.com:me/my-services.git", "git@gitlab.com:me/my-services.git", None, "gitlab.com__me__my-services"), ("https://github.com/a/b", "https://github.com/a/b", None, "github.com__a__b"), ], ) def test_parse_spec(spec, url, branch, dest): assert service_repo.parse_spec(spec) == (url, branch, dest) def test_slug_distinguishes_hosts(): # same owner/repo on different hosts must not share a clone dir a = service_repo.parse_spec("https://github.com/owner/repo")[2] b = service_repo.parse_spec("https://gitlab.com/owner/repo")[2] assert a != b def test_repos_base_uses_configured_local_dir(monkeypatch, tmp_path): from unshackle.core.config import config monkeypatch.setattr(config.directories, "services", ["owner/repo", str(tmp_path / "svc")], raising=False) assert service_repo.repos_base() == (tmp_path / "svc" / "_repos") def test_is_stale(tmp_path): stamp = tmp_path / "stamp" assert service_repo.is_stale(stamp, ttl=10) is True # missing → stale stamp.write_text(str(time.time())) assert service_repo.is_stale(stamp, ttl=10) is False # fresh stamp.write_text(str(time.time() - 100)) assert service_repo.is_stale(stamp, ttl=10) is True # old def test_clone_invokes_git(monkeypatch, tmp_path): monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) calls = [] def fake_run(args, **kw): calls.append(args) (tmp_path / "github.com__owner__repo").mkdir(parents=True, exist_ok=True) # simulate clone output return subprocess.CompletedProcess(args, 0, b"", b"") monkeypatch.setattr(service_repo.subprocess, "run", fake_run) dest = service_repo.resolve_service_repo("owner/repo@main") assert dest == tmp_path / "github.com__owner__repo" assert calls[0][:4] == ["git", "clone", "--depth", "1"] assert "-b" in calls[0] and "main" in calls[0] assert service_repo.stamp_for(dest).exists() # stamp written (beside the clone, not inside) def test_clone_missing_git_returns_none(monkeypatch, tmp_path): monkeypatch.setattr(service_repo.binaries, "Git", None) monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) assert service_repo.resolve_service_repo("a/b") is None def test_fresh_clone_skips_pull(monkeypatch, tmp_path): dest = tmp_path / "github.com__a__b" dest.mkdir(parents=True) service_repo.stamp_for(dest).write_text(str(time.time())) monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) monkeypatch.setattr( service_repo.subprocess, "run", lambda *a, **k: pytest.fail("git should not run for a fresh clone") ) assert service_repo.resolve_service_repo("a/b") == dest def git_stub(porcelain=b"", ahead=b"0"): """Fake subprocess.run for git: dirty checks return given output, pull succeeds.""" def run(args, **kw): sub = args[3] if len(args) > 3 else "" if sub == "status": return subprocess.CompletedProcess(args, 0, porcelain, b"") if sub == "rev-list": return subprocess.CompletedProcess(args, 0, ahead, b"") return subprocess.CompletedProcess(args, 0, b"", b"") # pull return run def test_is_dirty_uncommitted(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b" M EXAMPLE/__init__.py\n")) assert service_repo.is_dirty(Path("/x")) is True def test_is_dirty_commits_ahead(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b"", ahead=b"2")) assert service_repo.is_dirty(Path("/x")) is True def test_is_dirty_clean(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b"", ahead=b"0")) assert service_repo.is_dirty(Path("/x")) is False def test_is_dirty_ignores_untracked_files(monkeypatch): # status must ask git to skip untracked files so importing services (__pycache__) isn't "dirty" seen = [] monkeypatch.setattr(service_repo.binaries, "Git", "git") def run(args, **kw): seen.append(args) out = b"0" if args[3] == "rev-list" else b"" return subprocess.CompletedProcess(args, 0, out, b"") monkeypatch.setattr(service_repo.subprocess, "run", run) service_repo.is_dirty(Path("/x")) status_args = next(a for a in seen if a[3] == "status") assert "--untracked-files=no" in status_args def test_auto_refresh_refuses_dirty_clone(monkeypatch, tmp_path): """Auto path (force=False): a stale clone with local edits raises instead of clobbering.""" dest = tmp_path / "github.com__a__b" dest.mkdir(parents=True) service_repo.stamp_for(dest).write_text("0") # stale → refresh due monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr(service_repo.subprocess, "run", git_stub(porcelain=b" M f\n")) with pytest.raises(service_repo.DirtyServiceRepo) as ei: service_repo.resolve_service_repo("a/b") assert ei.value.path == dest def test_manual_refresh_force_overwrites(monkeypatch, tmp_path): """Force path (services_repo_force): hard-resets a stale clone, never raises; leaves a fresh clone alone.""" dest = tmp_path / "github.com__a__b" dest.mkdir(parents=True) monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) monkeypatch.setattr(service_repo.binaries, "Git", "git") calls = [] monkeypatch.setattr( service_repo.subprocess, "run", lambda args, **kw: (calls.append(args[3]), subprocess.CompletedProcess(args, 0, b"", b""))[1], ) assert service_repo.resolve_service_repo("a/b", force=True) == dest assert "fetch" in calls and "reset" in calls # force-sync ran assert "status" not in calls # no dirty gate on the force path calls.clear() service_repo.write_stamp(service_repo.stamp_for(dest)) # fresh stamp, inside the TTL assert service_repo.resolve_service_repo("a/b", force=True) == dest assert calls == [] # no network before the TTL expires def test_changed_services_groups_by_top_dir(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") def run(args, **kw): if "--name-status" in args: out = b"A\tNEW/__init__.py\nM\tOLD/__init__.py\nM\tOLD/config.yaml\nD\tGONE/__init__.py\n" else: out = b"" return subprocess.CompletedProcess(args, 0, out, b"") monkeypatch.setattr(service_repo.subprocess, "run", run) lines = service_repo.changed_services(Path("/x"), "aaa", "bbb") assert lines == ["-GONE", "+NEW", "~OLD"] def test_local_dirty_services_lists_edited_dirs(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") monkeypatch.setattr( service_repo.subprocess, "run", lambda *a, **k: subprocess.CompletedProcess( a, 0, b"EXAMPLE/__init__.py\nEXAMPLE/config.yaml\nDEMO/__init__.py\n", b"" ), ) assert service_repo.local_dirty_services(Path("/x")) == ["DEMO", "EXAMPLE"] def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path): """Force refresh with no upstream advance still reports the local edits it wiped.""" dest = tmp_path / "github.com__a__b" dest.mkdir(parents=True) monkeypatch.setattr(service_repo, "repos_base", lambda: tmp_path) monkeypatch.setattr(service_repo.binaries, "Git", "git") def run(args, **kw): if "diff" in args and "--name-only" in args: return subprocess.CompletedProcess(args, 0, b"EXAMPLE/__init__.py\n", b"") # local edit present if "rev-parse" in args: return subprocess.CompletedProcess(args, 0, b"samehead\n", b"") # upstream did not advance return subprocess.CompletedProcess(args, 0, b"", b"") # fetch/reset/diff monkeypatch.setattr(service_repo.subprocess, "run", run) _, changes = service_repo.refresh_repo("a/b") assert changes == ["!EXAMPLE"] def test_changed_services_up_to_date(monkeypatch): monkeypatch.setattr(service_repo.binaries, "Git", "git") # same HEAD before/after → no diff computed at all monkeypatch.setattr(service_repo.subprocess, "run", lambda *a, **k: pytest.fail("no git diff when HEAD unchanged")) assert service_repo.changed_services(Path("/x"), "same", "same") == [] @pytest.fixture def bytecode_clone(tmp_path): """A clone of a repo that commits a ``.pyc``, with that ``.pyc`` rewritten as an import does.""" git = service_repo.binaries.Git if not git: pytest.skip("git not found") upstream, dest = tmp_path / "upstream", tmp_path / "clone" (upstream / "EXAMPLE" / "__pycache__").mkdir(parents=True) (upstream / "EXAMPLE" / "__init__.py").write_text("X = 1\n") (upstream / "EXAMPLE" / "__pycache__" / "__init__.cpython-312.pyc").write_bytes(b"old") def run(*args: str, cwd: Path = upstream) -> None: subprocess.run( [git, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", *args], cwd=cwd, check=True, capture_output=True, ) run("init", "-q", "-b", "main") run("add", "-A") run("commit", "-qm", "init") run("clone", "-q", str(upstream), str(dest), cwd=tmp_path) (dest / "EXAMPLE" / "__pycache__" / "__init__.cpython-312.pyc").write_bytes(b"rewritten") return upstream, dest, run def test_rewritten_bytecode_does_not_block_pull(bytecode_clone, tmp_path): upstream, dest, run = bytecode_clone assert service_repo.is_dirty(dest) is False assert service_repo.local_dirty_services(dest) == [] (upstream / "EXAMPLE" / "__init__.py").write_text("X = 2\n") (upstream / "EXAMPLE" / "__pycache__" / "__init__.cpython-312.pyc").write_bytes(b"new") run("commit", "-qam", "update") service_repo.pull(dest, tmp_path / "stamp") assert (dest / "EXAMPLE" / "__init__.py").read_text() == "X = 2\n" def test_source_edit_still_dirty_beside_bytecode(bytecode_clone): _, dest, _ = bytecode_clone (dest / "EXAMPLE" / "__init__.py").write_text("X = 3\n") assert service_repo.is_dirty(dest) is True assert service_repo.local_dirty_services(dest) == ["EXAMPLE"] def test_untracked_bytecode_does_not_block_pull(bytecode_clone, tmp_path): upstream, dest, run = bytecode_clone (dest / "EXAMPLE" / "__pycache__" / "__init__.cpython-314.pyc").write_bytes(b"local") (dest / "OTHER").mkdir() (dest / "OTHER" / "__init__.py").write_text("") (dest / "OTHER" / "__init__.cpython-314-x86_64-linux-gnu.so").write_bytes(b"compiled") (upstream / "EXAMPLE" / "__init__.py").write_text("X = 2\n") (upstream / "EXAMPLE" / "__pycache__" / "__init__.cpython-314.pyc").write_bytes(b"upstream") run("add", "-A") run("commit", "-qm", "update") service_repo.pull(dest, tmp_path / "stamp") assert (dest / "EXAMPLE" / "__init__.py").read_text() == "X = 2\n" assert (dest / "OTHER" / "__init__.cpython-314-x86_64-linux-gnu.so").read_bytes() == b"compiled" @pytest.mark.parametrize("order", [["a", "b"], ["b", "a"]]) def test_collision_first_source_wins(tmp_path, order): """Discovery dedupes by tag honoring list order: the FIRST source to define a tag wins, whether it's a local dir or a repo clone; later duplicates are shadowed.""" dirs = {} for name in ("a", "b"): svc = tmp_path / name / "EXAMPLE" svc.mkdir(parents=True) (svc / "__init__.py").touch() dirs[name] = tmp_path / name seen: dict = {} shadowed: list = [] for name in order: # services.py preserves config order for path in dirs[name].glob("*/__init__.py"): tag = path.parent.stem if tag in seen: shadowed.append((tag, path, seen[tag])) else: seen[tag] = path winner, loser = order[0], order[1] assert seen["EXAMPLE"] == dirs[winner] / "EXAMPLE" / "__init__.py" assert shadowed and shadowed[0][1] == dirs[loser] / "EXAMPLE" / "__init__.py"