Files
markitect-main/markitect/__version__.py
tegwick 69aea1ada7 refactor(version): separate version and release commands
`markitect version` now prints a clean version string (Unix style),
with -v for commit/branch/dirty. `markitect release` shows detailed
development status: commits since tag, local changes, upstream
divergence. No overlap between the two commands.

Replaces get_version_info()/get_release_info() with get_version()
and get_release_status(). Drops yaml output format from release
(json + text sufficient).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:49:14 +01:00

162 lines
5.3 KiB
Python

"""
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