""" Version information for MarkiTect. Uses setuptools-scm for version derivation. When running from a dev checkout (git repo present), the version is resolved dynamically from git tags so it stays correct even without ``pip install -e .``. """ import subprocess from pathlib import Path from typing import Optional _PROJECT_ROOT = Path(__file__).parent.parent # ── Low-level helpers ──────────────────────────────────────────────────── def _is_git_repo() -> bool: try: subprocess.check_output( ["git", "rev-parse", "--git-dir"], cwd=_PROJECT_ROOT, stderr=subprocess.DEVNULL, ) return True except (subprocess.CalledProcessError, FileNotFoundError): return False def _git(*args: str) -> Optional[str]: """Run a git command and return stripped stdout, or None on failure.""" try: return subprocess.check_output( ["git", *args], cwd=_PROJECT_ROOT, stderr=subprocess.DEVNULL, ).decode().strip() or None except (subprocess.CalledProcessError, FileNotFoundError): return None # ── Version resolution ─────────────────────────────────────────────────── def _git_version() -> Optional[str]: """Derive version from git via setuptools-scm (runtime, no install needed).""" try: from setuptools_scm import get_version return get_version(root=str(_PROJECT_ROOT)) except Exception: return None def _static_version() -> str: """Read the version baked into _version.py at install time.""" try: from ._version import version return version except ImportError: return "unknown" def _resolve_version() -> str: """Pick the best available version string. In a git checkout, setuptools-scm gives the authoritative answer (reflects tags even before reinstall). Otherwise fall back to the static _version.py produced at install time. """ if _is_git_repo(): v = _git_version() if v: return v return _static_version() __version__ = _resolve_version() def get_version() -> str: """Return a clean version string (for ``markitect version``). On a tagged commit returns the tag (e.g. ``0.12.0``). Otherwise returns the base version without dev/local suffixes. Matches Unix convention: ``tool --version`` prints a stable string. """ if _is_git_repo(): tag = _git("describe", "--tags", "--exact-match", "HEAD") if tag: return tag.lstrip("v") # Strip .devN+local from scm version. return __version__.split(".dev")[0] if ".dev" in __version__ else __version__ # ── Release status (used by ``markitect release``) ────────────────────── def get_release_status() -> dict: """Gather detailed release / development status. Returns a dict with: version, commit, branch, tag, is_dev, is_dirty, commits_since_tag, changed_files, upstream_diff. """ info: dict = { "full_version": __version__, "is_git_repo": _is_git_repo(), } if not info["is_git_repo"]: info.update( version=__version__, commit=None, branch=None, tag=None, is_dev=".dev" in __version__, is_dirty=False, commits_since_tag=0, changed_files=[], upstream_diff=None, ) return info info["commit"] = _git("rev-parse", "--short", "HEAD") info["branch"] = _git("rev-parse", "--abbrev-ref", "HEAD") info["tag"] = _git("describe", "--tags", "--exact-match", "HEAD") # Authoritative version: tag if present, otherwise scm-derived base. if info["tag"]: info["version"] = info["tag"].lstrip("v") else: info["version"] = __version__.split(".dev")[0] if ".dev" in __version__ else __version__ info["is_dev"] = info["tag"] is None or ".dev" in __version__ # Dirty working tree? info["is_dirty"] = _git("status", "--porcelain") is not None # Commits since last tag. describe = _git("describe", "--tags", "--long") if describe: # format: v0.12.0-3-gabcdef → 3 commits since tag parts = describe.rsplit("-", 2) info["commits_since_tag"] = int(parts[1]) if len(parts) == 3 else 0 else: # No tags at all — count all commits. count = _git("rev-list", "--count", "HEAD") info["commits_since_tag"] = int(count) if count else 0 # Changed files (staged + unstaged + untracked). status_out = _git("status", "--porcelain") if status_out: info["changed_files"] = status_out.splitlines() else: info["changed_files"] = [] # Upstream comparison. tracking = _git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") if tracking: ahead = _git("rev-list", "--count", f"{tracking}..HEAD") behind = _git("rev-list", "--count", f"HEAD..{tracking}") info["upstream_diff"] = { "tracking": tracking, "ahead": int(ahead) if ahead else 0, "behind": int(behind) if behind else 0, } else: info["upstream_diff"] = None return info