mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
Implements ifcurl-4d4. _compute_genesis() runs git rev-list --max-parents=0 HEAD to find the root commit(s); get_genesis_commit() wraps it with a per-remote disk cache (~/.cache/ifcurl/<hash>/genesis_commit) so the git command only runs once per repo. For multi-root repos the lexicographically smallest hexsha is chosen for a stable result. Local repos compute fresh on each call. Exported from __init__. Unblocks local repo discovery (ifcurl-egi). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
688 lines
26 KiB
Python
688 lines
26 KiB
Python
"""Tests for ifcurl.git — git repository access."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from ifcurl.git import fetch_ifc, fetch_ifc_bytes
|
|
from ifcurl.url import IfcUrl
|
|
|
|
|
|
def _local_url(repo_path: str, ref: str = "HEAD", path: str = "model.ifc") -> IfcUrl:
|
|
"""Build an ifc:// URL pointing at a local repo fixture."""
|
|
return IfcUrl.parse(f"ifc://{repo_path}@{ref}?path={path}")
|
|
|
|
|
|
class TestFetchIfc:
|
|
def test_returns_hexsha_and_bytes(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
hexsha, data, _ = fetch_ifc(url)
|
|
assert len(hexsha) == 40
|
|
assert all(c in "0123456789abcdef" for c in hexsha)
|
|
assert isinstance(data, bytes)
|
|
assert len(data) > 0
|
|
|
|
def test_local_repo_never_stale(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
_, _, is_stale = fetch_ifc(url)
|
|
assert is_stale is False
|
|
|
|
def test_hexsha_matches_commit(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
hexsha, _, _ = fetch_ifc(url)
|
|
assert hexsha == local_ifc_repo["hexsha"]
|
|
|
|
def test_bytes_match_committed_file(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
_, data, _ = fetch_ifc(url)
|
|
assert data == local_ifc_repo["bytes"]
|
|
|
|
def test_explicit_commit_hash_ref(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"], ref=local_ifc_repo["hexsha"])
|
|
hexsha, data, _ = fetch_ifc(url)
|
|
assert hexsha == local_ifc_repo["hexsha"]
|
|
assert data == local_ifc_repo["bytes"]
|
|
|
|
def test_tag_ref(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"], ref="tags/v1.0")
|
|
hexsha, data, _ = fetch_ifc(url)
|
|
assert hexsha == local_ifc_repo["hexsha"]
|
|
|
|
def test_branch_ref(self, local_ifc_repo):
|
|
import git as gitpkg
|
|
|
|
branch = gitpkg.Repo(local_ifc_repo["path"]).active_branch.name
|
|
url = _local_url(local_ifc_repo["path"], ref=f"heads/{branch}")
|
|
hexsha, data, _ = fetch_ifc(url)
|
|
assert hexsha == local_ifc_repo["hexsha"]
|
|
assert data == local_ifc_repo["bytes"]
|
|
|
|
def test_bad_ref_raises(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"], ref="heads/nonexistent")
|
|
with pytest.raises(ValueError, match="not found"):
|
|
fetch_ifc(url)
|
|
|
|
def test_bad_path_raises(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"], path="missing.ifc")
|
|
with pytest.raises(ValueError, match="not found"):
|
|
fetch_ifc(url)
|
|
|
|
def test_directory_path_raises(self, local_ifc_repo):
|
|
# Committing a subdir would be complex; test that asking for a
|
|
# non-blob raises — here we ask for the root tree entry.
|
|
url = _local_url(local_ifc_repo["path"], path=".")
|
|
with pytest.raises((ValueError, KeyError)):
|
|
fetch_ifc(url)
|
|
|
|
def test_no_path_raises(self, local_ifc_repo):
|
|
url = IfcUrl.parse(f"ifc://{local_ifc_repo['path']}@HEAD")
|
|
with pytest.raises(ValueError, match="'path' parameter"):
|
|
fetch_ifc(url)
|
|
|
|
|
|
class TestFetchIfcBytes:
|
|
def test_returns_bytes(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
data = fetch_ifc_bytes(url)
|
|
assert data == local_ifc_repo["bytes"]
|
|
|
|
def test_backwards_compat_with_fetch_ifc(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
_, expected, _ = fetch_ifc(url)
|
|
assert fetch_ifc_bytes(url) == expected
|
|
|
|
|
|
class TestInvalidRepo:
|
|
def test_nonexistent_path_raises(self, tmp_path):
|
|
url = IfcUrl.parse(f"ifc://{tmp_path / 'no_such_repo'}@HEAD?path=m.ifc")
|
|
with pytest.raises(ValueError):
|
|
fetch_ifc(url)
|
|
|
|
def test_non_git_directory_raises(self, tmp_path):
|
|
(tmp_path / "model.ifc").write_text("not a repo")
|
|
url = IfcUrl.parse(f"ifc://{tmp_path}@HEAD?path=model.ifc")
|
|
with pytest.raises(ValueError):
|
|
fetch_ifc(url)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fetch-on-miss retry (PR / non-default-branch commits)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_remote_with_branch_after_clone(tmp_path: Path, ifc_bytes: bytes, cache_dest: Path) -> dict:
|
|
"""Create a remote repo, bare-clone it, then add a branch commit to the remote.
|
|
|
|
This simulates the real scenario: initial bare clone only has the default branch,
|
|
then a PR is created (new commit appears in remote) that the clone doesn't know about.
|
|
Returns metadata about both commits and the pre-populated bare cache.
|
|
"""
|
|
import git as gitpkg
|
|
|
|
remote = tmp_path / "remote"
|
|
remote.mkdir()
|
|
r = gitpkg.Repo.init(str(remote))
|
|
with r.config_writer() as cw:
|
|
cw.set_value("user", "name", "Test")
|
|
cw.set_value("user", "email", "test@example.com")
|
|
|
|
(remote / "model.ifc").write_bytes(ifc_bytes)
|
|
r.index.add(["model.ifc"])
|
|
main_commit = r.index.commit("main commit")
|
|
|
|
# Clone while only the main commit exists
|
|
cache = gitpkg.Repo.clone_from(str(remote), str(cache_dest), bare=True)
|
|
|
|
# Now add a branch commit to the remote (simulates a PR being opened)
|
|
r.create_head("pr-branch").checkout()
|
|
modified = ifc_bytes + b"\n/* branch */"
|
|
(remote / "model.ifc").write_bytes(modified)
|
|
r.index.add(["model.ifc"])
|
|
branch_commit = r.index.commit("pr commit")
|
|
r.heads[r.active_branch.name].checkout() # back to default
|
|
|
|
return {
|
|
"remote_path": str(remote),
|
|
"cache": cache,
|
|
"main_hexsha": main_commit.hexsha,
|
|
"branch_hexsha": branch_commit.hexsha,
|
|
"branch_bytes": modified,
|
|
}
|
|
|
|
|
|
class TestFetchAllRefs:
|
|
def test_fetches_non_default_branch_commit(self, tmp_path, local_ifc_repo):
|
|
from ifcurl.git import _fetch_all_refs
|
|
|
|
meta = _make_remote_with_branch_after_clone(
|
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
|
)
|
|
|
|
with pytest.raises(Exception): # commit absent before fetch
|
|
meta["cache"].commit(meta["branch_hexsha"])
|
|
|
|
_fetch_all_refs(meta["cache"], meta["remote_path"], token=None)
|
|
|
|
found = meta["cache"].commit(meta["branch_hexsha"])
|
|
assert found.hexsha == meta["branch_hexsha"]
|
|
|
|
def test_main_commit_still_accessible_after_fetch(self, tmp_path, local_ifc_repo):
|
|
from ifcurl.git import _fetch_all_refs
|
|
|
|
meta = _make_remote_with_branch_after_clone(
|
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
|
)
|
|
_fetch_all_refs(meta["cache"], meta["remote_path"], token=None)
|
|
|
|
found = meta["cache"].commit(meta["main_hexsha"])
|
|
assert found.hexsha == meta["main_hexsha"]
|
|
|
|
def test_noop_when_fetch_fails(self, tmp_path, local_ifc_repo):
|
|
from ifcurl.git import _fetch_all_refs
|
|
|
|
meta = _make_remote_with_branch_after_clone(
|
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
|
)
|
|
# Bad remote URL — should silently fail without raising
|
|
_fetch_all_refs(meta["cache"], "file:///nonexistent/repo.git", token=None)
|
|
|
|
|
|
class TestFetchIfcRetry:
|
|
def _patch_url_as_remote(self, url, meta, monkeypatch):
|
|
"""Patch a local IfcUrl instance to look like a remote immutable ref."""
|
|
import ifcurl.url as url_mod
|
|
url.transport = "https"
|
|
monkeypatch.setattr(url_mod.IfcUrl, "is_mutable_ref", lambda self: False)
|
|
monkeypatch.setattr(url_mod.IfcUrl, "git_ref", lambda self: meta["branch_hexsha"])
|
|
monkeypatch.setattr(url_mod.IfcUrl, "git_remote_url", lambda self: meta["remote_path"])
|
|
|
|
def test_no_retry_for_local_transport(self, local_ifc_repo, monkeypatch):
|
|
"""For local transport, BadObject should propagate without triggering retry."""
|
|
import git.exc
|
|
import ifcurl.git as gmod
|
|
|
|
fetch_calls = []
|
|
monkeypatch.setattr(gmod, "_fetch_all_refs", lambda *a: fetch_calls.append(1))
|
|
|
|
monkeypatch.setattr(
|
|
gmod,
|
|
"_read_commit_blob",
|
|
lambda repo, git_ref, fp: (_ for _ in ()).throw(
|
|
git.exc.BadObject(git_ref.encode(), b"missing")
|
|
),
|
|
)
|
|
|
|
url = IfcUrl.parse(
|
|
f"ifc://{local_ifc_repo['path']}@{local_ifc_repo['hexsha']}?path=model.ifc"
|
|
)
|
|
with pytest.raises((ValueError, git.exc.BadObject)):
|
|
fetch_ifc(url)
|
|
|
|
assert fetch_calls == [], "retry must not fire for local transport"
|
|
|
|
def test_retry_fires_for_remote_immutable_ref(self, tmp_path, local_ifc_repo, monkeypatch):
|
|
"""fetch_ifc calls _fetch_all_refs then retries when commit is absent from clone."""
|
|
import ifcurl.git as gmod
|
|
|
|
meta = _make_remote_with_branch_after_clone(
|
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
|
)
|
|
|
|
real_fetch_all = gmod._fetch_all_refs
|
|
fetch_calls = []
|
|
|
|
def recording_fetch_all(repo, remote_url, token):
|
|
fetch_calls.append(remote_url)
|
|
real_fetch_all(repo, meta["remote_path"], token)
|
|
|
|
monkeypatch.setattr(gmod, "_get_repo", lambda ifc_url, token=None: (meta["cache"], False))
|
|
monkeypatch.setattr(gmod, "_fetch_all_refs", recording_fetch_all)
|
|
|
|
url = IfcUrl.parse(f"ifc:///ignored@{meta['branch_hexsha']}?path=model.ifc")
|
|
self._patch_url_as_remote(url, meta, monkeypatch)
|
|
|
|
hexsha, data, _ = fetch_ifc(url)
|
|
|
|
assert hexsha == meta["branch_hexsha"]
|
|
assert data == meta["branch_bytes"]
|
|
assert len(fetch_calls) == 1
|
|
|
|
def test_bad_object_exception_triggers_retry(self, tmp_path, local_ifc_repo, monkeypatch):
|
|
"""BadObject (even if a ValueError subclass) triggers the retry, not just ValueError."""
|
|
import git.exc
|
|
import ifcurl.git as gmod
|
|
|
|
meta = _make_remote_with_branch_after_clone(
|
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
|
)
|
|
|
|
orig_read = gmod._read_commit_blob
|
|
real_fetch_all = gmod._fetch_all_refs
|
|
call_count = [0]
|
|
|
|
def flaky_read(repo, git_ref, file_path):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
raise git.exc.BadObject(git_ref.encode(), b"missing")
|
|
return orig_read(repo, git_ref, file_path)
|
|
|
|
fetch_calls = []
|
|
|
|
def recording_fetch_all(repo, remote_url, token):
|
|
fetch_calls.append(remote_url)
|
|
real_fetch_all(repo, meta["remote_path"], token)
|
|
|
|
monkeypatch.setattr(gmod, "_read_commit_blob", flaky_read)
|
|
monkeypatch.setattr(gmod, "_fetch_all_refs", recording_fetch_all)
|
|
monkeypatch.setattr(gmod, "_get_repo", lambda ifc_url, token=None: (meta["cache"], False))
|
|
|
|
url = IfcUrl.parse(f"ifc:///ignored@{meta['branch_hexsha']}?path=model.ifc")
|
|
self._patch_url_as_remote(url, meta, monkeypatch)
|
|
|
|
hexsha, data, _ = fetch_ifc(url)
|
|
|
|
assert hexsha == meta["branch_hexsha"]
|
|
assert call_count[0] == 2 # first raises BadObject, second succeeds
|
|
assert len(fetch_calls) == 1 # _fetch_all_refs called once between the two
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache management helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
from ifcurl.git import _dir_size, _evict_if_needed, _repo_cache_entries, _touch_cache
|
|
|
|
|
|
def _make_fake_repo_cache(base: Path, url: str, size_bytes: int = 1024) -> Path:
|
|
"""Create a fake cached-repo directory structure under *base*."""
|
|
import hashlib
|
|
|
|
key = hashlib.sha256(url.encode()).hexdigest()[:24]
|
|
entry = base / key
|
|
git_dir = entry / "repo.git"
|
|
git_dir.mkdir(parents=True)
|
|
(git_dir / "dummy").write_bytes(b"\x00" * size_bytes)
|
|
(entry / "remote_url").write_text(url)
|
|
return entry
|
|
|
|
|
|
class TestCacheHelpers:
|
|
def test_dir_size_counts_nested_files(self, tmp_path):
|
|
(tmp_path / "a").write_bytes(b"x" * 100)
|
|
(tmp_path / "sub").mkdir()
|
|
(tmp_path / "sub" / "b").write_bytes(b"y" * 200)
|
|
assert _dir_size(tmp_path) == 300
|
|
|
|
def test_touch_cache_updates_mtime(self, tmp_path):
|
|
import time
|
|
|
|
old_mtime = tmp_path.stat().st_mtime
|
|
time.sleep(0.05)
|
|
_touch_cache(tmp_path)
|
|
assert tmp_path.stat().st_mtime > old_mtime
|
|
|
|
def test_repo_cache_entries_lists_repos(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
_make_fake_repo_cache(tmp_path, "https://example.com/org/repo")
|
|
_make_fake_repo_cache(tmp_path, "https://github.com/org/other")
|
|
entries = _repo_cache_entries()
|
|
urls = {e[3] for e in entries}
|
|
assert "https://example.com/org/repo" in urls
|
|
assert "https://github.com/org/other" in urls
|
|
|
|
def test_repo_cache_entries_ignores_non_repo_dirs(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
(tmp_path / "not_a_repo").mkdir() # no repo.git subdir
|
|
entries = _repo_cache_entries()
|
|
assert len(entries) == 0
|
|
|
|
def test_evict_removes_oldest_over_limit(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
monkeypatch.setenv("IFCURL_CACHE_MAX_GB", "0.000001") # ~1 KB limit
|
|
old = _make_fake_repo_cache(
|
|
tmp_path, "https://old.example.com/repo", size_bytes=512
|
|
)
|
|
time.sleep(0.05)
|
|
new = _make_fake_repo_cache(
|
|
tmp_path, "https://new.example.com/repo", size_bytes=512
|
|
)
|
|
_evict_if_needed()
|
|
assert not old.exists()
|
|
assert new.exists()
|
|
|
|
def test_evict_no_limit_does_nothing(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
monkeypatch.delenv("IFCURL_CACHE_MAX_GB", raising=False)
|
|
entry = _make_fake_repo_cache(tmp_path, "https://example.com/repo")
|
|
_evict_if_needed()
|
|
assert entry.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache CLI (_cmd_cache)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
import argparse
|
|
import types
|
|
|
|
|
|
def _cache_args(**kwargs) -> argparse.Namespace:
|
|
defaults = {"cache_cmd": None, "max_gb": None}
|
|
defaults.update(kwargs)
|
|
return argparse.Namespace(**defaults)
|
|
|
|
|
|
class TestCacheCli:
|
|
def _patch(self, monkeypatch, tmp_path):
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
monkeypatch.setattr("ifcurl.__main__.user_cache_dir", lambda *a, **kw: str(tmp_path), raising=False)
|
|
|
|
def test_list_empty(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="list"))
|
|
out = capsys.readouterr().out
|
|
assert "No cached repositories" in out
|
|
|
|
def test_list_default_subcommand(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
_make_fake_repo_cache(tmp_path, "https://example.com/org/repo", size_bytes=2048)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd=None))
|
|
out = capsys.readouterr().out
|
|
assert "https://example.com/org/repo" in out
|
|
assert "Total:" in out
|
|
|
|
def test_list_shows_entries_and_total(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
monkeypatch.delenv("IFCURL_CACHE_MAX_GB", raising=False)
|
|
_make_fake_repo_cache(tmp_path, "https://example.com/a", size_bytes=1024 * 1024)
|
|
_make_fake_repo_cache(tmp_path, "https://example.com/b", size_bytes=1024 * 1024)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="list"))
|
|
out = capsys.readouterr().out
|
|
assert "https://example.com/a" in out
|
|
assert "https://example.com/b" in out
|
|
assert "2 repos" in out
|
|
|
|
def test_list_shows_limit_when_env_set(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("IFCURL_CACHE_MAX_GB", "5.0")
|
|
_make_fake_repo_cache(tmp_path, "https://example.com/a", size_bytes=1024)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="list"))
|
|
out = capsys.readouterr().out
|
|
assert "limit:" in out
|
|
assert "5.0 GB" in out
|
|
|
|
def test_clear_removes_all(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
e1 = _make_fake_repo_cache(tmp_path, "https://example.com/a")
|
|
e2 = _make_fake_repo_cache(tmp_path, "https://example.com/b")
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="clear"))
|
|
assert not e1.exists()
|
|
assert not e2.exists()
|
|
out = capsys.readouterr().out
|
|
assert "Cleared" in out
|
|
assert "2 repos" in out
|
|
|
|
def test_clear_empty(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="clear"))
|
|
out = capsys.readouterr().out
|
|
assert "Nothing to clear" in out
|
|
|
|
def test_prune_with_max_gb_arg(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
monkeypatch.delenv("IFCURL_CACHE_MAX_GB", raising=False)
|
|
# Two 1 MB entries; limit to 0.5 MB so both are evicted
|
|
e1 = _make_fake_repo_cache(tmp_path, "https://example.com/old", size_bytes=1024 * 1024)
|
|
time.sleep(0.05)
|
|
e2 = _make_fake_repo_cache(tmp_path, "https://example.com/new", size_bytes=1024 * 1024)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="prune", max_gb=0.0000005))
|
|
out = capsys.readouterr().out
|
|
assert "Freed" in out
|
|
assert "remaining" in out
|
|
|
|
def test_prune_no_max_gb_and_no_env_exits(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
monkeypatch.delenv("IFCURL_CACHE_MAX_GB", raising=False)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
with pytest.raises(SystemExit):
|
|
_cmd_cache(_cache_args(cache_cmd="prune", max_gb=None))
|
|
err = capsys.readouterr().err
|
|
assert "required" in err
|
|
|
|
def test_prune_uses_env_when_no_arg(self, tmp_path, monkeypatch, capsys):
|
|
self._patch(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("IFCURL_CACHE_MAX_GB", "0.0000005")
|
|
_make_fake_repo_cache(tmp_path, "https://example.com/repo", size_bytes=1024 * 1024)
|
|
from ifcurl.__main__ import _cmd_cache
|
|
_cmd_cache(_cache_args(cache_cmd="prune", max_gb=None))
|
|
out = capsys.readouterr().out
|
|
assert "Freed" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fetch_ifc_path — file materialisation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
from ifcurl.git import fetch_ifc_path, _materialise
|
|
|
|
|
|
class TestMaterialise:
|
|
def test_returns_path(self, tmp_path, local_ifc_repo):
|
|
result = _materialise(tmp_path, "abc123", "model.ifc", b"data")
|
|
assert isinstance(result, Path)
|
|
|
|
def test_file_exists(self, tmp_path, local_ifc_repo):
|
|
result = _materialise(tmp_path, "abc123", "model.ifc", b"data")
|
|
assert result.exists()
|
|
|
|
def test_file_contains_data(self, tmp_path):
|
|
data = b"IFC4\x00\xFF"
|
|
result = _materialise(tmp_path, "deadbeef", "structure.ifc", data)
|
|
assert result.read_bytes() == data
|
|
|
|
def test_hexsha_in_path(self, tmp_path):
|
|
result = _materialise(tmp_path, "cafebabe", "model.ifc", b"x")
|
|
assert "cafebabe" in str(result)
|
|
|
|
def test_basename_preserved(self, tmp_path):
|
|
result = _materialise(tmp_path, "abc", "models/sub/structure.ifc", b"x")
|
|
assert result.name == "structure.ifc"
|
|
|
|
def test_not_rewritten_if_exists(self, tmp_path):
|
|
result = _materialise(tmp_path, "abc123", "model.ifc", b"original")
|
|
result.write_bytes(b"modified")
|
|
_materialise(tmp_path, "abc123", "model.ifc", b"original")
|
|
assert result.read_bytes() == b"modified"
|
|
|
|
|
|
class TestFetchIfcPath:
|
|
def test_returns_path_and_stale_flag(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
path, is_stale = fetch_ifc_path(url)
|
|
assert isinstance(path, Path)
|
|
assert is_stale is False
|
|
|
|
def test_file_exists_with_correct_content(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
path, _ = fetch_ifc_path(url)
|
|
assert path.exists()
|
|
assert path.read_bytes() == local_ifc_repo["bytes"]
|
|
|
|
def test_hexsha_in_path(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
path, _ = fetch_ifc_path(url)
|
|
assert local_ifc_repo["hexsha"] in str(path)
|
|
|
|
def test_basename_is_ifc_filename(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
path, _ = fetch_ifc_path(url)
|
|
assert path.name == "model.ifc"
|
|
|
|
def test_stable_across_calls(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
path1, _ = fetch_ifc_path(url)
|
|
path2, _ = fetch_ifc_path(url)
|
|
assert path1 == path2
|
|
|
|
def test_new_commit_produces_new_path(self, tmp_path):
|
|
import git as gitpkg
|
|
|
|
repo_dir = tmp_path / "repo"
|
|
repo_dir.mkdir()
|
|
ifc_bytes = b"IFC4\n"
|
|
(repo_dir / "model.ifc").write_bytes(ifc_bytes)
|
|
|
|
repo = gitpkg.Repo.init(str(repo_dir))
|
|
with repo.config_writer() as cw:
|
|
cw.set_value("user", "name", "Test")
|
|
cw.set_value("user", "email", "t@t.com")
|
|
repo.index.add(["model.ifc"])
|
|
repo.index.commit("first")
|
|
|
|
url = _local_url(str(repo_dir), ref="HEAD")
|
|
path1, _ = fetch_ifc_path(url)
|
|
|
|
(repo_dir / "model.ifc").write_bytes(b"IFC4\nUPDATED\n")
|
|
repo.index.add(["model.ifc"])
|
|
repo.index.commit("second")
|
|
|
|
path2, _ = fetch_ifc_path(url)
|
|
assert path1 != path2
|
|
assert path2.read_bytes() == b"IFC4\nUPDATED\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_genesis_commit — project identity key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
import hashlib
|
|
|
|
from ifcurl.git import get_genesis_commit, _compute_genesis
|
|
|
|
|
|
def _make_repo(path: Path, filename: str = "model.ifc") -> "git.Repo":
|
|
"""Create a minimal git repo with one commit in *path*."""
|
|
import git as gitpkg
|
|
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
(path / filename).write_bytes(b"IFC4\n")
|
|
repo = gitpkg.Repo.init(str(path))
|
|
with repo.config_writer() as cw:
|
|
cw.set_value("user", "name", "Test")
|
|
cw.set_value("user", "email", "t@t.com")
|
|
repo.index.add([filename])
|
|
repo.index.commit("init")
|
|
return repo
|
|
|
|
|
|
class TestComputeGenesis:
|
|
def test_returns_40_hex_chars(self, local_ifc_repo):
|
|
import git as gitpkg
|
|
|
|
repo = gitpkg.Repo(local_ifc_repo["path"])
|
|
hexsha = _compute_genesis(repo)
|
|
assert len(hexsha) == 40
|
|
assert all(c in "0123456789abcdef" for c in hexsha)
|
|
|
|
def test_single_commit_repo_genesis_is_head(self, local_ifc_repo):
|
|
import git as gitpkg
|
|
|
|
repo = gitpkg.Repo(local_ifc_repo["path"])
|
|
assert _compute_genesis(repo) == local_ifc_repo["hexsha"]
|
|
|
|
def test_genesis_unchanged_after_more_commits(self, tmp_path):
|
|
import git as gitpkg
|
|
|
|
repo = _make_repo(tmp_path / "repo")
|
|
genesis_before = _compute_genesis(repo)
|
|
|
|
(tmp_path / "repo" / "model.ifc").write_bytes(b"IFC4\nV2\n")
|
|
repo.index.add(["model.ifc"])
|
|
repo.index.commit("second")
|
|
|
|
assert _compute_genesis(repo) == genesis_before
|
|
|
|
def test_cloned_repo_same_genesis(self, tmp_path, local_ifc_repo):
|
|
import git as gitpkg
|
|
|
|
original = gitpkg.Repo(local_ifc_repo["path"])
|
|
fork = gitpkg.Repo.clone_from(local_ifc_repo["path"], str(tmp_path / "fork"))
|
|
assert _compute_genesis(fork) == _compute_genesis(original)
|
|
|
|
def test_unrelated_repos_differ(self, tmp_path, local_ifc_repo):
|
|
import git as gitpkg
|
|
|
|
unrelated = _make_repo(tmp_path / "other")
|
|
original = gitpkg.Repo(local_ifc_repo["path"])
|
|
assert _compute_genesis(unrelated) != _compute_genesis(original)
|
|
|
|
|
|
class TestGetGenesisCommit:
|
|
def test_local_repo(self, local_ifc_repo):
|
|
url = _local_url(local_ifc_repo["path"])
|
|
genesis = get_genesis_commit(url)
|
|
assert genesis == local_ifc_repo["hexsha"]
|
|
|
|
def test_forks_share_genesis(self, tmp_path, local_ifc_repo):
|
|
import git as gitpkg
|
|
|
|
gitpkg.Repo.clone_from(local_ifc_repo["path"], str(tmp_path / "fork"))
|
|
url_orig = _local_url(local_ifc_repo["path"])
|
|
url_fork = _local_url(str(tmp_path / "fork"))
|
|
assert get_genesis_commit(url_orig) == get_genesis_commit(url_fork)
|
|
|
|
def test_genesis_cached_for_remote_url(self, tmp_path, local_ifc_repo, monkeypatch):
|
|
import git as gitpkg
|
|
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
|
|
# Pre-populate bare cache as _open_remote would
|
|
remote_url = "https://example.com/org/repo"
|
|
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24]
|
|
cache_dir = tmp_path / url_hash
|
|
git_dir = cache_dir / "repo.git"
|
|
gitpkg.Repo.clone_from(local_ifc_repo["path"], str(git_dir), bare=True)
|
|
(cache_dir / "remote_url").write_text(remote_url)
|
|
|
|
url = IfcUrl.parse(f"ifc://example.com/org/repo@{local_ifc_repo['hexsha']}?path=model.ifc")
|
|
genesis = get_genesis_commit(url)
|
|
|
|
genesis_file = cache_dir / "genesis_commit"
|
|
assert genesis_file.exists()
|
|
assert genesis_file.read_text().strip() == genesis
|
|
|
|
def test_cache_reused_on_second_call(self, tmp_path, local_ifc_repo, monkeypatch):
|
|
import git as gitpkg
|
|
|
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
|
|
|
remote_url = "https://example.com/org/repo2"
|
|
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24]
|
|
cache_dir = tmp_path / url_hash
|
|
git_dir = cache_dir / "repo.git"
|
|
gitpkg.Repo.clone_from(local_ifc_repo["path"], str(git_dir), bare=True)
|
|
(cache_dir / "remote_url").write_text(remote_url)
|
|
|
|
url = IfcUrl.parse(f"ifc://example.com/org/repo2@{local_ifc_repo['hexsha']}?path=model.ifc")
|
|
genesis1 = get_genesis_commit(url)
|
|
|
|
# Overwrite the cache file with a sentinel — second call must return it
|
|
sentinel = "a" * 40
|
|
(cache_dir / "genesis_commit").write_text(sentinel)
|
|
genesis2 = get_genesis_commit(url)
|
|
|
|
assert genesis2 == sentinel # read from cache, not recomputed
|