mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
Add discover.py: find local repos matching a project by genesis commit
Implements ifcurl-egi. find_local_repos(genesis) scans directories from [repo_search] paths in ~/.config/ifcurl/config.toml, computes the genesis commit of each git repo found, and returns paths whose genesis matches. Results are cached in ~/.cache/ifcurl/genesis_repos/<genesis>.json for IFCURL_REPO_CACHE_TTL seconds (default 3600). Recursion stops at repo boundaries and hidden directories; depth capped at 4 levels. Adds tomli as a conditional dep for Python <3.11. Unblocks origin remapping (ifcurl-oef) and ambiguity resolution (ifcurl-77u). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8625528514
commit
988eed5a93
5 changed files with 454 additions and 5 deletions
|
|
@ -8,9 +8,9 @@
|
||||||
{"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-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-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-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-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":"in_progress","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:00:19Z","started_at":"2026-06-05T22:00:19Z","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-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":"in_progress","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:29:24Z","started_at":"2026-06-05T17:29:24Z","dependency_count":0,"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}
|
||||||
{"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":"closed","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-05T17:26:22Z","started_at":"2026-06-05T15:41:04Z","closed_at":"2026-06-05T17:26:22Z","close_reason":"Closed","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":"closed","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-05T17:26:22Z","started_at":"2026-06-05T15:41:04Z","closed_at":"2026-06-05T17:26:22Z","close_reason":"Closed","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-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-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}
|
||||||
|
|
@ -135,6 +135,6 @@
|
||||||
{"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."}
|
|
||||||
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."}
|
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."}
|
||||||
{"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."}
|
{"_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."}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@
|
||||||
# You should have received a copy of the GNU Lesser General Public License
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
|
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from ifcurl.discover import find_local_repos
|
||||||
from ifcurl.git import fetch_ifc, fetch_ifc_bytes, fetch_ifc_path, get_genesis_commit
|
from ifcurl.git import fetch_ifc, fetch_ifc_bytes, fetch_ifc_path, get_genesis_commit
|
||||||
from ifcurl.url import IfcUrl
|
from ifcurl.url import IfcUrl
|
||||||
|
|
||||||
__version__ = "0.0.0"
|
__version__ = "0.0.0"
|
||||||
__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes", "fetch_ifc_path", "get_genesis_commit"]
|
__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes", "fetch_ifc_path", "get_genesis_commit", "find_local_repos"]
|
||||||
|
|
|
||||||
190
ifcurl/discover.py
Normal file
190
ifcurl/discover.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
# IFC URL — resolve and render ifc:// URLs
|
||||||
|
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||||
|
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# This file is part of IFC URL.
|
||||||
|
#
|
||||||
|
# IFC URL is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# IFC URL is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
|
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Discover local git repositories that are clones or forks of a given project.
|
||||||
|
|
||||||
|
Repositories are identified by their genesis commit (root commit hexsha),
|
||||||
|
which is stable across all forks and across HTTPS/SSH URL forms of the same
|
||||||
|
project. See :func:`ifcurl.git.get_genesis_commit`.
|
||||||
|
|
||||||
|
Search paths are read from ``~/.config/ifcurl/config.toml``:
|
||||||
|
|
||||||
|
.. code-block:: toml
|
||||||
|
|
||||||
|
[repo_search]
|
||||||
|
paths = ["~/projects/", "~/work/"]
|
||||||
|
|
||||||
|
Discovery results are cached per genesis commit in
|
||||||
|
``~/.cache/ifcurl/genesis_repos/<genesis_commit>.json`` for
|
||||||
|
``IFCURL_REPO_CACHE_TTL`` seconds (default 3600).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ImportError:
|
||||||
|
import tomli as tomllib # type: ignore[no-redef]
|
||||||
|
|
||||||
|
from platformdirs import user_cache_dir, user_config_dir
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CACHE_TTL: int = int(os.environ.get("IFCURL_REPO_CACHE_TTL", "3600"))
|
||||||
|
_DEFAULT_MAX_DEPTH: int = 4
|
||||||
|
|
||||||
|
|
||||||
|
def find_local_repos(genesis_commit: str) -> list[Path]:
|
||||||
|
"""Return paths of local git repos whose genesis commit matches *genesis_commit*.
|
||||||
|
|
||||||
|
Scans each directory listed under ``[repo_search] paths`` in
|
||||||
|
``~/.config/ifcurl/config.toml``. Returns an empty list when no search
|
||||||
|
paths are configured or no matching repo is found.
|
||||||
|
|
||||||
|
Results are cached on disk for ``IFCURL_REPO_CACHE_TTL`` seconds
|
||||||
|
(default 3600) to avoid repeated filesystem scans within a session.
|
||||||
|
|
||||||
|
:param genesis_commit: 40-character root commit hexsha to match against.
|
||||||
|
:returns: Absolute paths to matching working-tree git repositories.
|
||||||
|
"""
|
||||||
|
cached = _load_cache(genesis_commit)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
search_paths = _get_search_paths()
|
||||||
|
matches: list[Path] = []
|
||||||
|
for search_path in search_paths:
|
||||||
|
for repo_path in _scan_for_git_repos(search_path):
|
||||||
|
genesis = _genesis_for_local(repo_path)
|
||||||
|
if genesis == genesis_commit:
|
||||||
|
matches.append(repo_path)
|
||||||
|
|
||||||
|
_save_cache(genesis_commit, matches)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> dict:
|
||||||
|
"""Return the parsed config.toml, or an empty dict if it does not exist."""
|
||||||
|
config_file = Path(user_config_dir("ifcurl")) / "config.toml"
|
||||||
|
if not config_file.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(config_file, "rb") as f:
|
||||||
|
return tomllib.load(f)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning("Could not read ifcurl config: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_search_paths() -> list[Path]:
|
||||||
|
"""Return expanded search paths from config, skipping non-existent ones."""
|
||||||
|
config = _load_config()
|
||||||
|
raw = config.get("repo_search", {}).get("paths", [])
|
||||||
|
paths = []
|
||||||
|
for p in raw:
|
||||||
|
expanded = Path(p).expanduser()
|
||||||
|
if expanded.is_dir():
|
||||||
|
paths.append(expanded)
|
||||||
|
else:
|
||||||
|
_logger.debug("Search path does not exist, skipping: %s", expanded)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filesystem scan
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_for_git_repos(search_path: Path) -> Iterator[Path]:
|
||||||
|
"""Yield working-tree git repo directories found under *search_path*.
|
||||||
|
|
||||||
|
Recursion stops at repo boundaries (nested repos are not descended into)
|
||||||
|
and at hidden directories (names starting with ``.``). Depth is capped
|
||||||
|
at ``_DEFAULT_MAX_DEPTH`` levels below *search_path*.
|
||||||
|
"""
|
||||||
|
for root, dirs, _ in os.walk(str(search_path)):
|
||||||
|
root_path = Path(root)
|
||||||
|
depth = len(root_path.relative_to(search_path).parts)
|
||||||
|
|
||||||
|
if (root_path / ".git").is_dir():
|
||||||
|
yield root_path
|
||||||
|
dirs.clear() # don't recurse into sub-repos
|
||||||
|
elif depth >= _DEFAULT_MAX_DEPTH:
|
||||||
|
dirs.clear()
|
||||||
|
else:
|
||||||
|
dirs[:] = sorted(d for d in dirs if not d.startswith("."))
|
||||||
|
|
||||||
|
|
||||||
|
def _genesis_for_local(repo_path: Path) -> str | None:
|
||||||
|
"""Return the genesis commit hexsha of the repo at *repo_path*, or None."""
|
||||||
|
try:
|
||||||
|
import git
|
||||||
|
from ifcurl.git import _compute_genesis
|
||||||
|
|
||||||
|
repo = git.Repo(str(repo_path))
|
||||||
|
return _compute_genesis(repo)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Disk cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_file(genesis_commit: str) -> Path:
|
||||||
|
cache_dir = Path(user_cache_dir("ifcurl")) / "genesis_repos"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return cache_dir / f"{genesis_commit}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cache(genesis_commit: str) -> list[Path] | None:
|
||||||
|
"""Return cached paths if present and unexpired, else None."""
|
||||||
|
cf = _cache_file(genesis_commit)
|
||||||
|
if not cf.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(cf.read_text())
|
||||||
|
if time.time() - data["timestamp"] > _CACHE_TTL:
|
||||||
|
return None
|
||||||
|
return [Path(p) for p in data["paths"]]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_cache(genesis_commit: str, paths: list[Path]) -> None:
|
||||||
|
"""Write discovery results to disk; failure is silently ignored."""
|
||||||
|
try:
|
||||||
|
_cache_file(genesis_commit).write_text(
|
||||||
|
json.dumps({"timestamp": time.time(), "paths": [str(p) for p in paths]})
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
@ -17,7 +17,7 @@ classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||||
]
|
]
|
||||||
dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
|
dependencies = ["ifcopenshell", "gitpython", "platformdirs", "tomli; python_version<'3.11'"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
render = ["pyvista", "numpy", "Pillow"]
|
render = ["pyvista", "numpy", "Pillow"]
|
||||||
|
|
|
||||||
258
tests/test_discover.py
Normal file
258
tests/test_discover.py
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""Tests for ifcurl.discover — local git repository discovery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcurl.discover import (
|
||||||
|
_cache_file,
|
||||||
|
_genesis_for_local,
|
||||||
|
_get_search_paths,
|
||||||
|
_load_cache,
|
||||||
|
_save_cache,
|
||||||
|
_scan_for_git_repos,
|
||||||
|
find_local_repos,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(path: Path, filename: str = "model.ifc") -> str:
|
||||||
|
"""Init a git repo at *path* with one commit; return genesis hexsha."""
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / filename).write_bytes(b"IFC4\n")
|
||||||
|
repo = gitpkg.Repo.init(str(path))
|
||||||
|
with repo.config_writer() as cw:
|
||||||
|
cw.set_value("user", "name", "Test")
|
||||||
|
cw.set_value("user", "email", "t@t.com")
|
||||||
|
repo.index.add([filename])
|
||||||
|
commit = repo.index.commit("init")
|
||||||
|
return commit.hexsha
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config(config_dir: Path, paths: list[str]) -> None:
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(config_dir / "config.toml").write_text(
|
||||||
|
"[repo_search]\npaths = [" + ", ".join(f'"{p}"' for p in paths) + "]\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _scan_for_git_repos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanForGitRepos:
|
||||||
|
def test_finds_repo_at_top_level(self, tmp_path):
|
||||||
|
_make_repo(tmp_path / "proj")
|
||||||
|
found = list(_scan_for_git_repos(tmp_path))
|
||||||
|
assert tmp_path / "proj" in found
|
||||||
|
|
||||||
|
def test_finds_nested_repo(self, tmp_path):
|
||||||
|
_make_repo(tmp_path / "client" / "proj")
|
||||||
|
found = list(_scan_for_git_repos(tmp_path))
|
||||||
|
assert tmp_path / "client" / "proj" in found
|
||||||
|
|
||||||
|
def test_does_not_descend_into_sub_repo(self, tmp_path):
|
||||||
|
_make_repo(tmp_path / "outer")
|
||||||
|
_make_repo(tmp_path / "outer" / "inner")
|
||||||
|
found = list(_scan_for_git_repos(tmp_path))
|
||||||
|
assert tmp_path / "outer" in found
|
||||||
|
assert tmp_path / "outer" / "inner" not in found
|
||||||
|
|
||||||
|
def test_skips_hidden_directories(self, tmp_path):
|
||||||
|
_make_repo(tmp_path / ".hidden" / "proj")
|
||||||
|
found = list(_scan_for_git_repos(tmp_path))
|
||||||
|
assert not any(".hidden" in str(p) for p in found)
|
||||||
|
|
||||||
|
def test_empty_search_path_yields_nothing(self, tmp_path):
|
||||||
|
assert list(_scan_for_git_repos(tmp_path)) == []
|
||||||
|
|
||||||
|
def test_nonexistent_path_yields_nothing(self, tmp_path):
|
||||||
|
assert list(_scan_for_git_repos(tmp_path / "no_such_dir")) == []
|
||||||
|
|
||||||
|
def test_plain_directory_not_yielded(self, tmp_path):
|
||||||
|
(tmp_path / "not_a_repo").mkdir()
|
||||||
|
assert list(_scan_for_git_repos(tmp_path)) == []
|
||||||
|
|
||||||
|
def test_multiple_repos_all_found(self, tmp_path):
|
||||||
|
_make_repo(tmp_path / "a")
|
||||||
|
_make_repo(tmp_path / "b")
|
||||||
|
found = list(_scan_for_git_repos(tmp_path))
|
||||||
|
assert tmp_path / "a" in found
|
||||||
|
assert tmp_path / "b" in found
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _genesis_for_local
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenesisForLocal:
|
||||||
|
def test_returns_hexsha(self, tmp_path):
|
||||||
|
hexsha = _make_repo(tmp_path / "repo")
|
||||||
|
result = _genesis_for_local(tmp_path / "repo")
|
||||||
|
assert result == hexsha
|
||||||
|
|
||||||
|
def test_returns_none_for_non_repo(self, tmp_path):
|
||||||
|
(tmp_path / "not_a_repo").mkdir()
|
||||||
|
assert _genesis_for_local(tmp_path / "not_a_repo") is None
|
||||||
|
|
||||||
|
def test_returns_none_for_nonexistent_path(self, tmp_path):
|
||||||
|
assert _genesis_for_local(tmp_path / "missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _get_search_paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSearchPaths:
|
||||||
|
def test_returns_configured_paths(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
search_dir = tmp_path / "projects"
|
||||||
|
search_dir.mkdir()
|
||||||
|
_write_config(tmp_path, [str(search_dir)])
|
||||||
|
paths = _get_search_paths()
|
||||||
|
assert search_dir in paths
|
||||||
|
|
||||||
|
def test_skips_nonexistent_paths(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
_write_config(tmp_path, [str(tmp_path / "does_not_exist")])
|
||||||
|
assert _get_search_paths() == []
|
||||||
|
|
||||||
|
def test_no_config_returns_empty(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
assert _get_search_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCache:
|
||||||
|
def test_save_and_load(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
paths = [Path("/home/user/proj"), Path("/home/user/other")]
|
||||||
|
_save_cache("abc123", paths)
|
||||||
|
result = _load_cache("abc123")
|
||||||
|
assert result == paths
|
||||||
|
|
||||||
|
def test_missing_cache_returns_none(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
assert _load_cache("deadbeef" * 5) is None
|
||||||
|
|
||||||
|
def test_expired_cache_returns_none(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
cf = _cache_file("abc123")
|
||||||
|
cf.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cf.write_text(json.dumps({"timestamp": time.time() - 7200, "paths": []}))
|
||||||
|
assert _load_cache("abc123") is None
|
||||||
|
|
||||||
|
def test_fresh_cache_returned(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
_save_cache("abc123", [Path("/some/repo")])
|
||||||
|
assert _load_cache("abc123") == [Path("/some/repo")]
|
||||||
|
|
||||||
|
def test_corrupt_cache_returns_none(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
cf = _cache_file("abc123")
|
||||||
|
cf.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cf.write_text("not json {{{")
|
||||||
|
assert _load_cache("abc123") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_local_repos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindLocalRepos:
|
||||||
|
def _setup(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config"))
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache"))
|
||||||
|
search_dir = tmp_path / "projects"
|
||||||
|
search_dir.mkdir()
|
||||||
|
_write_config(tmp_path / "config", [str(search_dir)])
|
||||||
|
return search_dir
|
||||||
|
|
||||||
|
def test_finds_matching_repo(self, tmp_path, monkeypatch):
|
||||||
|
search_dir = self._setup(tmp_path, monkeypatch)
|
||||||
|
genesis = _make_repo(search_dir / "building")
|
||||||
|
result = find_local_repos(genesis)
|
||||||
|
assert search_dir / "building" in result
|
||||||
|
|
||||||
|
def test_ignores_unrelated_repo(self, tmp_path, monkeypatch):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
search_dir = self._setup(tmp_path, monkeypatch)
|
||||||
|
# Give each repo distinct content so their genesis hashes differ
|
||||||
|
_make_repo(search_dir / "building")
|
||||||
|
other = search_dir / "other"
|
||||||
|
other.mkdir()
|
||||||
|
(other / "model.ifc").write_bytes(b"IFC4\nDISTINCT CONTENT\n")
|
||||||
|
repo = gitpkg.Repo.init(str(other))
|
||||||
|
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"])
|
||||||
|
unrelated_genesis = repo.index.commit("init other").hexsha
|
||||||
|
|
||||||
|
result = find_local_repos(unrelated_genesis)
|
||||||
|
assert search_dir / "building" not in result
|
||||||
|
assert search_dir / "other" in result
|
||||||
|
|
||||||
|
def test_finds_fork_by_genesis(self, tmp_path, monkeypatch):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
search_dir = self._setup(tmp_path, monkeypatch)
|
||||||
|
genesis = _make_repo(tmp_path / "upstream")
|
||||||
|
gitpkg.Repo.clone_from(str(tmp_path / "upstream"), str(search_dir / "myfork"))
|
||||||
|
result = find_local_repos(genesis)
|
||||||
|
assert search_dir / "myfork" in result
|
||||||
|
|
||||||
|
def test_no_search_paths_returns_empty(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config"))
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache"))
|
||||||
|
assert find_local_repos("a" * 40) == []
|
||||||
|
|
||||||
|
def test_result_is_cached(self, tmp_path, monkeypatch):
|
||||||
|
search_dir = self._setup(tmp_path, monkeypatch)
|
||||||
|
genesis = _make_repo(search_dir / "proj")
|
||||||
|
|
||||||
|
find_local_repos(genesis) # populate cache
|
||||||
|
|
||||||
|
# Remove the actual repo — second call must still return a result from cache
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(str(search_dir / "proj"))
|
||||||
|
|
||||||
|
result = find_local_repos(genesis)
|
||||||
|
assert search_dir / "proj" in result
|
||||||
|
|
||||||
|
def test_stale_cache_rescans(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_config_dir", lambda *a, **kw: str(tmp_path / "config"))
|
||||||
|
monkeypatch.setattr("ifcurl.discover.user_cache_dir", lambda *a, **kw: str(tmp_path / "cache"))
|
||||||
|
monkeypatch.setattr("ifcurl.discover._CACHE_TTL", 0)
|
||||||
|
|
||||||
|
search_dir = tmp_path / "projects"
|
||||||
|
search_dir.mkdir()
|
||||||
|
_write_config(tmp_path / "config", [str(search_dir)])
|
||||||
|
genesis = _make_repo(search_dir / "proj")
|
||||||
|
|
||||||
|
# Write a stale cache that claims no repos
|
||||||
|
cf = _cache_file(genesis)
|
||||||
|
cf.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cf.write_text(json.dumps({"timestamp": 0, "paths": []}))
|
||||||
|
|
||||||
|
result = find_local_repos(genesis)
|
||||||
|
assert search_dir / "proj" in result
|
||||||
Loading…
Add table
Reference in a new issue