From 4d4a76ea34ed85954ac3c6148ca0c85f79ceb31c Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 7 Jun 2026 18:11:47 +0100 Subject: [PATCH] Remap checkout origin to local repo when discovered After creating a working checkout, if find_local_repos() finds a local clone or fork of the same project, rename 'origin' (bare cache) to 'upstream' and add a new 'origin' pointing at the local repo. Mirrors the standard fork workflow: fetch from upstream, push to origin (local repo), then push to the shared remote and open a PR. If no local repo is found, origin stays as the bare cache (read-only). Idempotent. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 4 +- ifcurl/checkout.py | 38 +++++++++++++- tests/test_checkout.py | 111 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 01de97a..c47a1e8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -13,7 +13,7 @@ {"id":"ifcurl-7db","title":"ifcurl-open: OS protocol handler dispatcher for ifc:// URLs","description":"A small ifcurl-open executable that acts as the registered OS-level ifc:// protocol handler. Receives a raw ifc:// URL from the OS (e.g. launched by Firefox), reads user preference from ~/.config/ifcurl/config.toml ([handler] default_viewer = bonsai|ifcviewer), and forwards the raw URL to the chosen viewer as a CLI argument.\n\nifcurl-open does NOT pre-fetch or resolve the URL — that is the viewer's responsibility via the ifcurl Python library. The viewer calls ifcurl.resolve(url) → local path, then applies camera/selection from the parsed URL.\n\nifcurl-open always launches a new viewer instance. Instance reuse is the viewer's own concern and is not handled here.\n\nAdd ifcurl-open as a pyproject.toml entry point.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:02Z","created_by":"Bruno Postle","updated_at":"2026-06-07T08:33:09Z","started_at":"2026-06-07T07:50:01Z","closed_at":"2026-06-07T08:33:09Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-7db","depends_on_id":"ifcurl-coh","type":"blocks","created_at":"2026-06-05T15:43:24Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-bq6","title":"connector.json manifest and entry point for viewer discovery","description":"Ship a connector.json manifest alongside the ifcurl-connector executable so that ifcviewer and bonsai can discover the connector via their configured connector search paths. Format per CLOUD_SYNC_PROTOCOL.md: {id: ifcurl, name: IFC URL, version: ..., exec: /path/to/ifcurl-connector}. The manifest path should be resolvable relative to the installed package. Add ifcurl-connector as a pyproject.toml entry point.","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:00Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:00Z","dependencies":[{"issue_id":"ifcurl-bq6","depends_on_id":"ifcurl-242","type":"blocks","created_at":"2026-06-05T15:43:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-242","title":"connector.py: JSON-RPC connector implementing CLOUD_SYNC_PROTOCOL.md interface","description":"Implement the ifcurl connector as a JSON-RPC server per CLOUD_SYNC_PROTOCOL.md. Methods: download_models (resolve ifc:// source URLs to local file paths via working checkout), upload_model (receive a modified IFC file — the connector writes it into the working checkout, ready for the user to branch/commit/push with standard git tools). The connector is a thin JSON-RPC wrapper over checkout.py and git.py; all resolution logic stays in existing modules.","notes":"Protocol is still in flux on the IfcViewer side (as of 2026-06-07). Do not start implementation until the JSON-RPC interface stabilises. Key open questions:\n\n1. Built-in vs. JSON-RPC: CLOUD_SYNC_PROTOCOL.md describes 'ifcurl' as a built-in connector (like 'local') that bypasses JSON-RPC for resolving ifc:// URLs embedded in .ifcfed files. It is unclear whether ifcurl also needs a full JSON-RPC connector for the 'Open from Cloud' browse/save workflow, or whether the built-in path is sufficient.\n\n2. Scope of methods: The minimal set is download_models (ifc:// URL → local path via checkout.py) and upload_model (write modified IFC into working checkout). Whether open_ifcfed, download_ifcfed, upload_ifcfed, add_models are needed for the git/ifcurl use case is unclear.\n\n3. upload_model semantics: The connector writes the file into the working checkout — the user then branches, commits, and pushes with standard git tools. This is intentional: ifcurl does not handle the commit/push workflow, only the file placement. The correct remote configuration (ifcurl-oef) is what makes this useful.\n\n4. Transport: The protocol doc implies stdio JSON-RPC. Confirm with IfcViewer team before implementing.\n\n5. Connector discovery: ifcurl-bq6 (connector.json manifest) depends on this — the manifest format may also change.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:59Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:29:17Z","dependencies":[{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-5oo","type":"blocks","created_at":"2026-06-05T15:43:19Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-oef","type":"blocks","created_at":"2026-06-05T15:43:21Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} -{"id":"ifcurl-oef","title":"Remote origin remapping: set working checkout origin to user local repo","description":"When a working checkout is created for an ifc:// URL and a local repo has been identified (via discovery or config), set the checkout's origin remote to the local repo path and add an upstream remote pointing to the original ifc:// source URL. This mirrors standard fork workflow: fetch from upstream, push to origin (local repo), which the user then pushes to their remote and opens a PR. If no local repo is found, origin stays as the upstream (read-only without credentials).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:57Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:57Z","dependencies":[{"issue_id":"ifcurl-oef","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"id":"ifcurl-oef","title":"Remote origin remapping: set working checkout origin to user local repo","description":"When a working checkout is created for an ifc:// URL and a local repo has been identified (via discovery or config), set the checkout's origin remote to the local repo path and add an upstream remote pointing to the original ifc:// source URL. This mirrors standard fork workflow: fetch from upstream, push to origin (local repo), which the user then pushes to their remote and opens a PR. If no local repo is found, origin stays as the upstream (read-only without credentials).","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:57Z","created_by":"Bruno Postle","updated_at":"2026-06-07T16:24:09Z","started_at":"2026-06-07T15:26:57Z","closed_at":"2026-06-07T16:24:09Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-oef","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-egi","title":"Local repo discovery: scan configured paths for repos matching genesis commit","description":"When opening an ifc:// URL, scan user-configured search paths (e.g. ~/projects/) for local git repos whose genesis commit matches the project being opened. If a match is found, that local repo becomes the push target (origin) for the working checkout. Search paths are configured in ~/.config/ifcurl/config.toml under [repo_search] paths. Results are cached per genesis commit to avoid repeated scanning.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:54Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:05:37Z","started_at":"2026-06-05T22:00:19Z","closed_at":"2026-06-05T22:05:37Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-egi","depends_on_id":"ifcurl-4d4","type":"blocks","created_at":"2026-06-05T15:43:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:38:17Z","started_at":"2026-06-05T22:08:23Z","closed_at":"2026-06-05T22:38:17Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"ifcurl-4d4","title":"Genesis commit identity: compute and cache root commit hash as project key","description":"To identify that https and SSH forms of the same repo, and forks of the same project, are all 'the same project', we use the genesis commit (git rev-list --max-parents=0 HEAD) as a stable project identity key. Store the genesis commit hash alongside each bare cache clone. This key is used by local repo discovery and origin remapping.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:50Z","created_by":"Bruno Postle","updated_at":"2026-06-05T17:32:58Z","started_at":"2026-06-05T17:29:24Z","closed_at":"2026-06-05T17:32:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"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":"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."} -{"_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."} diff --git a/ifcurl/checkout.py b/ifcurl/checkout.py index 1839d1b..fce5b3f 100644 --- a/ifcurl/checkout.py +++ b/ifcurl/checkout.py @@ -66,7 +66,8 @@ try: except ImportError: _HAS_GITPYTHON = False -from ifcurl.git import _cache_dir_for, fetch_ifc +from ifcurl.discover import find_local_repos +from ifcurl.git import _cache_dir_for, _compute_genesis, fetch_ifc from ifcurl.url import IfcUrl @@ -105,6 +106,7 @@ def get_checkout(ifc_url: IfcUrl, token: str | None = None) -> Path: cache_dir = _cache_dir_for(ifc_url.git_remote_url()) checkout_dir = _ensure_checkout(cache_dir, hexsha) _ensure_viewer_excludes(checkout_dir) + _remap_origin(checkout_dir, cache_dir) return checkout_dir / ifc_url.path @@ -152,6 +154,40 @@ def _ensure_checkout(cache_dir: Path, hexsha: str) -> Path: return checkout_dir +def _remap_origin(checkout_dir: Path, cache_dir: Path) -> None: + """Remap checkout remotes when a local working repo is found. + + If a local repo sharing the same genesis commit is found via + :func:`ifcurl.discover.find_local_repos`, the checkout's ``origin`` + remote is renamed to ``upstream`` (pointing at the bare cache) and a new + ``origin`` remote is added pointing at the local repo. This mirrors the + standard fork workflow: fetch from ``upstream``, push to ``origin`` + (local repo), then push to the shared remote and open a PR. + + If no local repo is found, ``origin`` remains the bare cache (read-only + without credentials). The function is idempotent: it skips remapping if + the ``upstream`` remote already exists. + """ + repo = git.Repo(str(checkout_dir)) + if "upstream" in {r.name for r in repo.remotes}: + return + + genesis_file = cache_dir / "genesis_commit" + if genesis_file.exists(): + genesis = genesis_file.read_text().strip() + else: + bare_repo = git.Repo(str(cache_dir / "repo.git")) + genesis = _compute_genesis(bare_repo) + + local_repos = find_local_repos(genesis) + if not local_repos: + return + + _logger.debug("Remapping checkout origin → %s, upstream → bare cache", local_repos[0]) + repo.git.remote("rename", "origin", "upstream") + repo.git.remote("add", "origin", str(local_repos[0])) + + def _ensure_viewer_excludes(checkout_dir: Path) -> None: """Append missing viewer-artefact patterns to ``.git/info/exclude``. diff --git a/tests/test_checkout.py b/tests/test_checkout.py index 2686874..1e3277b 100644 --- a/tests/test_checkout.py +++ b/tests/test_checkout.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest -from ifcurl.checkout import _ensure_checkout, _ensure_viewer_excludes, _VIEWER_EXCLUDES, get_checkout +from ifcurl.checkout import _ensure_checkout, _ensure_viewer_excludes, _remap_origin, _VIEWER_EXCLUDES, get_checkout from ifcurl.url import IfcUrl @@ -248,3 +248,112 @@ class TestGetCheckout: url = IfcUrl.parse("ifc://example.com/org/repo@abc123def") with pytest.raises(ValueError, match="'path'"): get_checkout(url) + + +# --------------------------------------------------------------------------- +# _remap_origin +# --------------------------------------------------------------------------- + + +class TestRemapOrigin: + """Tests for origin-remapping when a local working repo is discovered.""" + + def _make_checkout_with_cache(self, tmp_path): + """Return (checkout_dir, cache_dir, genesis_hexsha, bare_dir).""" + import git as gitpkg + + src = tmp_path / "source" + src.mkdir() + (src / "model.ifc").write_bytes(b"IFC4\n") + repo = gitpkg.Repo.init(str(src)) + 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"]) + commit = repo.index.commit("init") + + cache_dir = tmp_path / "cache" + bare_dir = cache_dir / "repo.git" + gitpkg.Repo.clone_from(str(src), str(bare_dir), bare=True) + + checkout_dir = cache_dir / "checkout" + gitpkg.Repo.clone_from(str(bare_dir), str(checkout_dir)) + gitpkg.Repo(str(checkout_dir)).git.checkout("--detach", commit.hexsha) + + genesis = commit.hexsha # single-commit repo — genesis == head + (cache_dir / "genesis_commit").write_text(genesis) + + return checkout_dir, cache_dir, genesis, src + + def test_no_local_repo_origin_unchanged(self, tmp_path, monkeypatch): + import git as gitpkg + + monkeypatch.setattr("ifcurl.checkout.find_local_repos", lambda _: []) + checkout_dir, cache_dir, _, _ = self._make_checkout_with_cache(tmp_path) + _remap_origin(checkout_dir, cache_dir) + repo = gitpkg.Repo(str(checkout_dir)) + assert {r.name for r in repo.remotes} == {"origin"} + + def test_local_repo_found_origin_becomes_local(self, tmp_path, monkeypatch): + import git as gitpkg + + checkout_dir, cache_dir, genesis, src = self._make_checkout_with_cache(tmp_path) + + 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]) + _remap_origin(checkout_dir, cache_dir) + + repo = gitpkg.Repo(str(checkout_dir)) + remote_names = {r.name for r in repo.remotes} + assert "origin" in remote_names + assert "upstream" in remote_names + assert repo.remote("origin").url == str(local_repo) + + def test_upstream_is_bare_cache(self, tmp_path, monkeypatch): + import git as gitpkg + + checkout_dir, cache_dir, genesis, src = self._make_checkout_with_cache(tmp_path) + + 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]) + _remap_origin(checkout_dir, cache_dir) + + repo = gitpkg.Repo(str(checkout_dir)) + # upstream should point at the bare cache + assert str(cache_dir / "repo.git") in repo.remote("upstream").url + + def test_idempotent(self, tmp_path, monkeypatch): + import git as gitpkg + + checkout_dir, cache_dir, genesis, src = self._make_checkout_with_cache(tmp_path) + + 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]) + _remap_origin(checkout_dir, cache_dir) + _remap_origin(checkout_dir, cache_dir) # second call must not error + + repo = gitpkg.Repo(str(checkout_dir)) + remote_names = [r.name for r in repo.remotes] + assert remote_names.count("origin") == 1 + assert remote_names.count("upstream") == 1 + + def test_genesis_from_bare_repo_when_file_missing(self, tmp_path, monkeypatch): + import git as gitpkg + + checkout_dir, cache_dir, genesis, src = self._make_checkout_with_cache(tmp_path) + (cache_dir / "genesis_commit").unlink() # remove cached file + + 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]) + _remap_origin(checkout_dir, cache_dir) + + repo = gitpkg.Repo(str(checkout_dir)) + assert "upstream" in {r.name for r in repo.remotes}