mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
Fix fetch-on-miss for PR commits: catch ValueError from GitPython directly
GitPython raises a plain ValueError from _parse_object_header when a commit
hash is missing, not BadObject, so the previous message-based retry guard
("not found in repository") never matched. Broaden the except clause to catch
(ValueError, BadObject, BadName) and drop the fragile string check. Extract
the fetch logic into _fetch_all_refs helper and apply the same retry to
diff_text. Add tests covering _fetch_all_refs, the retry-on-BadObject path,
and the no-retry-for-local-transport invariant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3937de2974
commit
03782ec9f7
3 changed files with 215 additions and 21 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
|
{"id":"ifcurl-q3g","title":"render_diff 404 for branch/PR commits — git.py fix not deployed to /opt/ifcurl/","description":"The git.py fetch-on-miss fix (commit cad1fa5) works correctly from source (confirmed by python3 test returning OK for commit 50f3571). However the installed service at /opt/ifcurl/ is still running the old code and returns 404 with 'SHA ... could not be resolved'.\n\nRoot cause: initial bare clone only fetches the default branch; PR/branch commits are absent and immutable refs skip the fetch. Fix adds a retry with +refs/*:refs/* fetch on miss.\n\nTo deploy:\n sudo cp /home/bruno/src/ifcurl/ifcurl/git.py /opt/ifcurl/lib/python3.14/site-packages/ifcurl/git.py\n sudo systemctl restart ifcurl-api.service ifcurl-render.service\n\nNote: service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service).","status":"closed","priority":1,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-16T00:12:30Z","created_by":"Bruno Postle","updated_at":"2026-05-16T07:18:08Z","closed_at":"2026-05-16T07:18:08Z","close_reason":"Deployed git.py fix; confirmed 200 for branch commit hash","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-vm5","title":"ifcopenshell segfault on malformed IFC: service DoS and potential RCE risk","description":"ifcopenshell uses C++ bindings (via web-ifc) that can segfault when given malformed or adversarially crafted IFC data. In the current service architecture this has two serious implications:\n\n1. **DoS**: a segfault kills the uvicorn worker process. Under repeated attack a service with --workers=1 (the common deployment) goes down entirely. Even with multiple workers, each is vulnerable independently.\n\n2. **Potential RCE**: memory-corruption bugs in C++ parsers are a well-known exploit vector. A crafted IFC file that triggers a heap overflow in ifcopenshell could yield arbitrary code execution as the service user. The service runs on the same host as Forgejo, making this a high-value target.\n\nThe two call sites at risk are:\n- `_load_model()` → `ifcopenshell.file.from_string()` in service.py (runs in the uvicorn worker thread)\n- `render_mod.render()` → `ifcopenshell.geom.iterator()` in render.py (already spawns worker subprocesses, so a segfault there kills a worker process but not the service)\n\nThe primary mitigation to investigate is **subprocess isolation for the parse step**: run `from_string()` in a short-lived child process with a timeout and resource limits. If the child exits with a signal (SIGSEGV, SIGABRT) or times out, return HTTP 422 to the caller without killing the service. This is the standard pattern used by document-conversion services (e.g. LibreOffice sandboxing).\n\nSecondary mitigations:\n- Apply `ulimit` / `resource.setrlimit` to cap address-space and CPU time in the child\n- Consider `seccomp` syscall filtering for the parse subprocess (restrict to read-only FS access, no network, no exec)\n- File-size cap on uploaded/fetched IFC bytes (tracked separately in ifcurl-fu5)\n- Fuzz testing with `atheris` or AFL++ against the parse path to surface crashes before deployment\n\nAcceptance criteria: a crafted request that would segfault the current service returns 422 and the service continues to handle subsequent requests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T16:22:41Z","created_by":"Bruno Postle","updated_at":"2026-04-24T16:38:18Z","started_at":"2026-04-24T16:25:41Z","closed_at":"2026-04-24T16:38:18Z","close_reason":"Implemented subprocess isolation in ifcurl/sandbox.py. All ifcopenshell calls now run in a child process; segfault → SandboxCrashError → HTTP 422 without killing the service.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-vm5","title":"ifcopenshell segfault on malformed IFC: service DoS and potential RCE risk","description":"ifcopenshell uses C++ bindings (via web-ifc) that can segfault when given malformed or adversarially crafted IFC data. In the current service architecture this has two serious implications:\n\n1. **DoS**: a segfault kills the uvicorn worker process. Under repeated attack a service with --workers=1 (the common deployment) goes down entirely. Even with multiple workers, each is vulnerable independently.\n\n2. **Potential RCE**: memory-corruption bugs in C++ parsers are a well-known exploit vector. A crafted IFC file that triggers a heap overflow in ifcopenshell could yield arbitrary code execution as the service user. The service runs on the same host as Forgejo, making this a high-value target.\n\nThe two call sites at risk are:\n- `_load_model()` → `ifcopenshell.file.from_string()` in service.py (runs in the uvicorn worker thread)\n- `render_mod.render()` → `ifcopenshell.geom.iterator()` in render.py (already spawns worker subprocesses, so a segfault there kills a worker process but not the service)\n\nThe primary mitigation to investigate is **subprocess isolation for the parse step**: run `from_string()` in a short-lived child process with a timeout and resource limits. If the child exits with a signal (SIGSEGV, SIGABRT) or times out, return HTTP 422 to the caller without killing the service. This is the standard pattern used by document-conversion services (e.g. LibreOffice sandboxing).\n\nSecondary mitigations:\n- Apply `ulimit` / `resource.setrlimit` to cap address-space and CPU time in the child\n- Consider `seccomp` syscall filtering for the parse subprocess (restrict to read-only FS access, no network, no exec)\n- File-size cap on uploaded/fetched IFC bytes (tracked separately in ifcurl-fu5)\n- Fuzz testing with `atheris` or AFL++ against the parse path to surface crashes before deployment\n\nAcceptance criteria: a crafted request that would segfault the current service returns 422 and the service continues to handle subsequent requests.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T16:22:41Z","created_by":"Bruno Postle","updated_at":"2026-04-24T16:38:18Z","started_at":"2026-04-24T16:25:41Z","closed_at":"2026-04-24T16:38:18Z","close_reason":"Implemented subprocess isolation in ifcurl/sandbox.py. All ifcopenshell calls now run in a child process; segfault → SandboxCrashError → HTTP 422 without killing the service.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"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-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-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-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-eaf","title":"Preview image camera is offset ~5m laterally vs JS viewer","description":"When opening a Forgejo markdown preview image (rendered by Python service via /preview), the camera position appears ~5m to the left compared to the same URL opened in the JS viewer.\n\nConfirmed: the model geometry IS shifted, not just a display scaling issue.\n\nWhat's known:\n- render.py uses IfcOpenShell with use-world-coords=True (world-space vertices)\n- _apply_camera() sets PyVista camera directly in IFC coordinates (no axis conversion)\n- JS viewer uses toThree() to convert IFC (Z-up) → Three.js (Y-up) coords\n- Python test from source correctly fetches the file (fetch works, rendering is the issue)\n\nHypothesis to investigate:\n- Does IfcOpenShell's geom iterator output vertices in IFC world coords (Z-up) or does it apply an axis swap?\n- Does PyVista/VTK interpret the camera position in the same axis convention as the IFC geometry?\n- Is plotter.show() triggering a reset_camera() that overrides the explicitly set camera?\n\nTest URL: ifc://localhost/bruno/simple-ifc@6af61b5454ac10ee2651da2d7363f54faa4f5b70?path=_test_simple.ifc\u0026camera=13.4983,-3.5129,4.4906,-0.8673,-0.4901,-0.0869,0,0,1\u0026fov=60\n\nRelevant code: ifcurl/render.py _apply_camera() line 88, _render_iterator() line 269.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-16T00:13:00Z","created_by":"Bruno Postle","updated_at":"2026-05-16T00:13:00Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-b1c","title":"Implement OpenCDE BCF API on Forgejo","description":"Expose a BCF 3.0 REST API as a Python route group in the preview service, proxied at /bcf/3.0/ on the Forgejo hostname via nginx/Caddy.\n\n## Architecture\n\nPython routes added to service.py alongside the existing /preview and /bcf endpoints. No new Forgejo Go code — the service forwards the user's Authorization: Bearer header verbatim to Forgejo's REST API on localhost, so Forgejo's own auth middleware enforces per-user permissions. No service account or machine token required.\n\nBCF \u003c-\u003e ifc:// conversion stays entirely in Python: bcf.py (ifc:// -\u003e BCF, already exists) and the reverse direction implemented in ifcurl-l50.\n\n## Design\n\nThe BCF API is a stateless translation layer over Forgejo's REST API. No Forgejo recompilation, no schema changes, no new Go code.\n\n**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF\u003c-\u003eifc:// conversion is specified in SPECIFICATION.md §6.\n\n**Mapping:**\n\n| BCF resource | Forgejo |\n|---|---|\n| Project | Repository (project_id = owner/repo) |\n| Topic | Issue |\n| Topic guid | Derived from issue ID |\n| Comment | Issue comment |\n| Viewpoint | ifc:// URL found in issue/comment body |\n| Viewpoint guid | Derived from comment ID |\n| Snapshot | ifc:// URL proxied through preview service |\n\n**Auth:** BCF client sends Authorization: Bearer \u003cforgejo-token\u003e. Service forwards it to Forgejo REST API. No credential storage, no service token.\n\n**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.\n\n**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL (ifcurl-l50), post as a new Forgejo comment.\n\n**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the existing /preview endpoint.\n\n## Minimum viable endpoints\n\n- GET /bcf/3.0/projects\n- GET /bcf/3.0/projects/{id}\n- GET/POST /bcf/3.0/projects/{id}/topics\n- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot\n- GET /bcf/3.0/projects/{id}/extensions (static minimal response)\n\n## Prerequisites\n\n- ifcurl-lwl: Foundation API discovery so BCF clients can locate this endpoint via OpenCDE auto-discovery\n- ifcurl-l50: BCF -\u003e ifc:// URL conversion needed for POST /viewpoints","status":"closed","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:55:11Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:39:11Z","closed_at":"2026-04-29T18:39:11Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-l50","type":"blocks","created_at":"2026-04-29T18:44:06Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-lwl","type":"blocks","created_at":"2026-04-29T18:44:05Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-b1c","title":"Implement OpenCDE BCF API on Forgejo","description":"Expose a BCF 3.0 REST API as a Python route group in the preview service, proxied at /bcf/3.0/ on the Forgejo hostname via nginx/Caddy.\n\n## Architecture\n\nPython routes added to service.py alongside the existing /preview and /bcf endpoints. No new Forgejo Go code — the service forwards the user's Authorization: Bearer header verbatim to Forgejo's REST API on localhost, so Forgejo's own auth middleware enforces per-user permissions. No service account or machine token required.\n\nBCF \u003c-\u003e ifc:// conversion stays entirely in Python: bcf.py (ifc:// -\u003e BCF, already exists) and the reverse direction implemented in ifcurl-l50.\n\n## Design\n\nThe BCF API is a stateless translation layer over Forgejo's REST API. No Forgejo recompilation, no schema changes, no new Go code.\n\n**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF\u003c-\u003eifc:// conversion is specified in SPECIFICATION.md §6.\n\n**Mapping:**\n\n| BCF resource | Forgejo |\n|---|---|\n| Project | Repository (project_id = owner/repo) |\n| Topic | Issue |\n| Topic guid | Derived from issue ID |\n| Comment | Issue comment |\n| Viewpoint | ifc:// URL found in issue/comment body |\n| Viewpoint guid | Derived from comment ID |\n| Snapshot | ifc:// URL proxied through preview service |\n\n**Auth:** BCF client sends Authorization: Bearer \u003cforgejo-token\u003e. Service forwards it to Forgejo REST API. No credential storage, no service token.\n\n**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.\n\n**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL (ifcurl-l50), post as a new Forgejo comment.\n\n**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the existing /preview endpoint.\n\n## Minimum viable endpoints\n\n- GET /bcf/3.0/projects\n- GET /bcf/3.0/projects/{id}\n- GET/POST /bcf/3.0/projects/{id}/topics\n- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot\n- GET /bcf/3.0/projects/{id}/extensions (static minimal response)\n\n## Prerequisites\n\n- ifcurl-lwl: Foundation API discovery so BCF clients can locate this endpoint via OpenCDE auto-discovery\n- ifcurl-l50: BCF -\u003e ifc:// URL conversion needed for POST /viewpoints","status":"closed","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:55:11Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:39:11Z","closed_at":"2026-04-29T18:39:11Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-l50","type":"blocks","created_at":"2026-04-29T18:44:06Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-lwl","type":"blocks","created_at":"2026-04-29T18:44:05Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-962","title":"forgejo: replace Go markdown extension with JS, eliminate required Go compile","description":"ifc_url.go is a Goldmark AST extension that requires patching and recompiling Forgejo to work. This is painful when dev environment (Fedora) differs from deployment (Docker) — compiler flags, static linking, build tags must all match. The goal is to make the ifc:// preview feature work with zero Go compilation, via pure JS asset deployment.\n\nPlan:\n1. Add querySelectorAll('a[href^=\"ifc://\"]') scanning to ifcurl.js — replace matched anchors with figure elements (preview image + viewer link + ifc:// URL). Goldmark already renders [title](ifc://...) as \u003ca href=\"ifc://...\"\u003etitle\u003c/a\u003e without any extension, so this covers all markdown link syntax.\n2. Change the Issue button in viewer.js to emit [view](ifc://...) markdown link syntax instead of a bare URL — so that user-generated issue bodies produce linkable anchors Goldmark will render, not plain text.\n3. The existing Go extension (ifc_url.go) and its markdown.go registration can be removed on the next Forgejo upgrade; the current patched binary continues to work in the meantime.\n\nAcceptance: after this change, deploying new ifcurl features only requires copying JS assets — no Forgejo rebuild needed.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:58:15Z","created_by":"Bruno Postle","updated_at":"2026-04-27T07:25:09Z","started_at":"2026-04-27T06:58:48Z","closed_at":"2026-04-27T07:25:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-962","title":"forgejo: replace Go markdown extension with JS, eliminate required Go compile","description":"ifc_url.go is a Goldmark AST extension that requires patching and recompiling Forgejo to work. This is painful when dev environment (Fedora) differs from deployment (Docker) — compiler flags, static linking, build tags must all match. The goal is to make the ifc:// preview feature work with zero Go compilation, via pure JS asset deployment.\n\nPlan:\n1. Add querySelectorAll('a[href^=\"ifc://\"]') scanning to ifcurl.js — replace matched anchors with figure elements (preview image + viewer link + ifc:// URL). Goldmark already renders [title](ifc://...) as \u003ca href=\"ifc://...\"\u003etitle\u003c/a\u003e without any extension, so this covers all markdown link syntax.\n2. Change the Issue button in viewer.js to emit [view](ifc://...) markdown link syntax instead of a bare URL — so that user-generated issue bodies produce linkable anchors Goldmark will render, not plain text.\n3. The existing Go extension (ifc_url.go) and its markdown.go registration can be removed on the next Forgejo upgrade; the current patched binary continues to work in the meantime.\n\nAcceptance: after this change, deploying new ifcurl features only requires copying JS assets — no Forgejo rebuild needed.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:58:15Z","created_by":"Bruno Postle","updated_at":"2026-04-27T07:25:09Z","started_at":"2026-04-27T06:58:48Z","closed_at":"2026-04-27T07:25:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-22n","title":"viewer: add ifc:// URL and clickable image link to embedded snapshots","description":"Forgejo commit pages and pull requests embed IFC snapshots as visual diffs. Each snapshot should have two additions: (1) the ifc:// URL that addresses this exact view at the commit hash displayed as text alongside the image, and (2) the snapshot image itself should be a clickable link that opens the viewer at this view. This makes snapshots navigable and shareable.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:16Z","created_by":"Bruno Postle","updated_at":"2026-04-27T05:59:52Z","started_at":"2026-04-27T05:59:51Z","closed_at":"2026-04-27T05:59:52Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-22n","depends_on_id":"ifcurl-5a6","type":"blocks","created_at":"2026-04-27T06:37:10Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-22n","title":"viewer: add ifc:// URL and clickable image link to embedded snapshots","description":"Forgejo commit pages and pull requests embed IFC snapshots as visual diffs. Each snapshot should have two additions: (1) the ifc:// URL that addresses this exact view at the commit hash displayed as text alongside the image, and (2) the snapshot image itself should be a clickable link that opens the viewer at this view. This makes snapshots navigable and shareable.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T05:36:16Z","created_by":"Bruno Postle","updated_at":"2026-04-27T05:59:52Z","started_at":"2026-04-27T05:59:51Z","closed_at":"2026-04-27T05:59:52Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-22n","depends_on_id":"ifcurl-5a6","type":"blocks","created_at":"2026-04-27T06:37:10Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
|
@ -114,3 +116,4 @@
|
||||||
{"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":"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":"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":"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."}
|
||||||
|
|
|
||||||
|
|
@ -83,29 +83,15 @@ def fetch_ifc(ifc_url: IfcUrl, token: str | None = None) -> tuple[str, bytes, bo
|
||||||
repo, is_stale = _get_repo(ifc_url, token=token)
|
repo, is_stale = _get_repo(ifc_url, token=token)
|
||||||
try:
|
try:
|
||||||
hexsha, data = _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
|
hexsha, data = _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
|
||||||
except ValueError as exc:
|
except (ValueError, git.exc.BadObject, git.exc.BadName) as exc:
|
||||||
# For immutable refs (commit hashes) the initial clone only fetches the
|
# For immutable refs (commit hashes) the initial clone only fetches the
|
||||||
# default branch. PR-branch commits won't be present until we fetch all
|
# default branch. PR-branch commits won't be present until we fetch all
|
||||||
# refs. Retry once after a full fetch.
|
# refs. Retry once after a full fetch. We catch git exceptions directly
|
||||||
if (
|
# here because BadObject may be a subclass of ValueError in some GitPython
|
||||||
"not found in repository" in str(exc)
|
# versions, causing the raw git error message to bypass _read_commit_blob's
|
||||||
and ifc_url.transport != "local"
|
# wrapping and making string-based message checks unreliable.
|
||||||
and not ifc_url.is_mutable_ref()
|
if ifc_url.transport != "local" and not ifc_url.is_mutable_ref():
|
||||||
):
|
_fetch_all_refs(repo, ifc_url.git_remote_url(), token)
|
||||||
remote_url = ifc_url.git_remote_url()
|
|
||||||
auth = _auth_url(remote_url, token)
|
|
||||||
try:
|
|
||||||
if auth != remote_url:
|
|
||||||
repo.git.fetch(auth, "+refs/*:refs/*")
|
|
||||||
else:
|
|
||||||
repo.git.fetch("origin", "+refs/*:refs/*")
|
|
||||||
except git.exc.GitCommandError:
|
|
||||||
fallback = _http_fallback(auth)
|
|
||||||
if fallback:
|
|
||||||
try:
|
|
||||||
repo.git.fetch(fallback, "+refs/*:refs/*")
|
|
||||||
except git.exc.GitCommandError:
|
|
||||||
pass
|
|
||||||
hexsha, data = _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
|
hexsha, data = _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
@ -139,6 +125,12 @@ def diff_text(base_url: IfcUrl, head_url: IfcUrl, token: str | None = None) -> s
|
||||||
)
|
)
|
||||||
|
|
||||||
repo, _ = _get_repo(base_url, token=token)
|
repo, _ = _get_repo(base_url, token=token)
|
||||||
|
for ifc_url in (base_url, head_url):
|
||||||
|
try:
|
||||||
|
repo.commit(ifc_url.git_ref())
|
||||||
|
except (git.exc.BadName, git.exc.BadObject, ValueError):
|
||||||
|
if ifc_url.transport != "local" and not ifc_url.is_mutable_ref():
|
||||||
|
_fetch_all_refs(repo, ifc_url.git_remote_url(), token)
|
||||||
try:
|
try:
|
||||||
base_hexsha = repo.commit(base_url.git_ref()).hexsha
|
base_hexsha = repo.commit(base_url.git_ref()).hexsha
|
||||||
head_hexsha = repo.commit(head_url.git_ref()).hexsha
|
head_hexsha = repo.commit(head_url.git_ref()).hexsha
|
||||||
|
|
@ -169,6 +161,23 @@ def fetch_ifc_bytes(ifc_url: IfcUrl, token: str | None = None) -> bytes:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
if auth != remote_url:
|
||||||
|
repo.git.fetch(auth, "+refs/*:refs/*")
|
||||||
|
else:
|
||||||
|
repo.git.fetch("origin", "+refs/*:refs/*")
|
||||||
|
except git.exc.GitCommandError:
|
||||||
|
fallback = _http_fallback(auth)
|
||||||
|
if fallback:
|
||||||
|
try:
|
||||||
|
repo.git.fetch(fallback, "+refs/*:refs/*")
|
||||||
|
except git.exc.GitCommandError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_repo(ifc_url: IfcUrl, token: str | None = None) -> tuple[git.Repo, bool]:
|
def _get_repo(ifc_url: IfcUrl, token: str | None = None) -> tuple[git.Repo, bool]:
|
||||||
"""Open or fetch the repository for *ifc_url*. Returns ``(repo, is_stale)``."""
|
"""Open or fetch the repository for *ifc_url*. Returns ``(repo, is_stale)``."""
|
||||||
if ifc_url.transport == "local":
|
if ifc_url.transport == "local":
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -107,6 +108,187 @@ class TestInvalidRepo:
|
||||||
fetch_ifc(url)
|
fetch_ifc(url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fetch-on-miss retry (PR / non-default-branch commits)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_remote_with_branch_after_clone(tmp_path: Path, ifc_bytes: bytes, cache_dest: Path) -> dict:
|
||||||
|
"""Create a remote repo, bare-clone it, then add a branch commit to the remote.
|
||||||
|
|
||||||
|
This simulates the real scenario: initial bare clone only has the default branch,
|
||||||
|
then a PR is created (new commit appears in remote) that the clone doesn't know about.
|
||||||
|
Returns metadata about both commits and the pre-populated bare cache.
|
||||||
|
"""
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
remote = tmp_path / "remote"
|
||||||
|
remote.mkdir()
|
||||||
|
r = gitpkg.Repo.init(str(remote))
|
||||||
|
with r.config_writer() as cw:
|
||||||
|
cw.set_value("user", "name", "Test")
|
||||||
|
cw.set_value("user", "email", "test@example.com")
|
||||||
|
|
||||||
|
(remote / "model.ifc").write_bytes(ifc_bytes)
|
||||||
|
r.index.add(["model.ifc"])
|
||||||
|
main_commit = r.index.commit("main commit")
|
||||||
|
|
||||||
|
# Clone while only the main commit exists
|
||||||
|
cache = gitpkg.Repo.clone_from(str(remote), str(cache_dest), bare=True)
|
||||||
|
|
||||||
|
# Now add a branch commit to the remote (simulates a PR being opened)
|
||||||
|
r.create_head("pr-branch").checkout()
|
||||||
|
modified = ifc_bytes + b"\n/* branch */"
|
||||||
|
(remote / "model.ifc").write_bytes(modified)
|
||||||
|
r.index.add(["model.ifc"])
|
||||||
|
branch_commit = r.index.commit("pr commit")
|
||||||
|
r.heads[r.active_branch.name].checkout() # back to default
|
||||||
|
|
||||||
|
return {
|
||||||
|
"remote_path": str(remote),
|
||||||
|
"cache": cache,
|
||||||
|
"main_hexsha": main_commit.hexsha,
|
||||||
|
"branch_hexsha": branch_commit.hexsha,
|
||||||
|
"branch_bytes": modified,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchAllRefs:
|
||||||
|
def test_fetches_non_default_branch_commit(self, tmp_path, local_ifc_repo):
|
||||||
|
from ifcurl.git import _fetch_all_refs
|
||||||
|
|
||||||
|
meta = _make_remote_with_branch_after_clone(
|
||||||
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # commit absent before fetch
|
||||||
|
meta["cache"].commit(meta["branch_hexsha"])
|
||||||
|
|
||||||
|
_fetch_all_refs(meta["cache"], meta["remote_path"], token=None)
|
||||||
|
|
||||||
|
found = meta["cache"].commit(meta["branch_hexsha"])
|
||||||
|
assert found.hexsha == meta["branch_hexsha"]
|
||||||
|
|
||||||
|
def test_main_commit_still_accessible_after_fetch(self, tmp_path, local_ifc_repo):
|
||||||
|
from ifcurl.git import _fetch_all_refs
|
||||||
|
|
||||||
|
meta = _make_remote_with_branch_after_clone(
|
||||||
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
||||||
|
)
|
||||||
|
_fetch_all_refs(meta["cache"], meta["remote_path"], token=None)
|
||||||
|
|
||||||
|
found = meta["cache"].commit(meta["main_hexsha"])
|
||||||
|
assert found.hexsha == meta["main_hexsha"]
|
||||||
|
|
||||||
|
def test_noop_when_fetch_fails(self, tmp_path, local_ifc_repo):
|
||||||
|
from ifcurl.git import _fetch_all_refs
|
||||||
|
|
||||||
|
meta = _make_remote_with_branch_after_clone(
|
||||||
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
||||||
|
)
|
||||||
|
# Bad remote URL — should silently fail without raising
|
||||||
|
_fetch_all_refs(meta["cache"], "file:///nonexistent/repo.git", token=None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchIfcRetry:
|
||||||
|
def _patch_url_as_remote(self, url, meta, monkeypatch):
|
||||||
|
"""Patch a local IfcUrl instance to look like a remote immutable ref."""
|
||||||
|
import ifcurl.url as url_mod
|
||||||
|
url.transport = "https"
|
||||||
|
monkeypatch.setattr(url_mod.IfcUrl, "is_mutable_ref", lambda self: False)
|
||||||
|
monkeypatch.setattr(url_mod.IfcUrl, "git_ref", lambda self: meta["branch_hexsha"])
|
||||||
|
monkeypatch.setattr(url_mod.IfcUrl, "git_remote_url", lambda self: meta["remote_path"])
|
||||||
|
|
||||||
|
def test_no_retry_for_local_transport(self, local_ifc_repo, monkeypatch):
|
||||||
|
"""For local transport, BadObject should propagate without triggering retry."""
|
||||||
|
import git.exc
|
||||||
|
import ifcurl.git as gmod
|
||||||
|
|
||||||
|
fetch_calls = []
|
||||||
|
monkeypatch.setattr(gmod, "_fetch_all_refs", lambda *a: fetch_calls.append(1))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gmod,
|
||||||
|
"_read_commit_blob",
|
||||||
|
lambda repo, git_ref, fp: (_ for _ in ()).throw(
|
||||||
|
git.exc.BadObject(git_ref.encode(), b"missing")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
url = IfcUrl.parse(
|
||||||
|
f"ifc://{local_ifc_repo['path']}@{local_ifc_repo['hexsha']}?path=model.ifc"
|
||||||
|
)
|
||||||
|
with pytest.raises((ValueError, git.exc.BadObject)):
|
||||||
|
fetch_ifc(url)
|
||||||
|
|
||||||
|
assert fetch_calls == [], "retry must not fire for local transport"
|
||||||
|
|
||||||
|
def test_retry_fires_for_remote_immutable_ref(self, tmp_path, local_ifc_repo, monkeypatch):
|
||||||
|
"""fetch_ifc calls _fetch_all_refs then retries when commit is absent from clone."""
|
||||||
|
import ifcurl.git as gmod
|
||||||
|
|
||||||
|
meta = _make_remote_with_branch_after_clone(
|
||||||
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
real_fetch_all = gmod._fetch_all_refs
|
||||||
|
fetch_calls = []
|
||||||
|
|
||||||
|
def recording_fetch_all(repo, remote_url, token):
|
||||||
|
fetch_calls.append(remote_url)
|
||||||
|
real_fetch_all(repo, meta["remote_path"], token)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gmod, "_get_repo", lambda ifc_url, token=None: (meta["cache"], False))
|
||||||
|
monkeypatch.setattr(gmod, "_fetch_all_refs", recording_fetch_all)
|
||||||
|
|
||||||
|
url = IfcUrl.parse(f"ifc:///ignored@{meta['branch_hexsha']}?path=model.ifc")
|
||||||
|
self._patch_url_as_remote(url, meta, monkeypatch)
|
||||||
|
|
||||||
|
hexsha, data, _ = fetch_ifc(url)
|
||||||
|
|
||||||
|
assert hexsha == meta["branch_hexsha"]
|
||||||
|
assert data == meta["branch_bytes"]
|
||||||
|
assert len(fetch_calls) == 1
|
||||||
|
|
||||||
|
def test_bad_object_exception_triggers_retry(self, tmp_path, local_ifc_repo, monkeypatch):
|
||||||
|
"""BadObject (even if a ValueError subclass) triggers the retry, not just ValueError."""
|
||||||
|
import git.exc
|
||||||
|
import ifcurl.git as gmod
|
||||||
|
|
||||||
|
meta = _make_remote_with_branch_after_clone(
|
||||||
|
tmp_path, local_ifc_repo["bytes"], tmp_path / "cache.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
orig_read = gmod._read_commit_blob
|
||||||
|
real_fetch_all = gmod._fetch_all_refs
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def flaky_read(repo, git_ref, file_path):
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
raise git.exc.BadObject(git_ref.encode(), b"missing")
|
||||||
|
return orig_read(repo, git_ref, file_path)
|
||||||
|
|
||||||
|
fetch_calls = []
|
||||||
|
|
||||||
|
def recording_fetch_all(repo, remote_url, token):
|
||||||
|
fetch_calls.append(remote_url)
|
||||||
|
real_fetch_all(repo, meta["remote_path"], token)
|
||||||
|
|
||||||
|
monkeypatch.setattr(gmod, "_read_commit_blob", flaky_read)
|
||||||
|
monkeypatch.setattr(gmod, "_fetch_all_refs", recording_fetch_all)
|
||||||
|
monkeypatch.setattr(gmod, "_get_repo", lambda ifc_url, token=None: (meta["cache"], False))
|
||||||
|
|
||||||
|
url = IfcUrl.parse(f"ifc:///ignored@{meta['branch_hexsha']}?path=model.ifc")
|
||||||
|
self._patch_url_as_remote(url, meta, monkeypatch)
|
||||||
|
|
||||||
|
hexsha, data, _ = fetch_ifc(url)
|
||||||
|
|
||||||
|
assert hexsha == meta["branch_hexsha"]
|
||||||
|
assert call_count[0] == 2 # first raises BadObject, second succeeds
|
||||||
|
assert len(fetch_calls) == 1 # _fetch_all_refs called once between the two
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cache management helpers
|
# Cache management helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue