"""Tests for ifcurl.discover — local git repository discovery.""" from __future__ import annotations import json import time from pathlib import Path import pytest from ifcurl.discover import ( _cache_file, _dump_config, _genesis_for_local, _get_project_origin, _get_search_paths, _load_cache, _save_cache, _save_project_origin, _scan_for_git_repos, find_local_repos, select_local_repo, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_repo(path: Path, filename: str = "model.ifc") -> str: """Init a git repo at *path* with one commit; return genesis hexsha.""" 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]) commit = repo.index.commit("init") return commit.hexsha def _write_config(config_dir: Path, paths: list[str]) -> None: config_dir.mkdir(parents=True, exist_ok=True) (config_dir / "config.toml").write_text( "[repo_search]\npaths = [" + ", ".join(f'"{p}"' for p in paths) + "]\n" ) # --------------------------------------------------------------------------- # _scan_for_git_repos # --------------------------------------------------------------------------- class TestScanForGitRepos: def test_finds_repo_at_top_level(self, tmp_path): _make_repo(tmp_path / "proj") found = list(_scan_for_git_repos(tmp_path)) assert tmp_path / "proj" in found def test_finds_nested_repo(self, tmp_path): _make_repo(tmp_path / "client" / "proj") found = list(_scan_for_git_repos(tmp_path)) assert tmp_path / "client" / "proj" in found def test_does_not_descend_into_sub_repo(self, tmp_path): _make_repo(tmp_path / "outer") _make_repo(tmp_path / "outer" / "inner") found = list(_scan_for_git_repos(tmp_path)) assert tmp_path / "outer" in found assert tmp_path / "outer" / "inner" not in found def test_skips_hidden_directories(self, tmp_path): _make_repo(tmp_path / ".hidden" / "proj") found = list(_scan_for_git_repos(tmp_path)) assert not any(".hidden" in str(p) for p in found) def test_empty_search_path_yields_nothing(self, tmp_path): assert list(_scan_for_git_repos(tmp_path)) == [] def test_nonexistent_path_yields_nothing(self, tmp_path): assert list(_scan_for_git_repos(tmp_path / "no_such_dir")) == [] def test_plain_directory_not_yielded(self, tmp_path): (tmp_path / "not_a_repo").mkdir() assert list(_scan_for_git_repos(tmp_path)) == [] def test_multiple_repos_all_found(self, tmp_path): _make_repo(tmp_path / "a") _make_repo(tmp_path / "b") found = list(_scan_for_git_repos(tmp_path)) assert tmp_path / "a" in found assert tmp_path / "b" in found # --------------------------------------------------------------------------- # _genesis_for_local # --------------------------------------------------------------------------- class TestGenesisForLocal: def test_returns_hexsha(self, tmp_path): hexsha = _make_repo(tmp_path / "repo") result = _genesis_for_local(tmp_path / "repo") assert result == hexsha def test_returns_none_for_non_repo(self, tmp_path): (tmp_path / "not_a_repo").mkdir() assert _genesis_for_local(tmp_path / "not_a_repo") is None def test_returns_none_for_nonexistent_path(self, tmp_path): assert _genesis_for_local(tmp_path / "missing") is None # --------------------------------------------------------------------------- # _get_search_paths # --------------------------------------------------------------------------- class TestGetSearchPaths: def test_returns_configured_paths(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path)) search_dir = tmp_path / "projects" search_dir.mkdir() _write_config(tmp_path, [str(search_dir)]) paths = _get_search_paths() assert search_dir in paths def test_skips_nonexistent_paths(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path)) _write_config(tmp_path, [str(tmp_path / "does_not_exist")]) assert _get_search_paths() == [] def test_no_config_returns_empty(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path)) assert _get_search_paths() == [] # --------------------------------------------------------------------------- # Cache helpers # --------------------------------------------------------------------------- class TestCache: def test_save_and_load(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path)) paths = [Path("/home/user/proj"), Path("/home/user/other")] _save_cache("abc123", paths) result = _load_cache("abc123") assert result == paths def test_missing_cache_returns_none(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path)) assert _load_cache("deadbeef" * 5) is None def test_expired_cache_returns_none(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path)) cf = _cache_file("abc123") cf.parent.mkdir(parents=True, exist_ok=True) cf.write_text(json.dumps({"timestamp": time.time() - 7200, "paths": []})) assert _load_cache("abc123") is None def test_fresh_cache_returned(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path)) _save_cache("abc123", [Path("/some/repo")]) assert _load_cache("abc123") == [Path("/some/repo")] def test_corrupt_cache_returns_none(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path)) cf = _cache_file("abc123") cf.parent.mkdir(parents=True, exist_ok=True) cf.write_text("not json {{{") assert _load_cache("abc123") is None # --------------------------------------------------------------------------- # find_local_repos # --------------------------------------------------------------------------- class TestFindLocalRepos: def _setup(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config")) monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache")) search_dir = tmp_path / "projects" search_dir.mkdir() _write_config(tmp_path / "config", [str(search_dir)]) return search_dir def test_finds_matching_repo(self, tmp_path, monkeypatch): search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(search_dir / "building") result = find_local_repos(genesis) assert search_dir / "building" in result def test_ignores_unrelated_repo(self, tmp_path, monkeypatch): import git as gitpkg search_dir = self._setup(tmp_path, monkeypatch) # Give each repo distinct content so their genesis hashes differ _make_repo(search_dir / "building") other = search_dir / "other" other.mkdir() (other / "model.ifc").write_bytes(b"IFC4\nDISTINCT CONTENT\n") repo = gitpkg.Repo.init(str(other)) 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"]) unrelated_genesis = repo.index.commit("init other").hexsha result = find_local_repos(unrelated_genesis) assert search_dir / "building" not in result assert search_dir / "other" in result def test_finds_fork_by_genesis(self, tmp_path, monkeypatch): import git as gitpkg search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(tmp_path / "upstream") gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "myfork")) result = find_local_repos(genesis) assert search_dir / "myfork" in result def test_no_search_paths_returns_empty(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config")) monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache")) assert find_local_repos("a" * 40) == [] def test_result_is_cached(self, tmp_path, monkeypatch): search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(search_dir / "proj") find_local_repos(genesis) # populate cache # Remove the actual repo — second call must still return a result from cache import shutil shutil.rmtree(str(search_dir / "proj")) result = find_local_repos(genesis) assert search_dir / "proj" in result def test_stale_cache_rescans(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config")) monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache")) monkeypatch.setattr("ifcurl.discover._CACHE_TTL", 0) search_dir = tmp_path / "projects" search_dir.mkdir() _write_config(tmp_path / "config", [str(search_dir)]) genesis = _make_repo(search_dir / "proj") # Write a stale cache that claims no repos cf = _cache_file(genesis) cf.parent.mkdir(parents=True, exist_ok=True) cf.write_text(json.dumps({"timestamp": 0, "paths": []})) result = find_local_repos(genesis) assert search_dir / "proj" in result # --------------------------------------------------------------------------- # _dump_config # --------------------------------------------------------------------------- class TestDumpConfig: def test_string_value(self): out = _dump_config({"sec": {"key": "val"}}) assert '[sec]\nkey = "val"' in out def test_bool_false(self): out = _dump_config({"sec": {"key": False}}) assert "key = false" in out def test_bool_true(self): out = _dump_config({"sec": {"key": True}}) assert "key = true" in out def test_list_value(self): out = _dump_config({"sec": {"paths": ["/a", "/b"]}}) assert 'paths = ["/a", "/b"]' in out def test_multiple_sections(self): out = _dump_config({"a": {"x": "1"}, "b": {"y": "2"}}) assert "[a]" in out assert "[b]" in out def test_roundtrip_via_tomllib(self): try: import tomllib except ImportError: import tomli as tomllib cfg = {"repo_search": {"paths": ["/home/user/proj"]}, "project_origins": {"abc": "/repo"}} out = _dump_config(cfg) parsed = tomllib.loads(out) assert parsed == cfg # --------------------------------------------------------------------------- # _get_project_origin / _save_project_origin # --------------------------------------------------------------------------- class TestProjectOriginConfig: def _setup(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path)) monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache")) def test_missing_returns_none(self, tmp_path, monkeypatch): self._setup(tmp_path, monkeypatch) assert _get_project_origin("a" * 40) is None def test_save_and_read_back(self, tmp_path, monkeypatch): self._setup(tmp_path, monkeypatch) path = tmp_path / "myrepo" _save_project_origin("a" * 40, path) assert _get_project_origin("a" * 40) == path def test_false_returns_false(self, tmp_path, monkeypatch): self._setup(tmp_path, monkeypatch) config_dir = tmp_path config_dir.mkdir(parents=True, exist_ok=True) genesis = "b" * 40 (config_dir / "config.toml").write_text(f'[project_origins]\n{genesis} = false\n') result = _get_project_origin(genesis) assert result is False def test_save_preserves_existing_sections(self, tmp_path, monkeypatch): self._setup(tmp_path, monkeypatch) search_dir = tmp_path / "projects" search_dir.mkdir() _write_config(tmp_path, [str(search_dir)]) _save_project_origin("c" * 40, tmp_path / "repo") try: import tomllib except ImportError: import tomli as tomllib cfg = tomllib.loads((tmp_path / "config.toml").read_text()) assert "repo_search" in cfg assert "project_origins" in cfg # --------------------------------------------------------------------------- # select_local_repo # --------------------------------------------------------------------------- class TestSelectLocalRepo: def _setup(self, tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config")) monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache")) search_dir = tmp_path / "projects" search_dir.mkdir() _write_config(tmp_path / "config", [str(search_dir)]) return search_dir def test_no_candidates_returns_none(self, tmp_path, monkeypatch): self._setup(tmp_path, monkeypatch) result = select_local_repo("a" * 40) assert result is None def test_single_candidate_returned_and_cached(self, tmp_path, monkeypatch): search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(search_dir / "building") result = select_local_repo(genesis) assert result == search_dir / "building" assert _get_project_origin(genesis) == search_dir / "building" def test_cached_path_gone_rescans(self, tmp_path, monkeypatch): search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(search_dir / "building") select_local_repo(genesis) # caches it # Remove the repo — cached path is stale, rescan finds nothing import shutil shutil.rmtree(str(search_dir / "building")) result = select_local_repo(genesis) assert result is None def test_false_in_config_returns_none(self, tmp_path, monkeypatch): search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(search_dir / "building") config_dir = tmp_path / "config" config_path = config_dir / "config.toml" existing = config_path.read_text() config_path.write_text(existing + f'\n[project_origins]\n{genesis} = false\n') result = select_local_repo(genesis) assert result is None def test_multiple_candidates_calls_prompt_fn(self, tmp_path, monkeypatch): import git as gitpkg search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(tmp_path / "upstream") gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "clone1")) gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "clone2")) chosen = search_dir / "clone2" called_with: list[list[Path]] = [] def fake_prompt(candidates): called_with.append(candidates) return chosen result = select_local_repo(genesis, prompt_fn=fake_prompt) assert result == chosen assert len(called_with) == 1 assert len(called_with[0]) == 2 def test_prompt_none_returns_none(self, tmp_path, monkeypatch): import git as gitpkg search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(tmp_path / "upstream") gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "clone1")) gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "clone2")) result = select_local_repo(genesis, prompt_fn=lambda _: None) assert result is None def test_prompt_choice_is_cached(self, tmp_path, monkeypatch): import git as gitpkg search_dir = self._setup(tmp_path, monkeypatch) genesis = _make_repo(tmp_path / "upstream") gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "clone1")) gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "clone2")) chosen = search_dir / "clone1" select_local_repo(genesis, prompt_fn=lambda _: chosen) assert _get_project_origin(genesis) == chosen