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>
This commit is contained in:
@@ -8,11 +8,40 @@ 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
|
||||
|
||||
|
||||
def _git_version() -> str | None:
|
||||
# ── 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
|
||||
@@ -30,18 +59,6 @@ def _static_version() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
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 _resolve_version() -> str:
|
||||
"""Pick the best available version string.
|
||||
|
||||
@@ -59,88 +76,86 @@ def _resolve_version() -> str:
|
||||
__version__ = _resolve_version()
|
||||
|
||||
|
||||
def get_version():
|
||||
"""Get the current version string."""
|
||||
return __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__
|
||||
|
||||
|
||||
def _git_info() -> dict:
|
||||
"""Gather git metadata (commit, branch, tag)."""
|
||||
info: dict = {"is_git_repo": False}
|
||||
if not _is_git_repo():
|
||||
return info
|
||||
info["is_git_repo"] = True
|
||||
# ── Release status (used by ``markitect release``) ──────────────────────
|
||||
|
||||
def _run(*args: str) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", *args],
|
||||
cwd=_PROJECT_ROOT,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode().strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
def get_release_status() -> dict:
|
||||
"""Gather detailed release / development status.
|
||||
|
||||
info["git_commit"] = _run("rev-parse", "--short", "HEAD") or "unknown"
|
||||
info["git_branch"] = _run("rev-parse", "--abbrev-ref", "HEAD") or "unknown"
|
||||
info["git_tag"] = _run("describe", "--tags", "--exact-match", "HEAD")
|
||||
return info
|
||||
|
||||
|
||||
def get_version_info():
|
||||
"""Get comprehensive version information."""
|
||||
try:
|
||||
from release_management.utils.version import get_version_info as rm_get_version_info
|
||||
return rm_get_version_info(_PROJECT_ROOT)
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
git = _git_info()
|
||||
is_dev = ".dev" in __version__
|
||||
|
||||
return {
|
||||
Returns a dict with:
|
||||
version, commit, branch, tag, is_dev, is_dirty,
|
||||
commits_since_tag, changed_files, upstream_diff.
|
||||
"""
|
||||
info: dict = {
|
||||
"full_version": __version__,
|
||||
"short_version": __version__.split(".dev")[0] if is_dev else __version__,
|
||||
"is_dev": is_dev,
|
||||
"is_git_repo": git.get("is_git_repo", False),
|
||||
"git_commit": git.get("git_commit", "unknown"),
|
||||
"git_branch": git.get("git_branch", "unknown"),
|
||||
"git_tag": git.get("git_tag"),
|
||||
"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
|
||||
|
||||
def _normalize_release_info(raw):
|
||||
"""Ensure release info dict has the keys the CLI release command expects."""
|
||||
if "full_version" in raw:
|
||||
return raw
|
||||
info["commit"] = _git("rev-parse", "--short", "HEAD")
|
||||
info["branch"] = _git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
info["tag"] = _git("describe", "--tags", "--exact-match", "HEAD")
|
||||
|
||||
version = raw.get("version", "unknown")
|
||||
is_dev = raw.get("is_development", ".dev" in version)
|
||||
commit = raw.get("git_commit", "unknown")
|
||||
git = _git_info()
|
||||
# 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__
|
||||
|
||||
return {
|
||||
"full_version": version,
|
||||
"release_type": "development" if is_dev else "release",
|
||||
"build_from": "git" if git.get("is_git_repo") else "source",
|
||||
"commit": commit,
|
||||
"clean_build": not is_dev,
|
||||
"is_git_repo": git.get("is_git_repo", False),
|
||||
"git_tag": git.get("git_tag"),
|
||||
}
|
||||
# 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
|
||||
|
||||
def get_release_info():
|
||||
"""Get release information."""
|
||||
try:
|
||||
from release_management.utils.version import get_release_info as rm_get_release_info
|
||||
return _normalize_release_info(rm_get_release_info(_PROJECT_ROOT))
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
# Changed files (staged + unstaged + untracked).
|
||||
status_out = _git("status", "--porcelain")
|
||||
if status_out:
|
||||
info["changed_files"] = status_out.splitlines()
|
||||
else:
|
||||
info["changed_files"] = []
|
||||
|
||||
info = get_version_info()
|
||||
return _normalize_release_info({
|
||||
"version": info["full_version"],
|
||||
"is_development": info["is_dev"],
|
||||
"git_commit": info.get("git_commit", "unknown"),
|
||||
})
|
||||
# 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
|
||||
|
||||
@@ -184,15 +184,12 @@ class CleanDocumentManager:
|
||||
|
||||
def _get_version_info(self) -> dict:
|
||||
"""Get repository name and version information."""
|
||||
from .__version__ import get_version_info
|
||||
from .__version__ import get_version
|
||||
|
||||
version_info = get_version_info()
|
||||
|
||||
# Transform to the format expected by the editor
|
||||
return {
|
||||
'repo_name': 'Markitect',
|
||||
'version': version_info['full_version'],
|
||||
'git_info': '' # Already included in full_version
|
||||
'version': get_version(),
|
||||
'git_info': '',
|
||||
}
|
||||
|
||||
def _get_template_css(self, template: str = None, image_max_width: str = '12cm', image_max_height: str = '20cm') -> str:
|
||||
|
||||
128
markitect/cli.py
128
markitect/cli.py
@@ -28,7 +28,7 @@ import builtins
|
||||
|
||||
from .database import DatabaseManager
|
||||
from .legacy_compat import LegacyMode, emit_deprecation_warning, legacy_switch_option
|
||||
from .__version__ import get_version_info, get_release_info
|
||||
from .__version__ import get_version, get_release_status
|
||||
from .batch_processor import BatchProcessor, ProcessingMode, ErrorHandling, create_file_processor
|
||||
from .config_manager import ConfigurationManager
|
||||
|
||||
@@ -201,8 +201,7 @@ def print_version(ctx, param, value):
|
||||
"""Callback to print version and exit."""
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
version_info = get_version_info()
|
||||
click.echo(version_info['full_version'])
|
||||
click.echo(get_version())
|
||||
ctx.exit()
|
||||
|
||||
@click.group()
|
||||
@@ -253,59 +252,98 @@ def cli(config, verbose, database, config_file):
|
||||
# Version and release information commands
|
||||
|
||||
@cli.command()
|
||||
@click.option('--short', is_flag=True, help='Show only version number')
|
||||
def version(short):
|
||||
"""Show MarkiTect version information."""
|
||||
version_info = get_version_info()
|
||||
@click.option('--verbose', '-v', 'verbose', is_flag=True,
|
||||
help='Include commit and branch details.')
|
||||
def version(verbose):
|
||||
"""Print the version number.
|
||||
|
||||
if short:
|
||||
click.echo(version_info['full_version'])
|
||||
\b
|
||||
Behaves like standard Unix tools: bare version string by default,
|
||||
additional detail with -v.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
markitect version # 0.12.0
|
||||
markitect version -v # 0.12.0 (be3b4e3 on main)
|
||||
"""
|
||||
ver = get_version()
|
||||
if not verbose:
|
||||
click.echo(ver)
|
||||
else:
|
||||
click.echo("MarkiTect Version Information")
|
||||
click.echo("============================")
|
||||
click.echo(f"Version: {version_info['full_version']}")
|
||||
click.echo(f"Short Version: {version_info['short_version']}")
|
||||
|
||||
if version_info.get('is_git_repo'):
|
||||
click.echo(f"Git Commit: {version_info.get('git_commit', 'N/A')}")
|
||||
click.echo(f"Git Branch: {version_info.get('git_branch', 'N/A')}")
|
||||
if version_info.get('git_tag'):
|
||||
click.echo(f"Git Tag: {version_info['git_tag']}")
|
||||
click.echo(f"Development Build: {'Yes' if version_info.get('is_dev') else 'No'}")
|
||||
else:
|
||||
click.echo("Git Repository: Not available")
|
||||
status = get_release_status()
|
||||
parts = [ver]
|
||||
if status.get("commit"):
|
||||
parts.append(f"({status['commit']} on {status.get('branch', '?')})")
|
||||
if status.get("is_dirty"):
|
||||
parts.append("[dirty]")
|
||||
click.echo(" ".join(parts))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--format', 'output_format', default='text',
|
||||
type=click.Choice(['text', 'json', 'yaml']),
|
||||
help='Output format (text, json, yaml)')
|
||||
type=click.Choice(['text', 'json']),
|
||||
help='Output format.')
|
||||
def release(output_format):
|
||||
"""Show MarkiTect release information."""
|
||||
release_info = get_release_info()
|
||||
"""Show detailed release and development status.
|
||||
|
||||
\b
|
||||
Reports what has changed since the last tagged version:
|
||||
commits, local modifications, and upstream divergence.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
markitect release
|
||||
markitect release --format json
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
status = get_release_status()
|
||||
|
||||
if output_format == 'json':
|
||||
import json
|
||||
click.echo(json.dumps(release_info, indent=2))
|
||||
elif output_format == 'yaml':
|
||||
import yaml
|
||||
click.echo(yaml.dump(release_info, default_flow_style=False))
|
||||
else:
|
||||
# Text format
|
||||
click.echo("MarkiTect Release Information")
|
||||
click.echo("============================")
|
||||
click.echo(f"Version: {release_info['full_version']}")
|
||||
click.echo(f"Release Type: {release_info['release_type']}")
|
||||
click.echo(f"Build From: {release_info['build_from']}")
|
||||
click.echo(f"Commit: {release_info['commit']}")
|
||||
click.echo(f"Clean Build: {'Yes' if release_info['clean_build'] else 'No'}")
|
||||
click.echo(_json.dumps(status, indent=2))
|
||||
return
|
||||
|
||||
if release_info['is_git_repo']:
|
||||
click.echo(f"Git Repository: Available")
|
||||
if release_info['git_tag']:
|
||||
click.echo(f"Tagged Release: {release_info['git_tag']}")
|
||||
# ── Header ──
|
||||
tag_label = status.get("tag") or "(none)"
|
||||
dirty = " [dirty]" if status.get("is_dirty") else ""
|
||||
click.echo(f"Version: {status['version']}")
|
||||
click.echo(f"Commit: {status.get('commit', 'N/A')}")
|
||||
click.echo(f"Branch: {status.get('branch', 'N/A')}")
|
||||
click.echo(f"Tag: {tag_label}")
|
||||
|
||||
# ── Commits since tag ──
|
||||
n = status.get("commits_since_tag", 0)
|
||||
if n == 0 and status.get("tag"):
|
||||
click.echo(f"Status: release (tagged){dirty}")
|
||||
else:
|
||||
click.echo(f"Status: development ({n} commit{'s' if n != 1 else ''} since last tag){dirty}")
|
||||
|
||||
# ── Upstream comparison ──
|
||||
upstream = status.get("upstream_diff")
|
||||
if upstream:
|
||||
ahead = upstream["ahead"]
|
||||
behind = upstream["behind"]
|
||||
tracking = upstream["tracking"]
|
||||
if ahead == 0 and behind == 0:
|
||||
click.echo(f"Upstream: up to date with {tracking}")
|
||||
else:
|
||||
click.echo("Git Repository: Not available")
|
||||
parts = []
|
||||
if ahead:
|
||||
parts.append(f"{ahead} ahead")
|
||||
if behind:
|
||||
parts.append(f"{behind} behind")
|
||||
click.echo(f"Upstream: {', '.join(parts)} ({tracking})")
|
||||
else:
|
||||
click.echo("Upstream: no tracking branch")
|
||||
|
||||
# ── Changed files ──
|
||||
changed = status.get("changed_files", [])
|
||||
if changed:
|
||||
click.echo(f"\nLocal changes ({len(changed)} file{'s' if len(changed) != 1 else ''}):")
|
||||
for line in changed:
|
||||
click.echo(f" {line}")
|
||||
elif not status.get("is_git_repo"):
|
||||
click.echo("\nNot a git repository — no change tracking available.")
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user