diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 702dd98a..f8894256 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -43,6 +43,7 @@ Health check. **This is the only route exempt from authentication**: you can cal { "status": "ok", "version": "5.3.0", + "commit": "a24c9b9", "update_check": { "update_available": false, "current_version": "5.3.0", @@ -51,6 +52,8 @@ Health check. **This is the only route exempt from authentication**: you can cal } ``` +`commit` is the short git commit of the running checkout. It is `null` when the server cannot read one (for example, an installed package with no `.git` directory, or a host without git). + If the update check fails (for example, no network), `update_available` and `latest_version` are `null` while `current_version` still reports the installed version. | Status | Meaning | diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 383850e3..70cfa4fe 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -74,7 +74,7 @@ For example, `unshackle_debug_EXAMPLE_20260703-142530.jsonl`. The `` ta Every line is a self-contained JSON object. The first line of a session records the environment: ```json -{"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}} +{"timestamp":"2026-07-03T14:25:30.123456+00:00","session_id":"a1b2c3d4","level":"INFO","operation":"session_start","message":"Debug logging session started","context":{"unshackle_version":"5.3.0","unshackle_commit":"a24c9b9","python_version":"3.12.4 ...","platform":"Linux-6.18.5-x86_64","platform_system":"Linux","platform_release":"6.18.5"}} ``` Subsequent lines describe operations: service calls, DRM licence requests, vault lookups, downloader progress, and errors. Common fields include: diff --git a/tests/core/test_git_commit.py b/tests/core/test_git_commit.py new file mode 100644 index 00000000..f375ceab --- /dev/null +++ b/tests/core/test_git_commit.py @@ -0,0 +1,57 @@ +"""Unit tests for the git commit probe behind the banner and the /api/health `commit` field. +The probe runs at import time, so it must always return a string and must never raise, whatever +state git and the checkout are in.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +import unshackle.core +from unshackle.core import _git_commit + +pytestmark = pytest.mark.unit + +# Without a .git the probe returns before it runs git, so the subprocess stubs below prove nothing. +needs_checkout = pytest.mark.skipif( + not (Path(unshackle.core.__file__).parents[2] / ".git").exists(), reason="not running from a git checkout" +) + + +def test_returns_str_in_this_checkout() -> None: + assert isinstance(_git_commit(), str) + + +@pytest.mark.parametrize( + "error", + [ + FileNotFoundError("git"), + PermissionError("git"), + subprocess.TimeoutExpired(cmd="git", timeout=2), + subprocess.SubprocessError("boom"), + ], +) +@needs_checkout +def test_swallows_subprocess_errors(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None: + def raise_it(*args: object, **kwargs: object) -> None: + raise error + + monkeypatch.setattr(subprocess, "run", raise_it) + assert _git_commit() == "" + + +@pytest.mark.parametrize( + ("returncode", "stdout", "expected"), + [ + (0, "a24c9b9\n", "a24c9b9"), + (128, "", ""), # empty repo, corrupt repo, or a stale worktree pointer + (0, "", ""), # no output despite success + ], +) +@needs_checkout +def test_maps_git_result(monkeypatch: pytest.MonkeyPatch, returncode: int, stdout: str, expected: str) -> None: + completed = subprocess.CompletedProcess(args=["git"], returncode=returncode, stdout=stdout, stderr="noise") + monkeypatch.setattr(subprocess, "run", lambda *a, **k: completed) + assert _git_commit() == expected diff --git a/unshackle/core/__init__.py b/unshackle/core/__init__.py index f5752882..7826b872 100644 --- a/unshackle/core/__init__.py +++ b/unshackle/core/__init__.py @@ -1 +1,24 @@ +import subprocess +from pathlib import Path + __version__ = "5.3.0" + + +def _git_commit() -> str: + root = Path(__file__).parents[2] + if not (root / ".git").exists(): + return "" + try: + proc = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=root, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return "" + return proc.stdout.strip() if proc.returncode == 0 else "" + + +__commit__ = _git_commit() diff --git a/unshackle/core/__main__.py b/unshackle/core/__main__.py index 74744bd3..89f90616 100644 --- a/unshackle/core/__main__.py +++ b/unshackle/core/__main__.py @@ -10,7 +10,7 @@ from rich.padding import Padding from rich.text import Text from urllib3.exceptions import InsecureRequestWarning -from unshackle.core import __version__ +from unshackle.core import __commit__, __version__ from unshackle.core.commands import Commands from unshackle.core.config import config from unshackle.core.console import ComfyRichHandler, console @@ -63,7 +63,8 @@ def main(version: bool, debug: bool) -> None: r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ", style="ascii.art", ), - f"v [repr.number]{__version__}[/] - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle", + f"v [repr.number]{__version__}[/]{f' ({__commit__})' if __commit__ else ''}" + f" - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle", ), (1, 11, 1, 10), expand=True, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 44956915..c578170b 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -8,7 +8,7 @@ import click from aiohttp import web from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings -from unshackle.core import __version__ +from unshackle.core import __commit__, __version__ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception from unshackle.core.api.handlers import ( cancel_download_job_handler, @@ -105,6 +105,10 @@ async def health(request: web.Request) -> web.Response: version: type: string example: "2.0.0" + commit: + type: string + nullable: true + example: "a24c9b9" update_check: type: object properties: @@ -128,7 +132,9 @@ async def health(request: web.Request) -> web.Response: log.warning(f"Failed to check for updates: {e}") update_info = {"update_available": None, "current_version": __version__, "latest_version": None} - return web.json_response({"status": "ok", "version": __version__, "update_check": update_info}) + return web.json_response( + {"status": "ok", "version": __version__, "commit": __commit__ or None, "update_check": update_info} + ) @api_handler diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 62958a55..dd100b22 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -787,7 +787,7 @@ class DebugLogger: """Log the start of a new session with environment information.""" import platform - from unshackle.core import __version__ + from unshackle.core import __commit__, __version__ self.log( level="INFO", @@ -795,6 +795,7 @@ class DebugLogger: message="Debug logging session started", context={ "unshackle_version": __version__, + "unshackle_commit": __commit__ or None, "python_version": sys.version, "platform": platform.platform(), "platform_system": platform.system(),