diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c47a1e8..7fe4424 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -71,7 +71,7 @@ {"id":"ifcurl-pq6","title":"Document OpenCDE Foundation API and document picker in SPECIFICATION.md","description":"The OpenCDE Foundation API 1.1 OAuth2 proxy routes (foundation_api.py) and document picker select-documents flow (documents_api.py) are implemented but not described in SPECIFICATION.md or the CLAUDE.md browser test checklist. Update SPECIFICATION.md §9 or add a new section covering: the OAuth2 setup (registering Forgejo app, env vars IFCURL_OAUTH2_CLIENT_ID/SECRET), the three /foundation/1.1/oauth2/ routes and their request/response shapes, the POST /documents/1.0/select-documents flow and the picker UI. Add a browser test checklist item to CLAUDE.md covering the end-to-end select-documents flow.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-07T11:21:26Z","created_by":"Bruno Postle","updated_at":"2026-06-07T14:52:56Z","started_at":"2026-06-07T14:19:39Z","closed_at":"2026-06-07T14:52:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-4zf","title":"Document recently-closed features in README or SPECIFICATION","description":"Several features have been implemented and closed in recent beads but are not reflected in user-facing documentation. These include: OpenCDE Foundation API 1.1 OAuth2 proxy routes (ifcurl-bil), OpenCDE document picker select-documents flow (ifcurl-xfi, in progress), BCF export rewrite as pure client-side (7c25e06), ifc:// OS protocol handler ifcurl-open/ifcurl-register (d975658), PR/commit diff view, BCF round-trip import, query panel, click-to-inspect, metadata panel, and storey isolation. README and/or SPECIFICATION should be updated to reflect what is now implemented vs planned.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-07T09:11:03Z","created_by":"Bruno Postle","updated_at":"2026-06-07T14:52:57Z","started_at":"2026-06-07T14:19:42Z","closed_at":"2026-06-07T14:52:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-bsw","title":"Socket IPC: dispatch ifc:// URLs to already-running viewer instance","description":"When a viewer (bonsai or ifcviewer) is running, ifcurl-open should send the URL to the existing instance rather than launching a new process. Protocol: on startup the viewer opens a Unix socket at ~/.cache/ifcurl/\u003cviewer-name\u003e.sock. ifcurl-open tries the socket first; if it connects it sends the URL as a newline-terminated string and exits. If no socket exists or connection fails, falls back to launching fresh. Bonsai and ifcviewer each need to implement the socket listener.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:06Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:34:25Z","closed_at":"2026-06-07T07:34:25Z","close_reason":"Descoped: ifcurl-open will always launch a new viewer instance. Socket IPC is complex, not idiomatic for Blender, and can be added later if there is demand. Instance reuse policy is left to the viewer application.","dependencies":[{"issue_id":"ifcurl-bsw","depends_on_id":"ifcurl-7db","type":"blocks","created_at":"2026-06-05T15:43:26Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-77u","title":"Ambiguity resolution: ask user when multiple local repos match same genesis commit","description":"When local repo discovery finds more than one local repo with the same genesis commit (user has two side-by-side clones of the same project, by mistake or intent), there is no reliable heuristic for choosing. The system should ask the user once, then cache the choice keyed by genesis commit in ~/.config/ifcurl/config.toml under [project_origins]. The user can also set false to mark a project as read-only (no local push target).","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:56Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:56Z","dependencies":[{"issue_id":"ifcurl-77u","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:16Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-77u","title":"Ambiguity resolution: ask user when multiple local repos match same genesis commit","description":"When local repo discovery finds more than one local repo with the same genesis commit (user has two side-by-side clones of the same project, by mistake or intent), there is no reliable heuristic for choosing. The system should ask the user once, then cache the choice keyed by genesis commit in ~/.config/ifcurl/config.toml under [project_origins]. The user can also set false to mark a project as read-only (no local push target).","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:56Z","created_by":"Bruno Postle","updated_at":"2026-06-07T20:02:26Z","started_at":"2026-06-07T17:16:02Z","closed_at":"2026-06-07T20:02:26Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-77u","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:16Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-987","title":"UI: no way to remove clipping planes without editing the URL","description":"Once clip= parameters have been added to the ifc:// URL (e.g. via BCF import or manual URL edit), there is no UI control to remove them. The only workaround is to manually edit the URL in the toolbar and delete the clip= parameters. The viewer should provide a button or control to clear all active clipping planes.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-17T23:30:53Z","created_by":"Bruno Postle","updated_at":"2026-05-17T23:34:40Z","closed_at":"2026-05-17T23:34:40Z","close_reason":"Button to remove clipping planes already exists in the viewer","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-2dj","title":"Clearance representations visible as backfaces despite hide logic","description":"There is code to hide Clearance representations of elements, but they remain visible as backfaces — the back face of the clearance geometry is rendered through the model. The hide logic is not suppressing backface rendering, so the representation bleeds through.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T08:45:41Z","created_by":"Bruno Postle","updated_at":"2026-05-17T09:55:26Z","started_at":"2026-05-17T09:36:23Z","closed_at":"2026-05-17T09:55:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-yhp","title":"Query panel: clicking an element in the results list should highlight it in the 3D view","description":"When a selector query is active, the query panel lists matched elements. Clicking a row in this list should highlight (or isolate) that specific element in the 3D canvas, so the user can locate it spatially. This mirrors the click-to-inspect flow but driven from the panel rather than the canvas.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-05-17T08:45:31Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:46:10Z","closed_at":"2026-06-07T07:46:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -143,6 +143,6 @@ {"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."} {"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."} {"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} -{"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."} diff --git a/ifcurl/checkout.py b/ifcurl/checkout.py index fce5b3f..a5b8a92 100644 --- a/ifcurl/checkout.py +++ b/ifcurl/checkout.py @@ -66,7 +66,7 @@ try: except ImportError: _HAS_GITPYTHON = False -from ifcurl.discover import find_local_repos +from ifcurl.discover import select_local_repo from ifcurl.git import _cache_dir_for, _compute_genesis, fetch_ifc from ifcurl.url import IfcUrl @@ -179,13 +179,13 @@ def _remap_origin(checkout_dir: Path, cache_dir: Path) -> None: bare_repo = git.Repo(str(cache_dir / "repo.git")) genesis = _compute_genesis(bare_repo) - local_repos = find_local_repos(genesis) - if not local_repos: + local_repo = select_local_repo(genesis) + if local_repo is None: return - _logger.debug("Remapping checkout origin → %s, upstream → bare cache", local_repos[0]) + _logger.debug("Remapping checkout origin → %s, upstream → bare cache", local_repo) repo.git.remote("rename", "origin", "upstream") - repo.git.remote("add", "origin", str(local_repos[0])) + repo.git.remote("add", "origin", str(local_repo)) def _ensure_viewer_excludes(checkout_dir: Path) -> None: diff --git a/ifcurl/discover.py b/ifcurl/discover.py index 72647b6..ff962d7 100644 --- a/ifcurl/discover.py +++ b/ifcurl/discover.py @@ -41,7 +41,7 @@ import json import logging import os import time -from collections.abc import Iterator +from collections.abc import Callable, Iterator from pathlib import Path try: @@ -86,6 +86,49 @@ def find_local_repos(genesis_commit: str) -> list[Path]: return matches +def select_local_repo( + genesis_commit: str, + prompt_fn: Callable[[list[Path]], Path | None] | None = None, +) -> Path | None: + """Return the user-preferred local repo for this genesis commit, or None. + + Checks ``[project_origins]`` in ``config.toml`` first. If a path is + cached and still exists, returns it. If configured as ``false``, returns + None (read-only project, no push target). If not cached: + + * 0 repos found → ``None`` + * 1 repo found → cache it automatically and return it + * 2+ repos found → call *prompt_fn* (or the default stdin prompt), + cache the chosen path, return it + + :param genesis_commit: 40-character root commit hexsha. + :param prompt_fn: Override the interactive prompt. Receives the list of + candidate :class:`~pathlib.Path` objects and returns the chosen one, + or ``None`` to skip. Defaults to an interactive stdin prompt. + """ + cached = _get_project_origin(genesis_commit) + if cached is False: + return None + if cached is not None: + if cached.is_dir(): + return cached + _logger.debug("Cached project origin %s no longer exists; rescanning", cached) + _cache_file(genesis_commit).unlink(missing_ok=True) + + candidates = find_local_repos(genesis_commit) + if not candidates: + return None + if len(candidates) == 1: + _save_project_origin(genesis_commit, candidates[0]) + return candidates[0] + + fn = prompt_fn if prompt_fn is not None else _prompt_user + choice = fn(candidates) + if choice is not None: + _save_project_origin(genesis_commit, choice) + return choice + + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- @@ -104,6 +147,83 @@ def _load_config() -> dict: return {} +def _get_project_origin(genesis_commit: str) -> Path | bool | None: + """Return the configured origin for *genesis_commit* from ``config.toml``. + + :returns: ``False`` if configured as read-only, a :class:`~pathlib.Path` + if a path is saved, or ``None`` if not configured. + """ + val = _load_config().get("project_origins", {}).get(genesis_commit) + if val is None: + return None + if val is False: + return False + return Path(val) + + +def _save_project_origin(genesis_commit: str, path: Path) -> None: + """Persist *path* as the push origin for *genesis_commit* in ``config.toml``.""" + config_file = Path(user_config_dir("ifcurl")) / "config.toml" + config = _load_config() + config.setdefault("project_origins", {})[genesis_commit] = str(path) + config_file.parent.mkdir(parents=True, exist_ok=True) + try: + config_file.write_text(_dump_config(config)) + except OSError as exc: + _logger.warning("Could not save project origin to config: %s", exc) + + +def _dump_config(config: dict) -> str: + """Serialise *config* to TOML (handles our schema: string lists and string/bool leaf values).""" + out: list[str] = [] + for section, body in config.items(): + if out: + out.append("") + out.append(f"[{section}]") + for key, val in body.items(): + if isinstance(val, bool): + out.append(f"{key} = {str(val).lower()}") + elif isinstance(val, list): + items = ", ".join(f'"{v}"' for v in val) + out.append(f"{key} = [{items}]") + else: + escaped = str(val).replace("\\", "\\\\").replace('"', '\\"') + out.append(f'{key} = "{escaped}"') + return "\n".join(out) + "\n" + + +def _prompt_user(candidates: list[Path]) -> Path | None: + """Interactive stdin prompt when multiple local repos share the same genesis commit.""" + import sys + + if not sys.stdin.isatty(): + _logger.warning( + "Multiple local repos found for this project but stdin is not a TTY; " + "skipping origin remap. Set [project_origins] in ~/.config/ifcurl/config.toml to configure." + ) + return None + + print( + "\nMultiple local repositories match this project. " + "Choose one to use as the push target (or 0 to skip):", + file=sys.stderr, + ) + for i, p in enumerate(candidates, 1): + print(f" {i}. {p}", file=sys.stderr) + + while True: + try: + raw = input("Choice [0]: ").strip() or "0" + n = int(raw) + except (ValueError, EOFError, KeyboardInterrupt): + return None + if n == 0: + return None + if 1 <= n <= len(candidates): + return candidates[n - 1] + print(f" Please enter a number between 0 and {len(candidates)}.", file=sys.stderr) + + def _get_search_paths() -> list[Path]: """Return expanded search paths from config, skipping non-existent ones.""" config = _load_config() diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 1e3277b..78ba111 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -288,7 +288,7 @@ class TestRemapOrigin: def test_no_local_repo_origin_unchanged(self, tmp_path, monkeypatch): import git as gitpkg - monkeypatch.setattr("ifcurl.checkout.find_local_repos", lambda _: []) + monkeypatch.setattr("ifcurl.checkout.select_local_repo", lambda _: None) checkout_dir, cache_dir, _, _ = self._make_checkout_with_cache(tmp_path) _remap_origin(checkout_dir, cache_dir) repo = gitpkg.Repo(str(checkout_dir)) @@ -302,7 +302,7 @@ class TestRemapOrigin: local_repo = tmp_path / "local_work" gitpkg.Repo.clone_from(str(src), str(local_repo)) - monkeypatch.setattr("ifcurl.checkout.find_local_repos", lambda _: [local_repo]) + monkeypatch.setattr("ifcurl.checkout.select_local_repo", lambda _: local_repo) _remap_origin(checkout_dir, cache_dir) repo = gitpkg.Repo(str(checkout_dir)) @@ -319,7 +319,7 @@ class TestRemapOrigin: local_repo = tmp_path / "local_work" gitpkg.Repo.clone_from(str(src), str(local_repo)) - monkeypatch.setattr("ifcurl.checkout.find_local_repos", lambda _: [local_repo]) + monkeypatch.setattr("ifcurl.checkout.select_local_repo", lambda _: local_repo) _remap_origin(checkout_dir, cache_dir) repo = gitpkg.Repo(str(checkout_dir)) @@ -334,7 +334,7 @@ class TestRemapOrigin: local_repo = tmp_path / "local_work" gitpkg.Repo.clone_from(str(src), str(local_repo)) - monkeypatch.setattr("ifcurl.checkout.find_local_repos", lambda _: [local_repo]) + monkeypatch.setattr("ifcurl.checkout.select_local_repo", lambda _: local_repo) _remap_origin(checkout_dir, cache_dir) _remap_origin(checkout_dir, cache_dir) # second call must not error @@ -352,7 +352,7 @@ class TestRemapOrigin: local_repo = tmp_path / "local_work" gitpkg.Repo.clone_from(str(src), str(local_repo)) - monkeypatch.setattr("ifcurl.checkout.find_local_repos", lambda _: [local_repo]) + monkeypatch.setattr("ifcurl.checkout.select_local_repo", lambda _: local_repo) _remap_origin(checkout_dir, cache_dir) repo = gitpkg.Repo(str(checkout_dir)) diff --git a/tests/test_discover.py b/tests/test_discover.py index cffc3ed..abb3897 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -10,12 +10,16 @@ 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, ) @@ -256,3 +260,176 @@ class TestFindLocalRepos: 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