mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
feat(services): load service plugins from git repos
Allow directories.services entries to be git repo specs (URL/owner-repo/ @branch) alongside local paths: clone + TTL-pull as cache, list-order priority with a duplicate-ignored summary, a refresh-services command that force-syncs and reports the per-service diff, and a dirty-clone guard that protects local edits on the automated path. Adds binaries.Git and a redact_paths config toggle.
This commit is contained in:
@@ -261,6 +261,19 @@ def test_redact_path_leaves_relative_paths_untouched():
|
||||
assert redact_path("temp/Audio.mp4") == "temp/Audio.mp4"
|
||||
|
||||
|
||||
def test_redact_path_disabled_by_config(monkeypatch):
|
||||
# config.redact_paths: false shows full paths verbatim
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.utils import redact as redact_mod
|
||||
|
||||
root = str(Path(redact_mod.__file__).resolve().parents[3])
|
||||
full = f"{root}/temp/Audio.mp4"
|
||||
monkeypatch.setattr(config, "redact_paths", False, raising=False)
|
||||
assert redact_path(full) == full
|
||||
monkeypatch.setattr(config, "redact_paths", True, raising=False)
|
||||
assert redact_path(full) == "<unshackle>/temp/Audio.mp4"
|
||||
|
||||
|
||||
# ---------- redact_all + output_dir integration ----------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Unit tests for remote git service-repo resolution. No network, no 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):
|
||||
"""Manual path (force=True): hard-reset to upstream, never raises on a dirty clone."""
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
elif "--shortstat" in args:
|
||||
out = b" 4 files changed, 10 insertions(+), 2 deletions(-)\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 "+ NEW (added)" in lines
|
||||
assert "~ OLD (modified)" in lines
|
||||
assert "- GONE (removed)" in lines
|
||||
assert any("4 files changed" in line for line in lines)
|
||||
|
||||
|
||||
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"CR/__init__.py\nCR/config.yaml\nNF/__init__.py\n", b""),
|
||||
)
|
||||
assert service_repo._local_dirty_services(Path("/x")) == ["CR", "NF"]
|
||||
|
||||
|
||||
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"CR/__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 == ["! CR (local changes discarded)"]
|
||||
|
||||
|
||||
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.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"
|
||||
Reference in New Issue
Block a user