From ae143b74b705b536f0ec114b219c62cd2cd3a235 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 5 Jun 2026 18:26:11 +0100 Subject: [PATCH] Add fetch_ifc_path: materialise IFC blob to stable per-commit cache path Implements ifcurl-i07. fetch_ifc_path() fetches the IFC file and writes it to ~/.cache/ifcurl///, returning the path alongside the is_stale flag. Content-addressed by commit hexsha, so the file is never rewritten once present. Works for both local and remote URLs. Underpins the connector and checkout layers (ifcurl-5oo). Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 11 ++++++ ifcurl/__init__.py | 4 +- ifcurl/git.py | 44 +++++++++++++++++++++ tests/test_git.py | 93 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8e7d01e..98ad276 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,6 +3,15 @@ {"id":"ifcurl-p60","title":"Viewer: no element click-to-identify — can't get GlobalId or properties","description":"There is no way to click on a mesh in the viewer and see which IFC element it represents. Reviewers can see geometry but cannot reference specific elements — they can't discover a GlobalId to put in a selector URL, can't see the element name/type/Psets, and can't say 'this specific wall' in a BCF or Forgejo comment. This breaks the collaboration workflow at its foundation: every useful feedback comment needs to identify specific elements. Need: click or hover on a surface to highlight it and show a properties panel (name, type, GlobalId, key Psets). The GlobalId should be copyable into the selector field or URL. The ThatOpen components library provides the tools for this (OBC.IfcRelationsIndexer, element property queries).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:57Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-coh","title":"ifcurl-register: register ifc:// OS protocol handler on Linux, macOS, Windows","description":"A ifcurl-register command that installs ifcurl-open as the OS-level ifc:// protocol handler. Linux: write ~/.local/share/applications/ifc-url-handler.desktop and run xdg-mime default. macOS: install a minimal app bundle with CFBundleURLTypes in Info.plist. Windows: write registry key under HKEY_CLASSES_ROOT\\ifc\\shell\\open\\command. Ship desktop/plist/reg templates in data/. Add ifcurl-register as a pyproject.toml entry point.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:04Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:04Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"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 URL from the OS (e.g. launched by Firefox), reads user preference from ~/.config/ifcurl/config.toml ([handler] default_viewer), and launches the appropriate viewer (bonsai/ifcviewer) with the URL. If a running instance is detected via the viewer socket (see socket IPC issue), sends the URL there instead of launching fresh. Add as a pyproject.toml entry point.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:02Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:02Z","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.","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-05T14:42:59Z","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-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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:54Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:54Z","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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:52Z","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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:50Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:50Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"id":"ifcurl-i07","title":"File materialisation layer: extract IFC blob to stable per-commit cache path","description":"When a tool needs a real file path (not bytes), the bare cache clone is not sufficient. We need a layer that writes the IFC bytes from a git blob to a stable directory keyed by commit hexsha, e.g. ~/.cache/ifcurl/\u003curl-hash\u003e/\u003ccommit-hexsha\u003e/model.ifc. Immutable refs (commit hashes) can be cached indefinitely. Mutable refs (branches, HEAD) must re-extract after each fetch. This is the foundation for all connector and checkout work.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:30Z","created_by":"Bruno Postle","updated_at":"2026-06-05T15:41:04Z","started_at":"2026-06-05T15:41:04Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-8jp","title":"BCF export missing snapshot.png when selector is active","description":"When a selector is active in the viewer URL, BCF export delegates to the server-side /bcf endpoint which cannot capture the canvas. The resulting zip has viewpoint.bcfv but no snapshot.png. Fix: capture the canvas client-side before POSTing to /bcf, and include the snapshot bytes in the request so the server can embed it in the zip.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-17T21:58:39Z","created_by":"Bruno Postle","updated_at":"2026-05-17T21:58:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-oub","title":"New Issue button produces ifc:// link that JS rewrites to empty href","description":"The viewer's 'New Issue' button pre-fills the issue body with a link in the form [label](ifc://...). The ifcurl.js linkification code then rewrites this to [label]() stripping the URL. The JS should not rewrite links that already have an explicit ifc:// href.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-17T21:18:25Z","created_by":"Bruno Postle","updated_at":"2026-05-17T22:59:15Z","closed_at":"2026-05-17T22:59:15Z","close_reason":"Fixed: scope linkifyBareIfcUrls and replaceIfcAnchors to .markup elements only, preventing Forgejo editor preview from being modified","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-lal","title":"Render shows multiple copies of building","description":"When rendering the brown-street model via /preview, the output image shows multiple duplicate copies of the building rather than a single model. Likely a geometry or camera issue in the render pipeline.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T21:18:07Z","created_by":"Bruno Postle","updated_at":"2026-05-17T23:16:36Z","started_at":"2026-05-17T23:05:27Z","closed_at":"2026-05-17T23:16:36Z","close_reason":"Excluded IfcSpace and IfcAnnotation from default render iterator, matching Bonsai/JS viewer behavior","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -53,6 +62,8 @@ {"id":"ifcurl-ei8","title":"Fix syncCameraUrl: convert WebGL coords to IFC world space","description":"syncCameraUrl in viewer.py currently records camera.position/direction/up directly from Three.js, which are in WebGL/Three.js coordinate space (Y-up, different scale/orientation than IFC). The ifc:// URL spec expects IFC world coordinates (Z-up, matching BCF viewpoint conventions). Need to find the IFC→Three.js transform that @thatopen/components applies when loading a model, then apply its inverse in syncCameraUrl before serialising the camera params. Repos cloned for reference: ~/src/engine_components and ~/src/engine_fragment.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-23T05:53:06Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:36Z","closed_at":"2026-04-23T06:14:36Z","close_reason":"Split into atomic tasks: ifcurl-ldk, ifcurl-j2s, ifcurl-cmd","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} {"id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"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":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:06Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:06Z","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-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":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-05-17T08:45:31Z","created_by":"Bruno Postle","updated_at":"2026-05-17T08:45:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/ifcurl/__init__.py b/ifcurl/__init__.py index 2fdebf3..acb50f3 100644 --- a/ifcurl/__init__.py +++ b/ifcurl/__init__.py @@ -17,8 +17,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with IFC URL. If not, see . -from ifcurl.git import fetch_ifc, fetch_ifc_bytes +from ifcurl.git import fetch_ifc, fetch_ifc_bytes, fetch_ifc_path from ifcurl.url import IfcUrl __version__ = "0.0.0" -__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes"] +__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes", "fetch_ifc_path"] diff --git a/ifcurl/git.py b/ifcurl/git.py index 8627172..54c6ce3 100644 --- a/ifcurl/git.py +++ b/ifcurl/git.py @@ -157,11 +157,55 @@ def fetch_ifc_bytes(ifc_url: IfcUrl, token: str | None = None) -> bytes: return data +def fetch_ifc_path(ifc_url: IfcUrl, token: str | None = None) -> tuple[Path, bool]: + """Return ``(path, is_stale)`` for the IFC file addressed by *ifc_url*. + + The file is written to a stable, content-addressed location: + + ``//`` + + where ```` is the per-URL directory used by the bare cache + clone. Because the path is keyed on the immutable commit hexsha, the + file is never rewritten once present — callers may hold the path across + calls without it changing under them. + + For mutable refs (branches, HEAD) the remote fetch runs first (as in + :func:`fetch_ifc`), and the returned path reflects the latest commit; + older materialised files are left in place until the parent cache entry + is evicted by LRU. + + *is_stale* has the same semantics as in :func:`fetch_ifc`. + + :param ifc_url: A parsed :class:`IfcUrl`. + :param token: Optional bearer token for HTTPS authentication. + :raises ImportError: If GitPython is not installed. + :raises ValueError: If ``ifc_url.path`` is unset, the repo cannot be + reached, or the file is not found at the specified ref. + """ + hexsha, data, is_stale = fetch_ifc(ifc_url, token=token) + path = _materialise(_cache_dir_for(ifc_url.git_remote_url()), hexsha, ifc_url.path, data) + return path, is_stale + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- +def _materialise(base_dir: Path, hexsha: str, file_path: str, data: bytes) -> Path: + """Write *data* to ``//`` and return the path. + + The file is skipped if it already exists — the hexsha guarantees content + identity so rewriting is unnecessary. + """ + dest_dir = base_dir / hexsha + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / Path(file_path).name + if not dest.exists(): + dest.write_bytes(data) + return dest + + def _fetch_all_refs(repo: "git.Repo", remote_url: str, token: str | None) -> None: """Fetch all refs from *remote_url* into *repo*, with http fallback.""" auth = _auth_url(remote_url, token) diff --git a/tests/test_git.py b/tests/test_git.py index 7554d4f..d9c2fd2 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -469,3 +469,96 @@ class TestCacheCli: _cmd_cache(_cache_args(cache_cmd="prune", max_gb=None)) out = capsys.readouterr().out assert "Freed" in out + + +# --------------------------------------------------------------------------- +# fetch_ifc_path — file materialisation +# --------------------------------------------------------------------------- + + +from ifcurl.git import fetch_ifc_path, _materialise + + +class TestMaterialise: + def test_returns_path(self, tmp_path, local_ifc_repo): + result = _materialise(tmp_path, "abc123", "model.ifc", b"data") + assert isinstance(result, Path) + + def test_file_exists(self, tmp_path, local_ifc_repo): + result = _materialise(tmp_path, "abc123", "model.ifc", b"data") + assert result.exists() + + def test_file_contains_data(self, tmp_path): + data = b"IFC4\x00\xFF" + result = _materialise(tmp_path, "deadbeef", "structure.ifc", data) + assert result.read_bytes() == data + + def test_hexsha_in_path(self, tmp_path): + result = _materialise(tmp_path, "cafebabe", "model.ifc", b"x") + assert "cafebabe" in str(result) + + def test_basename_preserved(self, tmp_path): + result = _materialise(tmp_path, "abc", "models/sub/structure.ifc", b"x") + assert result.name == "structure.ifc" + + def test_not_rewritten_if_exists(self, tmp_path): + result = _materialise(tmp_path, "abc123", "model.ifc", b"original") + result.write_bytes(b"modified") + _materialise(tmp_path, "abc123", "model.ifc", b"original") + assert result.read_bytes() == b"modified" + + +class TestFetchIfcPath: + def test_returns_path_and_stale_flag(self, local_ifc_repo): + url = _local_url(local_ifc_repo["path"]) + path, is_stale = fetch_ifc_path(url) + assert isinstance(path, Path) + assert is_stale is False + + def test_file_exists_with_correct_content(self, local_ifc_repo): + url = _local_url(local_ifc_repo["path"]) + path, _ = fetch_ifc_path(url) + assert path.exists() + assert path.read_bytes() == local_ifc_repo["bytes"] + + def test_hexsha_in_path(self, local_ifc_repo): + url = _local_url(local_ifc_repo["path"]) + path, _ = fetch_ifc_path(url) + assert local_ifc_repo["hexsha"] in str(path) + + def test_basename_is_ifc_filename(self, local_ifc_repo): + url = _local_url(local_ifc_repo["path"]) + path, _ = fetch_ifc_path(url) + assert path.name == "model.ifc" + + def test_stable_across_calls(self, local_ifc_repo): + url = _local_url(local_ifc_repo["path"]) + path1, _ = fetch_ifc_path(url) + path2, _ = fetch_ifc_path(url) + assert path1 == path2 + + def test_new_commit_produces_new_path(self, tmp_path): + import git as gitpkg + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + ifc_bytes = b"IFC4\n" + (repo_dir / "model.ifc").write_bytes(ifc_bytes) + + repo = gitpkg.Repo.init(str(repo_dir)) + with repo.config_writer() as cw: + cw.set_value("user", "name", "Test") + cw.set_value("user", "email", "t@t.com") + repo.index.add(["model.ifc"]) + repo.index.commit("first") + + url = _local_url(str(repo_dir), ref="HEAD") + path1, _ = fetch_ifc_path(url) + + (repo_dir / "model.ifc").write_bytes(b"IFC4\nUPDATED\n") + repo.index.add(["model.ifc"]) + repo.index.commit("second") + + path2, _ = fetch_ifc_path(url) + assert path1 != path2 + assert path2.read_bytes() == b"IFC4\nUPDATED\n"