diff --git a/markitect/__version__.py b/markitect/__version__.py index 6c9e7a67..f535efd2 100644 --- a/markitect/__version__.py +++ b/markitect/__version__.py @@ -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 diff --git a/markitect/clean_document_manager.py b/markitect/clean_document_manager.py index a59d9803..b80a7cfd 100644 --- a/markitect/clean_document_manager.py +++ b/markitect/clean_document_manager.py @@ -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: diff --git a/markitect/cli.py b/markitect/cli.py index 322f085c..ffd7fa9e 100644 --- a/markitect/cli.py +++ b/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.")