`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>
162 lines
5.3 KiB
Python
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
|