Ambiguity resolution: prompt when multiple local repos match genesis

select_local_repo() replaces direct find_local_repos() calls in
_remap_origin.  It reads/writes [project_origins] in config.toml:
a cached path is returned immediately; false marks a project read-only;
a single match is auto-cached without prompting; multiple matches call
a prompt_fn (default: interactive stdin) and cache the choice.  Stale
config paths bust the discovery cache so the next call rescans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-07 21:03:13 +01:00
parent 4d4a76ea34
commit fe0025ebd6
5 changed files with 310 additions and 13 deletions

View file

@ -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-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-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-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-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-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} {"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-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-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} {"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":"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":"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."}

View file

@ -66,7 +66,7 @@ try:
except ImportError: except ImportError:
_HAS_GITPYTHON = False _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.git import _cache_dir_for, _compute_genesis, fetch_ifc
from ifcurl.url import IfcUrl 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")) bare_repo = git.Repo(str(cache_dir / "repo.git"))
genesis = _compute_genesis(bare_repo) genesis = _compute_genesis(bare_repo)
local_repos = find_local_repos(genesis) local_repo = select_local_repo(genesis)
if not local_repos: if local_repo is None:
return 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("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: def _ensure_viewer_excludes(checkout_dir: Path) -> None:

View file

@ -41,7 +41,7 @@ import json
import logging import logging
import os import os
import time import time
from collections.abc import Iterator from collections.abc import Callable, Iterator
from pathlib import Path from pathlib import Path
try: try:
@ -86,6 +86,49 @@ def find_local_repos(genesis_commit: str) -> list[Path]:
return matches 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 # Configuration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -104,6 +147,83 @@ def _load_config() -> dict:
return {} 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]: def _get_search_paths() -> list[Path]:
"""Return expanded search paths from config, skipping non-existent ones.""" """Return expanded search paths from config, skipping non-existent ones."""
config = _load_config() config = _load_config()

View file

@ -288,7 +288,7 @@ class TestRemapOrigin:
def test_no_local_repo_origin_unchanged(self, tmp_path, monkeypatch): def test_no_local_repo_origin_unchanged(self, tmp_path, monkeypatch):
import git as gitpkg 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) checkout_dir, cache_dir, _, _ = self._make_checkout_with_cache(tmp_path)
_remap_origin(checkout_dir, cache_dir) _remap_origin(checkout_dir, cache_dir)
repo = gitpkg.Repo(str(checkout_dir)) repo = gitpkg.Repo(str(checkout_dir))
@ -302,7 +302,7 @@ class TestRemapOrigin:
local_repo = tmp_path / "local_work" local_repo = tmp_path / "local_work"
gitpkg.Repo.clone_from(str(src), str(local_repo)) 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)
repo = gitpkg.Repo(str(checkout_dir)) repo = gitpkg.Repo(str(checkout_dir))
@ -319,7 +319,7 @@ class TestRemapOrigin:
local_repo = tmp_path / "local_work" local_repo = tmp_path / "local_work"
gitpkg.Repo.clone_from(str(src), str(local_repo)) 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)
repo = gitpkg.Repo(str(checkout_dir)) repo = gitpkg.Repo(str(checkout_dir))
@ -334,7 +334,7 @@ class TestRemapOrigin:
local_repo = tmp_path / "local_work" local_repo = tmp_path / "local_work"
gitpkg.Repo.clone_from(str(src), str(local_repo)) 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)
_remap_origin(checkout_dir, cache_dir) # second call must not error _remap_origin(checkout_dir, cache_dir) # second call must not error
@ -352,7 +352,7 @@ class TestRemapOrigin:
local_repo = tmp_path / "local_work" local_repo = tmp_path / "local_work"
gitpkg.Repo.clone_from(str(src), str(local_repo)) 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)
repo = gitpkg.Repo(str(checkout_dir)) repo = gitpkg.Repo(str(checkout_dir))

View file

@ -10,12 +10,16 @@ import pytest
from ifcurl.discover import ( from ifcurl.discover import (
_cache_file, _cache_file,
_dump_config,
_genesis_for_local, _genesis_for_local,
_get_project_origin,
_get_search_paths, _get_search_paths,
_load_cache, _load_cache,
_save_cache, _save_cache,
_save_project_origin,
_scan_for_git_repos, _scan_for_git_repos,
find_local_repos, find_local_repos,
select_local_repo,
) )
@ -256,3 +260,176 @@ class TestFindLocalRepos:
result = find_local_repos(genesis) result = find_local_repos(genesis)
assert search_dir / "proj" in result 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