mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
Add checkout.py: writable working-tree checkouts from bare cache
Implements ifcurl-5oo. get_checkout() ensures the bare cache is current via fetch_ifc(), then clones it locally to <cache_dir>/checkout/ (no network), and places it at the resolved commit as a detached HEAD — forcing branch creation before any commit. Viewer artefact patterns (*.ifcview, *.rdbview, etc.) are appended to .git/info/exclude so they are locally ignored without touching the repo's own .gitignore. When a newer commit is requested, the checkout fetches from origin (the local bare cache) using the default refspec to avoid git's "refusing to fetch into checked-out branch" error. Unblocks the JSON-RPC connector (ifcurl-242). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
988eed5a93
commit
caf303d3ed
4 changed files with 428 additions and 3 deletions
|
|
@ -8,8 +8,8 @@
|
||||||
{"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":"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-egi","title":"Local repo discovery: scan configured paths for repos matching genesis commit","description":"When opening an ifc:// URL, scan user-configured search paths (e.g. ~/projects/) for local git repos whose genesis commit matches the project being opened. If a match is found, that local repo becomes the push target (origin) for the working checkout. Search paths are configured in ~/.config/ifcurl/config.toml under [repo_search] paths. Results are cached per genesis commit to avoid repeated scanning.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:54Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:05:37Z","started_at":"2026-06-05T22:00:19Z","closed_at":"2026-06-05T22:05:37Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-egi","depends_on_id":"ifcurl-4d4","type":"blocks","created_at":"2026-06-05T15:43:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
{"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"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":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:08:23Z","started_at":"2026-06-05T22:08:23Z","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
{"id":"ifcurl-4d4","title":"Genesis commit identity: compute and cache root commit hash as project key","description":"To identify that https and SSH forms of the same repo, and forks of the same project, are all 'the same project', we use the genesis commit (git rev-list --max-parents=0 HEAD) as a stable project identity key. Store the genesis commit hash alongside each bare cache clone. This key is used by local repo discovery and origin remapping.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:50Z","created_by":"Bruno Postle","updated_at":"2026-06-05T17:32:58Z","started_at":"2026-06-05T17:29:24Z","closed_at":"2026-06-05T17:32:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0}
|
{"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}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@
|
||||||
# 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.checkout import get_checkout
|
||||||
from ifcurl.discover import find_local_repos
|
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", "find_local_repos"]
|
__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes", "fetch_ifc_path", "get_genesis_commit", "find_local_repos", "get_checkout"]
|
||||||
|
|
|
||||||
174
ifcurl/checkout.py
Normal file
174
ifcurl/checkout.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
"""Full working-tree checkouts for ifc:// URLs.
|
||||||
|
|
||||||
|
The bare cache clone in :mod:`ifcurl.git` is read-only by design. When a
|
||||||
|
tool (Bonsai, ifcviewer) needs an actual file path it can open and later
|
||||||
|
commit changes to, this module provides a writable checkout alongside the
|
||||||
|
bare clone.
|
||||||
|
|
||||||
|
Layout inside the per-URL cache directory::
|
||||||
|
|
||||||
|
~/.cache/ifcurl/<url-hash>/
|
||||||
|
repo.git/ ← bare clone (fetch/read layer, managed by git.py)
|
||||||
|
checkout/ ← full working tree (this module)
|
||||||
|
|
||||||
|
The checkout is cloned locally from the bare repo — no extra network
|
||||||
|
traffic — and placed at the requested commit as a **detached HEAD**. A
|
||||||
|
detached HEAD forces the user to create a named branch before committing,
|
||||||
|
which is the intended workflow: edit in the checkout, branch, push to a
|
||||||
|
local working repo (see :mod:`ifcurl.discover` and the origin-remapping
|
||||||
|
issue ifcurl-oef).
|
||||||
|
|
||||||
|
Viewer artefacts (``*.ifcview``, ``*.rdbview``, etc.) are added to
|
||||||
|
``.git/info/exclude`` so they are never accidentally staged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_VIEWER_EXCLUDES = [
|
||||||
|
"# ifcurl: viewer artefacts — do not commit",
|
||||||
|
"*.ifcview",
|
||||||
|
"*.rdbview",
|
||||||
|
"*.rdb",
|
||||||
|
"*.sqlite",
|
||||||
|
"*.sqlite-wal",
|
||||||
|
"*.sqlite-shm",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import git
|
||||||
|
import git.exc
|
||||||
|
|
||||||
|
_HAS_GITPYTHON = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_GITPYTHON = False
|
||||||
|
|
||||||
|
from ifcurl.git import _cache_dir_for, fetch_ifc
|
||||||
|
from ifcurl.url import IfcUrl
|
||||||
|
|
||||||
|
|
||||||
|
def get_checkout(ifc_url: IfcUrl, token: str | None = None) -> Path:
|
||||||
|
"""Return the path to the IFC file within a full working-tree checkout.
|
||||||
|
|
||||||
|
Ensures the bare cache clone is up to date, then creates or updates a
|
||||||
|
working checkout at ``<cache_dir>/checkout/``. The checkout is placed
|
||||||
|
at the resolved commit as a detached HEAD so the user must create a
|
||||||
|
branch before committing any edits.
|
||||||
|
|
||||||
|
Viewer artefact patterns are written to ``.git/info/exclude`` on first
|
||||||
|
creation so they are locally ignored without modifying the repo's own
|
||||||
|
``.gitignore``.
|
||||||
|
|
||||||
|
:param ifc_url: A parsed :class:`IfcUrl` with ``transport != 'local'``
|
||||||
|
and a ``path`` parameter set.
|
||||||
|
:param token: Optional bearer token for HTTPS authentication.
|
||||||
|
:raises ImportError: If GitPython is not installed.
|
||||||
|
:raises ValueError: For local-transport URLs (no checkout needed) or
|
||||||
|
when ``ifc_url.path`` is unset.
|
||||||
|
"""
|
||||||
|
if not _HAS_GITPYTHON:
|
||||||
|
raise ImportError(
|
||||||
|
"GitPython is not installed. Install with: pip install gitpython"
|
||||||
|
)
|
||||||
|
if ifc_url.transport == "local":
|
||||||
|
raise ValueError(
|
||||||
|
"get_checkout is not applicable to local-transport URLs; "
|
||||||
|
"access the file directly from the working tree."
|
||||||
|
)
|
||||||
|
if ifc_url.path is None:
|
||||||
|
raise ValueError("URL has no 'path' parameter — cannot locate IFC file")
|
||||||
|
|
||||||
|
hexsha, _, _ = fetch_ifc(ifc_url, token=token)
|
||||||
|
cache_dir = _cache_dir_for(ifc_url.git_remote_url())
|
||||||
|
checkout_dir = _ensure_checkout(cache_dir, hexsha)
|
||||||
|
_ensure_viewer_excludes(checkout_dir)
|
||||||
|
return checkout_dir / ifc_url.path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_checkout(cache_dir: Path, hexsha: str) -> Path:
|
||||||
|
"""Create or update the working checkout to *hexsha*; return its path.
|
||||||
|
|
||||||
|
Clones from the bare cache if the checkout does not yet exist. Then
|
||||||
|
checks out *hexsha* as a detached HEAD. If *hexsha* is absent from the
|
||||||
|
checkout's local objects, fetches from origin (the bare cache, a local
|
||||||
|
path — no network) before retrying.
|
||||||
|
"""
|
||||||
|
checkout_dir = cache_dir / "checkout"
|
||||||
|
bare_dir = cache_dir / "repo.git"
|
||||||
|
|
||||||
|
if not checkout_dir.exists():
|
||||||
|
_logger.debug("Creating checkout at %s", checkout_dir)
|
||||||
|
git.Repo.clone_from(str(bare_dir), str(checkout_dir))
|
||||||
|
|
||||||
|
repo = git.Repo(str(checkout_dir))
|
||||||
|
|
||||||
|
# Fast path: already detached at the right commit.
|
||||||
|
try:
|
||||||
|
if repo.head.is_detached and repo.head.commit.hexsha == hexsha:
|
||||||
|
return checkout_dir
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo.git.checkout("--detach", hexsha)
|
||||||
|
except git.exc.GitCommandError:
|
||||||
|
# Commit may not be in the checkout yet (bare cache received it after
|
||||||
|
# the checkout was cloned). Fetch from origin (bare cache, local path
|
||||||
|
# — no network). Use default refspec so git maps heads/* to
|
||||||
|
# remotes/origin/*, avoiding the "refusing to fetch into checked-out
|
||||||
|
# branch" error when a branch is currently active in the checkout.
|
||||||
|
_logger.debug("Commit %s absent from checkout; fetching from bare cache", hexsha[:8])
|
||||||
|
repo.git.fetch("origin")
|
||||||
|
repo.git.checkout("--detach", hexsha)
|
||||||
|
|
||||||
|
return checkout_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_viewer_excludes(checkout_dir: Path) -> None:
|
||||||
|
"""Append missing viewer-artefact patterns to ``.git/info/exclude``.
|
||||||
|
|
||||||
|
Uses the local exclude file rather than ``.gitignore`` so the patterns
|
||||||
|
are never committed and do not conflict with the project's own ignore
|
||||||
|
rules.
|
||||||
|
"""
|
||||||
|
exclude_file = checkout_dir / ".git" / "info" / "exclude"
|
||||||
|
exclude_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
existing = exclude_file.read_text() if exclude_file.exists() else ""
|
||||||
|
missing = [line for line in _VIEWER_EXCLUDES if line not in existing]
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(exclude_file, "a") as f:
|
||||||
|
if existing and not existing.endswith("\n"):
|
||||||
|
f.write("\n")
|
||||||
|
for line in missing:
|
||||||
|
f.write(line + "\n")
|
||||||
250
tests/test_checkout.py
Normal file
250
tests/test_checkout.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""Tests for ifcurl.checkout — working-tree checkout management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ifcurl.checkout import _ensure_checkout, _ensure_viewer_excludes, _VIEWER_EXCLUDES, get_checkout
|
||||||
|
from ifcurl.url import IfcUrl
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_bare_cache(tmp_path: Path, remote_url: str, ifc_bytes: bytes = b"IFC4\n") -> dict:
|
||||||
|
"""Create a source repo + bare cache clone; return metadata dict."""
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
src = tmp_path / "source"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "model.ifc").write_bytes(ifc_bytes)
|
||||||
|
repo = gitpkg.Repo.init(str(src))
|
||||||
|
with repo.config_writer() as cw:
|
||||||
|
cw.set_value("user", "name", "Test")
|
||||||
|
cw.set_value("user", "email", "t@t.com")
|
||||||
|
repo.index.add(["model.ifc"])
|
||||||
|
commit = repo.index.commit("init")
|
||||||
|
|
||||||
|
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24]
|
||||||
|
cache_dir = tmp_path / url_hash
|
||||||
|
bare_dir = cache_dir / "repo.git"
|
||||||
|
gitpkg.Repo.clone_from(str(src), str(bare_dir), bare=True)
|
||||||
|
(cache_dir / "remote_url").write_text(remote_url)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hexsha": commit.hexsha,
|
||||||
|
"bytes": ifc_bytes,
|
||||||
|
"cache_dir": cache_dir,
|
||||||
|
"bare_dir": bare_dir,
|
||||||
|
"src": src,
|
||||||
|
"remote_url": remote_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ifc_url(hexsha: str, remote_url: str, path: str = "model.ifc") -> IfcUrl:
|
||||||
|
"""Build an IfcUrl for a pre-populated cache (https transport, commit ref)."""
|
||||||
|
host_path = remote_url.removeprefix("https://")
|
||||||
|
return IfcUrl.parse(f"ifc://{host_path}@{hexsha}?path={path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _ensure_viewer_excludes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureViewerExcludes:
|
||||||
|
def test_creates_exclude_file(self, tmp_path):
|
||||||
|
fake_checkout = tmp_path / "checkout"
|
||||||
|
(fake_checkout / ".git" / "info").mkdir(parents=True)
|
||||||
|
_ensure_viewer_excludes(fake_checkout)
|
||||||
|
assert (fake_checkout / ".git" / "info" / "exclude").exists()
|
||||||
|
|
||||||
|
def test_patterns_present(self, tmp_path):
|
||||||
|
fake_checkout = tmp_path / "checkout"
|
||||||
|
(fake_checkout / ".git" / "info").mkdir(parents=True)
|
||||||
|
_ensure_viewer_excludes(fake_checkout)
|
||||||
|
content = (fake_checkout / ".git" / "info" / "exclude").read_text()
|
||||||
|
assert "*.ifcview" in content
|
||||||
|
assert "*.rdbview" in content
|
||||||
|
|
||||||
|
def test_idempotent(self, tmp_path):
|
||||||
|
fake_checkout = tmp_path / "checkout"
|
||||||
|
(fake_checkout / ".git" / "info").mkdir(parents=True)
|
||||||
|
_ensure_viewer_excludes(fake_checkout)
|
||||||
|
_ensure_viewer_excludes(fake_checkout)
|
||||||
|
content = (fake_checkout / ".git" / "info" / "exclude").read_text()
|
||||||
|
assert content.count("*.ifcview") == 1
|
||||||
|
|
||||||
|
def test_appends_to_existing_file(self, tmp_path):
|
||||||
|
fake_checkout = tmp_path / "checkout"
|
||||||
|
(fake_checkout / ".git" / "info").mkdir(parents=True)
|
||||||
|
exclude = fake_checkout / ".git" / "info" / "exclude"
|
||||||
|
exclude.write_text("# existing\n*.log\n")
|
||||||
|
_ensure_viewer_excludes(fake_checkout)
|
||||||
|
content = exclude.read_text()
|
||||||
|
assert "*.log" in content
|
||||||
|
assert "*.ifcview" in content
|
||||||
|
|
||||||
|
def test_does_not_duplicate_existing_pattern(self, tmp_path):
|
||||||
|
fake_checkout = tmp_path / "checkout"
|
||||||
|
(fake_checkout / ".git" / "info").mkdir(parents=True)
|
||||||
|
exclude = fake_checkout / ".git" / "info" / "exclude"
|
||||||
|
exclude.write_text("*.ifcview\n")
|
||||||
|
_ensure_viewer_excludes(fake_checkout)
|
||||||
|
assert exclude.read_text().count("*.ifcview") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _ensure_checkout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureCheckout:
|
||||||
|
def test_creates_checkout_dir(self, tmp_path):
|
||||||
|
meta = _make_bare_cache(tmp_path, "https://example.com/org/repo")
|
||||||
|
checkout_dir = _ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
assert checkout_dir.is_dir()
|
||||||
|
|
||||||
|
def test_checkout_is_detached_head(self, tmp_path):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
meta = _make_bare_cache(tmp_path, "https://example.com/org/repo")
|
||||||
|
checkout_dir = _ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
repo = gitpkg.Repo(str(checkout_dir))
|
||||||
|
assert repo.head.is_detached
|
||||||
|
|
||||||
|
def test_checkout_at_correct_commit(self, tmp_path):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
meta = _make_bare_cache(tmp_path, "https://example.com/org/repo")
|
||||||
|
checkout_dir = _ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
repo = gitpkg.Repo(str(checkout_dir))
|
||||||
|
assert repo.head.commit.hexsha == meta["hexsha"]
|
||||||
|
|
||||||
|
def test_file_present_in_checkout(self, tmp_path):
|
||||||
|
meta = _make_bare_cache(tmp_path, "https://example.com/org/repo")
|
||||||
|
checkout_dir = _ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
assert (checkout_dir / "model.ifc").exists()
|
||||||
|
|
||||||
|
def test_idempotent_same_commit(self, tmp_path):
|
||||||
|
meta = _make_bare_cache(tmp_path, "https://example.com/org/repo")
|
||||||
|
d1 = _ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
d2 = _ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
assert d1 == d2
|
||||||
|
|
||||||
|
def test_updates_to_new_commit(self, tmp_path):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
meta = _make_bare_cache(tmp_path, "https://example.com/org/repo")
|
||||||
|
_ensure_checkout(meta["cache_dir"], meta["hexsha"])
|
||||||
|
|
||||||
|
# Add a new commit to the bare cache
|
||||||
|
src_repo = gitpkg.Repo(str(meta["src"]))
|
||||||
|
(meta["src"] / "model.ifc").write_bytes(b"IFC4\nV2\n")
|
||||||
|
src_repo.index.add(["model.ifc"])
|
||||||
|
new_commit = src_repo.index.commit("update")
|
||||||
|
bare = gitpkg.Repo(str(meta["bare_dir"]))
|
||||||
|
bare.git.fetch(str(meta["src"]), "+refs/*:refs/*")
|
||||||
|
|
||||||
|
checkout_dir = _ensure_checkout(meta["cache_dir"], new_commit.hexsha)
|
||||||
|
repo = gitpkg.Repo(str(checkout_dir))
|
||||||
|
assert repo.head.commit.hexsha == new_commit.hexsha
|
||||||
|
assert (checkout_dir / "model.ifc").read_bytes() == b"IFC4\nV2\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_checkout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCheckout:
|
||||||
|
def _url_and_cache(self, tmp_path, monkeypatch, remote_url="https://example.com/org/repo"):
|
||||||
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
meta = _make_bare_cache(tmp_path, remote_url)
|
||||||
|
url = _ifc_url(meta["hexsha"], remote_url)
|
||||||
|
return url, meta
|
||||||
|
|
||||||
|
def test_returns_path(self, tmp_path, monkeypatch):
|
||||||
|
url, _ = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
result = get_checkout(url)
|
||||||
|
assert isinstance(result, Path)
|
||||||
|
|
||||||
|
def test_file_exists(self, tmp_path, monkeypatch):
|
||||||
|
url, _ = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
result = get_checkout(url)
|
||||||
|
assert result.exists()
|
||||||
|
|
||||||
|
def test_file_content_correct(self, tmp_path, monkeypatch):
|
||||||
|
url, meta = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
result = get_checkout(url)
|
||||||
|
assert result.read_bytes() == meta["bytes"]
|
||||||
|
|
||||||
|
def test_path_ends_with_ifc_filename(self, tmp_path, monkeypatch):
|
||||||
|
url, _ = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
result = get_checkout(url)
|
||||||
|
assert result.name == "model.ifc"
|
||||||
|
|
||||||
|
def test_checkout_is_detached_head(self, tmp_path, monkeypatch):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
url, meta = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
get_checkout(url)
|
||||||
|
checkout_dir = meta["cache_dir"] / "checkout"
|
||||||
|
repo = gitpkg.Repo(str(checkout_dir))
|
||||||
|
assert repo.head.is_detached
|
||||||
|
|
||||||
|
def test_viewer_excludes_written(self, tmp_path, monkeypatch):
|
||||||
|
url, meta = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
get_checkout(url)
|
||||||
|
exclude = meta["cache_dir"] / "checkout" / ".git" / "info" / "exclude"
|
||||||
|
content = exclude.read_text()
|
||||||
|
assert "*.ifcview" in content
|
||||||
|
assert "*.rdbview" in content
|
||||||
|
|
||||||
|
def test_idempotent(self, tmp_path, monkeypatch):
|
||||||
|
url, _ = self._url_and_cache(tmp_path, monkeypatch)
|
||||||
|
p1 = get_checkout(url)
|
||||||
|
p2 = get_checkout(url)
|
||||||
|
assert p1 == p2
|
||||||
|
|
||||||
|
def test_nested_ifc_path(self, tmp_path, monkeypatch):
|
||||||
|
import git as gitpkg
|
||||||
|
|
||||||
|
remote_url = "https://example.com/org/repo2"
|
||||||
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
|
||||||
|
src = tmp_path / "source2"
|
||||||
|
(src / "models").mkdir(parents=True)
|
||||||
|
(src / "models" / "structure.ifc").write_bytes(b"IFC4\n")
|
||||||
|
repo = gitpkg.Repo.init(str(src))
|
||||||
|
with repo.config_writer() as cw:
|
||||||
|
cw.set_value("user", "name", "Test")
|
||||||
|
cw.set_value("user", "email", "t@t.com")
|
||||||
|
repo.index.add(["models/structure.ifc"])
|
||||||
|
commit = repo.index.commit("init")
|
||||||
|
|
||||||
|
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24]
|
||||||
|
cache_dir = tmp_path / url_hash
|
||||||
|
gitpkg.Repo.clone_from(str(src), str(cache_dir / "repo.git"), bare=True)
|
||||||
|
(cache_dir / "remote_url").write_text(remote_url)
|
||||||
|
|
||||||
|
url = IfcUrl.parse(f"ifc://example.com/org/repo2@{commit.hexsha}?path=models/structure.ifc")
|
||||||
|
result = get_checkout(url)
|
||||||
|
assert result.name == "structure.ifc"
|
||||||
|
assert result.exists()
|
||||||
|
|
||||||
|
def test_local_transport_raises(self, tmp_path):
|
||||||
|
url = IfcUrl.parse(f"ifc://{tmp_path}@HEAD?path=model.ifc")
|
||||||
|
with pytest.raises(ValueError, match="local"):
|
||||||
|
get_checkout(url)
|
||||||
|
|
||||||
|
def test_no_path_raises(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
|
url = IfcUrl.parse("ifc://example.com/org/repo@abc123def")
|
||||||
|
with pytest.raises(ValueError, match="'path'"):
|
||||||
|
get_checkout(url)
|
||||||
Loading…
Add table
Reference in a new issue