mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
Add get_genesis_commit: root commit hash as project identity key
Implements ifcurl-4d4. _compute_genesis() runs git rev-list --max-parents=0 HEAD to find the root commit(s); get_genesis_commit() wraps it with a per-remote disk cache (~/.cache/ifcurl/<hash>/genesis_commit) so the git command only runs once per repo. For multi-root repos the lexicographically smallest hexsha is chosen for a stable result. Local repos compute fresh on each call. Exported from __init__. Unblocks local repo discovery (ifcurl-egi). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae143b74b7
commit
8625528514
4 changed files with 187 additions and 5 deletions
|
|
@ -10,8 +10,8 @@
|
||||||
{"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":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:54Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:54Z","dependencies":[{"issue_id":"ifcurl-egi","depends_on_id":"ifcurl-4d4","type":"blocks","created_at":"2026-06-05T15:43:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:52Z","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
{"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:52Z","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"ifcurl-4d4","title":"Genesis commit identity: compute and cache root commit hash as project key","description":"To identify that https and SSH forms of the same repo, and forks of the same project, are all 'the same project', we use the genesis commit (git rev-list --max-parents=0 HEAD) as a stable project identity key. Store the genesis commit hash alongside each bare cache clone. This key is used by local repo discovery and origin remapping.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:50Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:50Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
{"id":"ifcurl-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-i07","title":"File materialisation layer: extract IFC blob to stable per-commit cache path","description":"When a tool needs a real file path (not bytes), the bare cache clone is not sufficient. We need a layer that writes the IFC bytes from a git blob to a stable directory keyed by commit hexsha, e.g. ~/.cache/ifcurl/\u003curl-hash\u003e/\u003ccommit-hexsha\u003e/model.ifc. Immutable refs (commit hashes) can be cached indefinitely. Mutable refs (branches, HEAD) must re-extract after each fetch. This is the foundation for all connector and checkout work.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:30Z","created_by":"Bruno Postle","updated_at":"2026-06-05T15:41:04Z","started_at":"2026-06-05T15:41:04Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
{"id":"ifcurl-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}
|
||||||
{"id":"ifcurl-lal","title":"Render shows multiple copies of building","description":"When rendering the brown-street model via /preview, the output image shows multiple duplicate copies of the building rather than a single model. Likely a geometry or camera issue in the render pipeline.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T21:18:07Z","created_by":"Bruno Postle","updated_at":"2026-05-17T23:16:36Z","started_at":"2026-05-17T23:05:27Z","closed_at":"2026-05-17T23:16:36Z","close_reason":"Excluded IfcSpace and IfcAnnotation from default render iterator, matching Bonsai/JS viewer behavior","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-lal","title":"Render shows multiple copies of building","description":"When rendering the brown-street model via /preview, the output image shows multiple duplicate copies of the building rather than a single model. Likely a geometry or camera issue in the render pipeline.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T21:18:07Z","created_by":"Bruno Postle","updated_at":"2026-05-17T23:16:36Z","started_at":"2026-05-17T23:05:27Z","closed_at":"2026-05-17T23:16:36Z","close_reason":"Excluded IfcSpace and IfcAnnotation from default render iterator, matching Bonsai/JS viewer behavior","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
@ -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,8 @@
|
||||||
# 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.git import fetch_ifc, fetch_ifc_bytes, fetch_ifc_path
|
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"]
|
__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes", "fetch_ifc_path", "get_genesis_commit"]
|
||||||
|
|
|
||||||
|
|
@ -187,11 +187,69 @@ def fetch_ifc_path(ifc_url: IfcUrl, token: str | None = None) -> tuple[Path, boo
|
||||||
return path, is_stale
|
return path, is_stale
|
||||||
|
|
||||||
|
|
||||||
|
def get_genesis_commit(ifc_url: IfcUrl, token: str | None = None) -> str:
|
||||||
|
"""Return the genesis (root) commit hexsha for the repo identified by *ifc_url*.
|
||||||
|
|
||||||
|
The genesis commit is the oldest ancestor — the commit with no parents.
|
||||||
|
It is a stable project identity key shared across all forks and across
|
||||||
|
HTTPS/SSH URL forms of the same repository.
|
||||||
|
|
||||||
|
For remote repos the result is cached in ``<cache_dir>/genesis_commit``
|
||||||
|
alongside the bare clone so that subsequent calls skip the git command.
|
||||||
|
For local repos the value is computed fresh on each call.
|
||||||
|
|
||||||
|
:param ifc_url: A parsed :class:`IfcUrl`.
|
||||||
|
:param token: Optional bearer token for HTTPS authentication.
|
||||||
|
:raises ImportError: If GitPython is not installed.
|
||||||
|
:raises ValueError: If the repository has no commits or cannot be reached.
|
||||||
|
"""
|
||||||
|
if not _HAS_GITPYTHON:
|
||||||
|
raise ImportError(
|
||||||
|
"GitPython is not installed. Install with: pip install gitpython"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_remote = ifc_url.transport != "local"
|
||||||
|
|
||||||
|
if is_remote:
|
||||||
|
cache_dir = _cache_dir_for(ifc_url.git_remote_url())
|
||||||
|
genesis_file = cache_dir / "genesis_commit"
|
||||||
|
if genesis_file.exists():
|
||||||
|
return genesis_file.read_text().strip()
|
||||||
|
|
||||||
|
repo, _ = _get_repo(ifc_url, token=token)
|
||||||
|
hexsha = _compute_genesis(repo)
|
||||||
|
|
||||||
|
if is_remote:
|
||||||
|
genesis_file.write_text(hexsha)
|
||||||
|
|
||||||
|
return hexsha
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_genesis(repo: "git.Repo") -> str:
|
||||||
|
"""Return the hexsha of the root commit(s) reachable from HEAD.
|
||||||
|
|
||||||
|
For repos with a single root (the common case) this is unambiguous.
|
||||||
|
For repos with multiple roots (unrelated histories merged together)
|
||||||
|
the lexicographically smallest hexsha is returned for a stable result.
|
||||||
|
|
||||||
|
:raises ValueError: If the repository has no commits.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = repo.git.rev_list("HEAD", max_parents=0)
|
||||||
|
except git.exc.GitCommandError as exc:
|
||||||
|
raise ValueError(f"Cannot compute genesis commit: {exc.stderr.strip()}") from exc
|
||||||
|
|
||||||
|
hexshas = [h for h in result.strip().split("\n") if h]
|
||||||
|
if not hexshas:
|
||||||
|
raise ValueError("Repository has no commits")
|
||||||
|
return min(hexshas)
|
||||||
|
|
||||||
|
|
||||||
def _materialise(base_dir: Path, hexsha: str, file_path: str, data: bytes) -> Path:
|
def _materialise(base_dir: Path, hexsha: str, file_path: str, data: bytes) -> Path:
|
||||||
"""Write *data* to ``<base_dir>/<hexsha>/<basename>`` and return the path.
|
"""Write *data* to ``<base_dir>/<hexsha>/<basename>`` and return the path.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -562,3 +562,127 @@ class TestFetchIfcPath:
|
||||||
path2, _ = fetch_ifc_path(url)
|
path2, _ = fetch_ifc_path(url)
|
||||||
assert path1 != path2
|
assert path1 != path2
|
||||||
assert path2.read_bytes() == b"IFC4\nUPDATED\n"
|
assert path2.read_bytes() == b"IFC4\nUPDATED\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_genesis_commit — project identity key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from ifcurl.git import get_genesis_commit, _compute_genesis
|
||||||
|
|
||||||
|
|
||||||
|
def _make_repo(path: Path, filename: str = "model.ifc") -> "git.Repo":
|
||||||
|
"""Create a minimal git repo with one commit in *path*."""
|
||||||
|
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])
|
||||||
|
repo.index.commit("init")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
class TestComputeGenesis:
|
||||||
|
def test_returns_40_hex_chars(self, local_ifc_repo):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
repo = gitpkg.Repo(local_ifc_repo["path"])
|
||||||
|
hexsha = _compute_genesis(repo)
|
||||||
|
assert len(hexsha) == 40
|
||||||
|
assert all(c in "0123456789abcdef" for c in hexsha)
|
||||||
|
|
||||||
|
def test_single_commit_repo_genesis_is_head(self, local_ifc_repo):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
repo = gitpkg.Repo(local_ifc_repo["path"])
|
||||||
|
assert _compute_genesis(repo) == local_ifc_repo["hexsha"]
|
||||||
|
|
||||||
|
def test_genesis_unchanged_after_more_commits(self, tmp_path):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
repo = _make_repo(tmp_path / "repo")
|
||||||
|
genesis_before = _compute_genesis(repo)
|
||||||
|
|
||||||
|
(tmp_path / "repo" / "model.ifc").write_bytes(b"IFC4\nV2\n")
|
||||||
|
repo.index.add(["model.ifc"])
|
||||||
|
repo.index.commit("second")
|
||||||
|
|
||||||
|
assert _compute_genesis(repo) == genesis_before
|
||||||
|
|
||||||
|
def test_cloned_repo_same_genesis(self, tmp_path, local_ifc_repo):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
original = gitpkg.Repo(local_ifc_repo["path"])
|
||||||
|
fork = gitpkg.Repo.clone_from(local_ifc_repo["path"], str(tmp_path / "fork"))
|
||||||
|
assert _compute_genesis(fork) == _compute_genesis(original)
|
||||||
|
|
||||||
|
def test_unrelated_repos_differ(self, tmp_path, local_ifc_repo):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
unrelated = _make_repo(tmp_path / "other")
|
||||||
|
original = gitpkg.Repo(local_ifc_repo["path"])
|
||||||
|
assert _compute_genesis(unrelated) != _compute_genesis(original)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetGenesisCommit:
|
||||||
|
def test_local_repo(self, local_ifc_repo):
|
||||||
|
url = _local_url(local_ifc_repo["path"])
|
||||||
|
genesis = get_genesis_commit(url)
|
||||||
|
assert genesis == local_ifc_repo["hexsha"]
|
||||||
|
|
||||||
|
def test_forks_share_genesis(self, tmp_path, local_ifc_repo):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
gitpkg.Repo.clone_from(local_ifc_repo["path"], str(tmp_path / "fork"))
|
||||||
|
url_orig = _local_url(local_ifc_repo["path"])
|
||||||
|
url_fork = _local_url(str(tmp_path / "fork"))
|
||||||
|
assert get_genesis_commit(url_orig) == get_genesis_commit(url_fork)
|
||||||
|
|
||||||
|
def test_genesis_cached_for_remote_url(self, tmp_path, local_ifc_repo, monkeypatch):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
|
||||||
|
# Pre-populate bare cache as _open_remote would
|
||||||
|
remote_url = "https://example.com/org/repo"
|
||||||
|
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24]
|
||||||
|
cache_dir = tmp_path / url_hash
|
||||||
|
git_dir = cache_dir / "repo.git"
|
||||||
|
gitpkg.Repo.clone_from(local_ifc_repo["path"], str(git_dir), bare=True)
|
||||||
|
(cache_dir / "remote_url").write_text(remote_url)
|
||||||
|
|
||||||
|
url = IfcUrl.parse(f"ifc://example.com/org/repo@{local_ifc_repo['hexsha']}?path=model.ifc")
|
||||||
|
genesis = get_genesis_commit(url)
|
||||||
|
|
||||||
|
genesis_file = cache_dir / "genesis_commit"
|
||||||
|
assert genesis_file.exists()
|
||||||
|
assert genesis_file.read_text().strip() == genesis
|
||||||
|
|
||||||
|
def test_cache_reused_on_second_call(self, tmp_path, local_ifc_repo, monkeypatch):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
|
||||||
|
remote_url = "https://example.com/org/repo2"
|
||||||
|
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24]
|
||||||
|
cache_dir = tmp_path / url_hash
|
||||||
|
git_dir = cache_dir / "repo.git"
|
||||||
|
gitpkg.Repo.clone_from(local_ifc_repo["path"], str(git_dir), bare=True)
|
||||||
|
(cache_dir / "remote_url").write_text(remote_url)
|
||||||
|
|
||||||
|
url = IfcUrl.parse(f"ifc://example.com/org/repo2@{local_ifc_repo['hexsha']}?path=model.ifc")
|
||||||
|
genesis1 = get_genesis_commit(url)
|
||||||
|
|
||||||
|
# Overwrite the cache file with a sentinel — second call must return it
|
||||||
|
sentinel = "a" * 40
|
||||||
|
(cache_dir / "genesis_commit").write_text(sentinel)
|
||||||
|
genesis2 = get_genesis_commit(url)
|
||||||
|
|
||||||
|
assert genesis2 == sentinel # read from cache, not recomputed
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue