diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..da0b679 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +## [2.1.0] - 2025-12-08 + +* Implement unified release helper with preview mode, multi-packaging version bumps, and new integration/unit tests (see ChatGPT conversation 2025-12-08: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630) + diff --git a/PKGBUILD b/PKGBUILD index 5bb494c..e23a13c 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Kevin Veen-Birkenbach pkgname=package-manager -pkgver=0.1.1 +pkgver=2.1.0 pkgrel=1 pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." arch=('any') diff --git a/debian/changelog b/debian/changelog index 46f287d..4232a6a 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +package-manager (2.1.0-1) unstable; urgency=medium + + * Implement unified release helper with preview mode, multi-packaging version bumps, and new integration/unit tests (see ChatGPT conversation 2025-12-08: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630) + + -- Kevin Veen-Birkenbach Mon, 08 Dec 2025 20:15:13 +0100 + package-manager (0.1.1-1) unstable; urgency=medium * Initial release. diff --git a/flake.nix b/flake.nix index 6cc9c17..3c29176 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ rec { pkgmgr = pyPkgs.buildPythonApplication { pname = "package-manager"; - version = "0.1.1"; + version = "2.1.0"; # Use the git repo as source src = ./.; diff --git a/package-manager.spec b/package-manager.spec index 25c33ce..d745c24 100644 --- a/package-manager.spec +++ b/package-manager.spec @@ -1,5 +1,5 @@ Name: package-manager -Version: 0.1.1 +Version: 2.1.0 Release: 1%{?dist} Summary: Wrapper that runs Kevin's package-manager via Nix flake diff --git a/pkgmgr/cli_core/commands/release.py b/pkgmgr/cli_core/commands/release.py index 8697159..b6afaf9 100644 --- a/pkgmgr/cli_core/commands/release.py +++ b/pkgmgr/cli_core/commands/release.py @@ -21,32 +21,56 @@ def handle_release( Handle the 'release' command. Creates a release by incrementing the version and updating the changelog - in the selected repositories. + in a single selected repository. + + Important: + - Releases are strictly limited to exactly ONE repository. + - Using --all or specifying multiple identifiers for release does + not make sense and is therefore rejected. + - The --preview flag is respected and passed through to the release + implementation so that no changes are made in preview mode. """ if not selected: print("No repositories selected for release.") sys.exit(1) + if len(selected) > 1: + print( + "[ERROR] Release operations are limited to a single repository.\n" + "Do not use --all or multiple identifiers with 'pkgmgr release'." + ) + sys.exit(1) + original_dir = os.getcwd() - for repo in selected: - repo_dir: Optional[str] = repo.get("directory") - if not repo_dir: - repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) + repo = selected[0] - pyproject_path = os.path.join(repo_dir, "pyproject.toml") - changelog_path = os.path.join(repo_dir, "CHANGELOG.md") + repo_dir: Optional[str] = repo.get("directory") + if not repo_dir: + repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) + if not os.path.isdir(repo_dir): print( - f"Releasing repository '{repo.get('repository')}' in '{repo_dir}'..." + f"[ERROR] Repository directory does not exist locally: {repo_dir}" ) + sys.exit(1) - os.chdir(repo_dir) + pyproject_path = os.path.join(repo_dir, "pyproject.toml") + changelog_path = os.path.join(repo_dir, "CHANGELOG.md") + + print( + f"Releasing repository '{repo.get('repository')}' in '{repo_dir}'..." + ) + + os.chdir(repo_dir) + try: rel.release( pyproject_path=pyproject_path, changelog_path=changelog_path, release_type=args.release_type, message=args.message, + preview=getattr(args, "preview", False), ) + finally: os.chdir(original_dir) diff --git a/pkgmgr/release.py b/pkgmgr/release.py index 65e5867..a54dd3c 100644 --- a/pkgmgr/release.py +++ b/pkgmgr/release.py @@ -1,152 +1,766 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + """ pkgmgr/release.py -This module defines a 'release' function that: - - Increments the version in pyproject.toml based on the release type (major, minor, patch) - - Updates the CHANGELOG.md with a new release entry (including an optional message) - - Executes Git commands to commit, tag, and push the release. +Release helper for pkgmgr. + +Responsibilities (Milestone 7): + - Determine the next semantic version based on existing Git tags. + - Update pyproject.toml with the new version. + - Update additional packaging files (flake.nix, PKGBUILD, + debian/changelog, RPM spec) where present. + - Prepend a basic entry to CHANGELOG.md. + - Commit, tag, and push the release on the current branch. + +Additional behaviour: + - If `preview=True` (from --preview), no files are written and no + Git commands are executed. Instead, a detailed summary of the + planned changes and commands is printed. """ +from __future__ import annotations + +import argparse +import os import re import subprocess -from datetime import date import sys -import argparse +import tempfile +from datetime import date, datetime +from typing import Optional, Tuple -def bump_version(version_str: str, release_type: str) -> str: +from pkgmgr.git_utils import get_tags, get_current_branch, GitError +from pkgmgr.versioning import ( + SemVer, + find_latest_version, + bump_major, + bump_minor, + bump_patch, +) + + +# --------------------------------------------------------------------------- +# Helpers for Git + version discovery +# --------------------------------------------------------------------------- + + +def _determine_current_version() -> SemVer: """ - Parse the version string and return the incremented version. - - Parameters: - version_str: The current version in the form "X.Y.Z". - release_type: One of "major", "minor", or "patch". - - Returns: - The bumped version string. + Determine the current semantic version from Git tags. + + Behaviour: + - If there are no tags or no SemVer-compatible tags, return 0.0.0. + - Otherwise, use the latest SemVer tag as current version. + """ + tags = get_tags() + if not tags: + return SemVer(0, 0, 0) + + latest = find_latest_version(tags) + if latest is None: + return SemVer(0, 0, 0) + + _tag, ver = latest + return ver + + +def _bump_semver(current: SemVer, release_type: str) -> SemVer: + """ + Bump the given SemVer according to the release type. + + release_type must be one of: "major", "minor", "patch". """ - parts = version_str.split('.') - if len(parts) != 3: - raise ValueError("Version format is unexpected. Expected format: X.Y.Z") - major, minor, patch = map(int, parts) if release_type == "major": - major += 1 - minor = 0 - patch = 0 - elif release_type == "minor": - minor += 1 - patch = 0 - elif release_type == "patch": - patch += 1 - else: - raise ValueError("release_type must be 'major', 'minor', or 'patch'.") - return f"{major}.{minor}.{patch}" + return bump_major(current) + if release_type == "minor": + return bump_minor(current) + if release_type == "patch": + return bump_patch(current) -def update_pyproject_version(pyproject_path: str, new_version: str): + raise ValueError(f"Unknown release type: {release_type!r}") + + +# --------------------------------------------------------------------------- +# Low-level Git command helper +# --------------------------------------------------------------------------- + + +def _run_git_command(cmd: str) -> None: + """ + Run a Git (or shell) command with basic error reporting. + + The command is executed via the shell, primarily for readability + when printed (as in 'git commit -am "msg"'). + """ + print(f"[GIT] {cmd}") + try: + subprocess.run(cmd, shell=True, check=True) + except subprocess.CalledProcessError as exc: + print(f"[ERROR] Git command failed: {cmd}") + print(f" Exit code: {exc.returncode}") + if exc.stdout: + print("--- stdout ---") + print(exc.stdout) + if exc.stderr: + print("--- stderr ---") + print(exc.stderr) + raise GitError(f"Git command failed: {cmd}") from exc + + +# --------------------------------------------------------------------------- +# Editor helper for interactive changelog messages +# --------------------------------------------------------------------------- + + +def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: + """ + Open $EDITOR (fallback 'nano') so the user can enter a changelog message. + + The temporary file is pre-filled with commented instructions and an + optional initial_message. Lines starting with '#' are ignored when the + message is read back. + + Returns the final message (may be empty string if user leaves it blank). + """ + editor = os.environ.get("EDITOR", "nano") + + with tempfile.NamedTemporaryFile( + mode="w+", + delete=False, + encoding="utf-8", + ) as tmp: + tmp_path = tmp.name + # Prefill with instructions as comments + tmp.write( + "# Write the changelog entry for this release.\n" + "# Lines starting with '#' will be ignored.\n" + "# Empty result will fall back to a generic message.\n\n" + ) + if initial_message: + tmp.write(initial_message.strip() + "\n") + tmp.flush() + + # Open editor + subprocess.call([editor, tmp_path]) + + # Read back content + try: + with open(tmp_path, "r", encoding="utf-8") as f: + content = f.read() + finally: + try: + os.remove(tmp_path) + except OSError: + pass + + # Filter out commented lines and return joined text + lines = [ + line for line in content.splitlines() + if not line.strip().startswith("#") + ] + return "\n".join(lines).strip() + + +# --------------------------------------------------------------------------- +# File update helpers (pyproject + extra packaging + changelog) +# --------------------------------------------------------------------------- + + +def update_pyproject_version( + pyproject_path: str, + new_version: str, + preview: bool = False, +) -> None: """ Update the version in pyproject.toml with the new version. - - Parameters: - pyproject_path: Path to the pyproject.toml file. - new_version: The new version string. - """ - with open(pyproject_path, "r") as f: - content = f.read() - # Search for the version string in the format: version = "X.Y.Z" - new_content, count = re.subn(r'(version\s*=\s*")([\d\.]+)(")', r'\1' + new_version + r'\3', content) - if count == 0: - print("Could not find version line in pyproject.toml") - sys.exit(1) - with open(pyproject_path, "w") as f: - f.write(new_content) - print(f"Updated pyproject.toml version to {new_version}") -def update_changelog(changelog_path: str, new_version: str, message: str = None): - """ - Prepend a new release section to CHANGELOG.md with the new version, - today’s date and an optional release message. - - Parameters: - changelog_path: Path to the CHANGELOG.md file. - new_version: The new version string. - message: An optional release message. - """ - release_date = date.today().isoformat() - header = f"## [{new_version}] - {release_date}\n" - if message: - header += f"{message}\n" - header += "\n" - try: - with open(changelog_path, "r") as f: - changelog = f.read() - except FileNotFoundError: - changelog = "" - new_changelog = header + changelog - with open(changelog_path, "w") as f: - f.write(new_changelog) - print(f"Updated CHANGELOG.md with version {new_version}") + The function looks for a line matching: -def run_git_command(cmd: str): - """ - Execute a shell command via Git and exit if it fails. - - Parameters: - cmd: The shell command to run. - """ - print(f"Running: {cmd}") - result = subprocess.run(cmd, shell=True) - if result.returncode != 0: - print(f"Command failed: {cmd}") - sys.exit(result.returncode) + version = "X.Y.Z" -def release(pyproject_path: str = "pyproject.toml", - changelog_path: str = "CHANGELOG.md", - release_type: str = "patch", - message: str = None): - """ - Perform a release by incrementing the version in pyproject.toml, - updating CHANGELOG.md with the release version and message, then executing - the Git commands to commit, tag, and push the changes. - - Parameters: - pyproject_path: The path to pyproject.toml. - changelog_path: The path to CHANGELOG.md. - release_type: A string indicating the type of release ("major", "minor", "patch"). - message: An optional release message to include in CHANGELOG.md and Git tag. + and replaces the version part with the given new_version string. + + It does not try to parse the full TOML structure here. This keeps the + implementation small and robust as long as the version line follows + the standard pattern. + + Behaviour: + - In normal mode: write the updated content back to the file. + - In preview mode: do NOT write, only report what would change. """ try: - with open(pyproject_path, "r") as f: + with open(pyproject_path, "r", encoding="utf-8") as f: content = f.read() except FileNotFoundError: - print(f"{pyproject_path} not found.") + print(f"[ERROR] pyproject.toml not found at: {pyproject_path}") sys.exit(1) - - match = re.search(r'version\s*=\s*"([\d\.]+)"', content) - if not match: - print("Could not find version in pyproject.toml") - sys.exit(1) - current_version = match.group(1) - new_version = bump_version(current_version, release_type) - - # Update files. - update_pyproject_version(pyproject_path, new_version) - update_changelog(changelog_path, new_version, message) - - # Execute Git commands. - commit_msg = f"Release version {new_version}" - run_git_command(f'git commit -am "{commit_msg}"') - run_git_command(f'git tag -a v{new_version} -m "{commit_msg}"') - run_git_command("git push origin main") - run_git_command("git push origin --tags") - print(f"Release {new_version} completed successfully.") -# Allow the script to be used as a CLI tool. -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Perform a release by updating version and changelog, then executing Git commands." + pattern = r'^(version\s*=\s*")([^"]+)(")' + new_content, count = re.subn( + pattern, + lambda m: f'{m.group(1)}{new_version}{m.group(3)}', + content, + flags=re.MULTILINE, + ) + + if count == 0: + print("[ERROR] Could not find version line in pyproject.toml") + sys.exit(1) + + if preview: + print(f"[PREVIEW] Would update pyproject.toml version to {new_version}") + return + + with open(pyproject_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated pyproject.toml version to {new_version}") + + +def update_flake_version( + flake_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in flake.nix, if present. + + Looks for a line like: + version = "1.2.3"; + + and replaces the string inside the quotes. If the file does not + exist or no version line is found, this is treated as a non-fatal + condition and only a log message is printed. + """ + if not os.path.exists(flake_path): + print("[INFO] flake.nix not found, skipping.") + return + + try: + with open(flake_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as exc: + print(f"[WARN] Could not read flake.nix: {exc}") + return + + pattern = r'(version\s*=\s*")([^"]+)(")' + new_content, count = re.subn( + pattern, + lambda m: f'{m.group(1)}{new_version}{m.group(3)}', + content, + ) + + if count == 0: + print("[WARN] No version assignment found in flake.nix, skipping.") + return + + if preview: + print(f"[PREVIEW] Would update flake.nix version to {new_version}") + return + + with open(flake_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated flake.nix version to {new_version}") + + +def update_pkgbuild_version( + pkgbuild_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in PKGBUILD, if present. + + Expects: + pkgver=1.2.3 + pkgrel=1 + + Behaviour: + - Set pkgver to the new_version (e.g. 1.2.3). + - Reset pkgrel to 1. + + If the file does not exist, this is non-fatal and only a log + message is printed. + """ + if not os.path.exists(pkgbuild_path): + print("[INFO] PKGBUILD not found, skipping.") + return + + try: + with open(pkgbuild_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as exc: + print(f"[WARN] Could not read PKGBUILD: {exc}") + return + + # Update pkgver + ver_pattern = r"^(pkgver\s*=\s*)(.+)$" + new_content, ver_count = re.subn( + ver_pattern, + lambda m: f"{m.group(1)}{new_version}", + content, + flags=re.MULTILINE, + ) + + if ver_count == 0: + print("[WARN] No pkgver line found in PKGBUILD.") + new_content = content # revert to original if we didn't change anything + + # Reset pkgrel to 1 + rel_pattern = r"^(pkgrel\s*=\s*)(.+)$" + new_content, rel_count = re.subn( + rel_pattern, + lambda m: f"{m.group(1)}1", + new_content, + flags=re.MULTILINE, + ) + + if rel_count == 0: + print("[WARN] No pkgrel line found in PKGBUILD.") + + if preview: + print(f"[PREVIEW] Would update PKGBUILD to pkgver={new_version}, pkgrel=1") + return + + with open(pkgbuild_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated PKGBUILD to pkgver={new_version}, pkgrel=1") + + +def update_spec_version( + spec_path: str, + new_version: str, + preview: bool = False, +) -> None: + """ + Update the version in an RPM spec file, if present. + + Assumes a file like 'package-manager.spec' with lines: + + Version: 1.2.3 + Release: 1%{?dist} + + Behaviour: + - Set 'Version:' to new_version. + - Reset 'Release:' to '1' while preserving any macro suffix, + e.g. '1%{?dist}'. + + If the file does not exist, this is non-fatal and only a log + message is printed. + """ + if not os.path.exists(spec_path): + print("[INFO] RPM spec file not found, skipping.") + return + + try: + with open(spec_path, "r", encoding="utf-8") as f: + content = f.read() + except Exception as exc: + print(f"[WARN] Could not read spec file: {exc}") + return + + # Update Version: + ver_pattern = r"^(Version:\s*)(.+)$" + new_content, ver_count = re.subn( + ver_pattern, + lambda m: f"{m.group(1)}{new_version}", + content, + flags=re.MULTILINE, + ) + + if ver_count == 0: + print("[WARN] No 'Version:' line found in spec file.") + + # Reset Release: + rel_pattern = r"^(Release:\s*)(.+)$" + + def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined] + rest = m.group(2).strip() + # Reset numeric prefix to "1" and keep any suffix (e.g. % macros). + match = re.match(r"^(\d+)(.*)$", rest) + if match: + suffix = match.group(2) + else: + suffix = "" + return f"{m.group(1)}1{suffix}" + + new_content, rel_count = re.subn( + rel_pattern, + _release_repl, + new_content, + flags=re.MULTILINE, + ) + + if rel_count == 0: + print("[WARN] No 'Release:' line found in spec file.") + + if preview: + print( + f"[PREVIEW] Would update spec file " + f"{os.path.basename(spec_path)} to Version: {new_version}, Release: 1..." + ) + return + + with open(spec_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print( + f"Updated spec file {os.path.basename(spec_path)} " + f"to Version: {new_version}, Release: 1..." + ) + + +def update_changelog( + changelog_path: str, + new_version: str, + message: Optional[str] = None, + preview: bool = False, +) -> str: + """ + Prepend a new release section to CHANGELOG.md with the new version, + current date, and a message. + + Behaviour: + - If message is None and preview is False: + → open $EDITOR (fallback 'nano') to let the user enter a message. + - If message is None and preview is True: + → use a generic automated message. + - The resulting changelog entry is printed to stdout. + - Returns the final message text used. + """ + today = date.today().isoformat() + + # Resolve message + if message is None: + if preview: + # Do not open editor in preview mode; keep it non-interactive. + message = "Automated release." + else: + print( + "\n[INFO] No release message provided, opening editor for " + "changelog entry...\n" + ) + editor_message = _open_editor_for_changelog() + if not editor_message: + message = "Automated release." + else: + message = editor_message + + header = f"## [{new_version}] - {today}\n" + header += f"\n* {message}\n\n" + + if os.path.exists(changelog_path): + try: + with open(changelog_path, "r", encoding="utf-8") as f: + changelog = f.read() + except Exception as exc: + print(f"[WARN] Could not read existing CHANGELOG.md: {exc}") + changelog = "" + else: + changelog = "" + + new_changelog = header + "\n" + changelog if changelog else header + + # Show the entry that will be written + print("\n================ CHANGELOG ENTRY ================") + print(header.rstrip()) + print("=================================================\n") + + if preview: + print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md") + return message + + with open(changelog_path, "w", encoding="utf-8") as f: + f.write(new_changelog) + + print(f"Updated CHANGELOG.md with version {new_version}") + + return message + + +# --------------------------------------------------------------------------- +# Debian changelog helpers (with Git config fallback for maintainer) +# --------------------------------------------------------------------------- + + +def _get_git_config_value(key: str) -> Optional[str]: + """ + Try to read a value from `git config --get `. + + Returns the stripped value or None if not set / on error. + """ + try: + result = subprocess.run( + ["git", "config", "--get", key], + capture_output=True, + text=True, + check=False, + ) + except Exception: + return None + + value = result.stdout.strip() + return value or None + + +def _get_debian_author() -> Tuple[str, str]: + """ + Determine the maintainer name/email for debian/changelog entries. + + Priority: + 1. DEBFULLNAME / DEBEMAIL + 2. GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL + 3. git config user.name / user.email + 4. Fallback: 'Unknown Maintainer' / 'unknown@example.com' + """ + name = os.environ.get("DEBFULLNAME") + email = os.environ.get("DEBEMAIL") + + if not name: + name = os.environ.get("GIT_AUTHOR_NAME") + if not email: + email = os.environ.get("GIT_AUTHOR_EMAIL") + + if not name: + name = _get_git_config_value("user.name") + if not email: + email = _get_git_config_value("user.email") + + if not name: + name = "Unknown Maintainer" + if not email: + email = "unknown@example.com" + + return name, email + + +def update_debian_changelog( + debian_changelog_path: str, + package_name: str, + new_version: str, + message: Optional[str] = None, + preview: bool = False, +) -> None: + """ + Prepend a new entry to debian/changelog, if it exists. + + The first line typically looks like: + package-name (1.2.3-1) unstable; urgency=medium + + We generate a new stanza at the top with Debian-style version + 'X.Y.Z-1'. If the file does not exist, this function does nothing. + """ + if not os.path.exists(debian_changelog_path): + print("[INFO] debian/changelog not found, skipping.") + return + + debian_version = f"{new_version}-1" + now = datetime.now().astimezone() + # Debian-like date string, e.g. "Mon, 08 Dec 2025 12:34:56 +0100" + date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z") + + author_name, author_email = _get_debian_author() + + first_line = f"{package_name} ({debian_version}) unstable; urgency=medium" + body_line = ( + message.strip() if message else f"Automated release {new_version}." + ) + stanza = ( + f"{first_line}\n\n" + f" * {body_line}\n\n" + f" -- {author_name} <{author_email}> {date_str}\n\n" + ) + + if preview: + print( + "[PREVIEW] Would prepend the following stanza to debian/changelog:\n" + f"{stanza}" + ) + return + + try: + with open(debian_changelog_path, "r", encoding="utf-8") as f: + existing = f.read() + except Exception as exc: + print(f"[WARN] Could not read debian/changelog: {exc}") + existing = "" + + new_content = stanza + existing + + with open(debian_changelog_path, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"Updated debian/changelog with version {debian_version}") + + +# --------------------------------------------------------------------------- +# Public release entry point +# --------------------------------------------------------------------------- + + +def release( + pyproject_path: str = "pyproject.toml", + changelog_path: str = "CHANGELOG.md", + release_type: str = "patch", + message: Optional[str] = None, + preview: bool = False, +) -> None: + """ + Perform a release by: + + 1. Determining the current version from Git tags. + 2. Computing the next version (major/minor/patch). + 3. Updating pyproject.toml with the new version. + 4. Updating CHANGELOG.md with a new entry. + 5. Updating additional packaging files where present: + - flake.nix + - PKGBUILD + - debian/changelog + - package-manager.spec + 6. Staging all these files. + 7. Committing, tagging, and pushing the changes. + + If `preview` is True, no files are written and no Git commands + are executed. Instead, the planned actions are printed. + """ + # 1) Determine the current version from Git tags. + current_ver = _determine_current_version() + + # 2) Compute the next version. + new_ver = _bump_semver(current_ver, release_type) + new_ver_str = str(new_ver) + new_tag = new_ver.to_tag(with_prefix=True) + + mode = "PREVIEW" if preview else "REAL" + print(f"Release mode: {mode}") + print(f"Current version: {current_ver}") + print(f"New version: {new_ver_str} ({release_type})") + + # Determine repository root based on pyproject location + repo_root = os.path.dirname(os.path.abspath(pyproject_path)) + + # 2) Update files. + update_pyproject_version(pyproject_path, new_ver_str, preview=preview) + # Let update_changelog resolve or edit the message; reuse it for debian. + message = update_changelog( + changelog_path, + new_ver_str, + message=message, + preview=preview, + ) + + # Additional packaging files (non-fatal if missing) + flake_path = os.path.join(repo_root, "flake.nix") + update_flake_version(flake_path, new_ver_str, preview=preview) + + pkgbuild_path = os.path.join(repo_root, "PKGBUILD") + update_pkgbuild_version(pkgbuild_path, new_ver_str, preview=preview) + + spec_path = os.path.join(repo_root, "package-manager.spec") + update_spec_version(spec_path, new_ver_str, preview=preview) + + debian_changelog_path = os.path.join(repo_root, "debian", "changelog") + # Use repo directory name as a simple default for package name + package_name = os.path.basename(repo_root) or "package-manager" + update_debian_changelog( + debian_changelog_path, + package_name=package_name, + new_version=new_ver_str, + message=message, + preview=preview, + ) + + # 3) Git operations: stage, commit, tag, push. + commit_msg = f"Release version {new_ver_str}" + tag_msg = message or commit_msg + + try: + branch = get_current_branch() or "main" + except GitError: + branch = "main" + print(f"Releasing on branch: {branch}") + + # Stage all relevant packaging files so they are included in the commit + files_to_add = [ + pyproject_path, + changelog_path, + flake_path, + pkgbuild_path, + spec_path, + debian_changelog_path, + ] + existing_files = [p for p in files_to_add if p and os.path.exists(p)] + + if preview: + for path in existing_files: + print(f"[PREVIEW] Would run: git add {path}") + print(f'[PREVIEW] Would run: git commit -am "{commit_msg}"') + print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"') + print(f"[PREVIEW] Would run: git push origin {branch}") + print("[PREVIEW] Would run: git push origin --tags") + print("Preview completed. No changes were made.") + return + + for path in existing_files: + _run_git_command(f"git add {path}") + + _run_git_command(f'git commit -am "{commit_msg}"') + _run_git_command(f'git tag -a {new_tag} -m "{tag_msg}"') + _run_git_command(f"git push origin {branch}") + _run_git_command("git push origin --tags") + + print(f"Release {new_ver_str} completed.") + + +# --------------------------------------------------------------------------- +# CLI entry point for standalone use +# --------------------------------------------------------------------------- + + +def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="pkgmgr release helper") + parser.add_argument( + "release_type", + choices=["major", "minor", "patch"], + help="Type of release (major/minor/patch).", + ) + parser.add_argument( + "-m", + "--message", + dest="message", + default=None, + help="Release message to use for changelog and tag.", + ) + parser.add_argument( + "--pyproject", + dest="pyproject", + default="pyproject.toml", + help="Path to pyproject.toml (default: pyproject.toml)", + ) + parser.add_argument( + "--changelog", + dest="changelog", + default="CHANGELOG.md", + help="Path to CHANGELOG.md (default: CHANGELOG.md)", + ) + parser.add_argument( + "--preview", + action="store_true", + help="Preview release changes without modifying files or running git.", + ) + return parser.parse_args(argv) + + +if __name__ == "__main__": + args = _parse_args() + release( + pyproject_path=args.pyproject, + changelog_path=args.changelog, + release_type=args.release_type, + message=args.message, + preview=args.preview, ) - parser.add_argument("release_type", choices=["major", "minor", "patch"], - help="Type of release increment (major, minor, patch).") - parser.add_argument("-m", "--message", help="Optional release message for changelog and tag.", default=None) - - args = parser.parse_args() - release(release_type=args.release_type, message=args.message) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e6150d6..4bc23b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "package-manager" -version = "0.1.1" +version = "2.1.0" description = "Kevin's package-manager tool (pkgmgr)" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/e2e/test_integration_branch_commands.py b/tests/e2e/test_integration_branch_commands.py index 837fd7a..69fc7f0 100644 --- a/tests/e2e/test_integration_branch_commands.py +++ b/tests/e2e/test_integration_branch_commands.py @@ -8,33 +8,37 @@ from unittest.mock import patch class TestIntegrationBranchCommands(unittest.TestCase): """ - E2E-style tests for the 'pkgmgr branch' CLI wiring. + Integration tests for the `pkgmgr branch` CLI wiring. - We do NOT call real git; instead we patch pkgmgr.branch_commands.open_branch - and verify that the CLI invokes it with the correct parameters. + These tests execute the real entry point (main.py) and mock + the high-level `open_branch` helper to ensure that argument + parsing and dispatch behave as expected. """ - def _run_pkgmgr(self, argv: list[str]) -> None: + def _run_pkgmgr(self, extra_args: list[str]) -> None: """ - Helper to run 'pkgmgr' via its entry module with a given argv. + Run the main entry point with the given extra args, as if called via: + + pkgmgr + + We explicitly set sys.argv and execute main.py as __main__ using runpy. """ original_argv = list(sys.argv) try: - # argv typically looks like: ["pkgmgr", "branch", ...] - sys.argv = argv - # Run the CLI entry point - runpy.run_module("pkgmgr.cli", run_name="__main__") + # argv[0] is the program name; the rest are CLI arguments. + sys.argv = ["pkgmgr"] + list(extra_args) + runpy.run_module("main", run_name="__main__") finally: sys.argv = original_argv - @patch("pkgmgr.branch_commands.open_branch") + @patch("pkgmgr.cli_core.commands.branch.open_branch") def test_branch_open_with_name_and_base(self, mock_open_branch) -> None: """ - pkgmgr branch open feature/test --base develop - should invoke open_branch(name='feature/test', base_branch='develop', cwd='.') + `pkgmgr branch open feature/test --base develop` must forward + the name and base branch to open_branch() with cwd=".". """ self._run_pkgmgr( - ["pkgmgr", "branch", "open", "feature/test", "--base", "develop"] + ["branch", "open", "feature/test", "--base", "develop"] ) mock_open_branch.assert_called_once() @@ -43,14 +47,16 @@ class TestIntegrationBranchCommands(unittest.TestCase): self.assertEqual(kwargs.get("base_branch"), "develop") self.assertEqual(kwargs.get("cwd"), ".") - @patch("pkgmgr.branch_commands.open_branch") - def test_branch_open_without_name_uses_default_base(self, mock_open_branch) -> None: + @patch("pkgmgr.cli_core.commands.branch.open_branch") + def test_branch_open_without_name_uses_default_base( + self, + mock_open_branch, + ) -> None: """ - pkgmgr branch open - should invoke open_branch(name=None, base_branch='main', cwd='.') - (the branch name will be asked interactively inside open_branch). + `pkgmgr branch open` without a name must still call open_branch(), + passing name=None and the default base branch 'main'. """ - self._run_pkgmgr(["pkgmgr", "branch", "open"]) + self._run_pkgmgr(["branch", "open"]) mock_open_branch.assert_called_once() _, kwargs = mock_open_branch.call_args diff --git a/tests/e2e/test_integration_release_commands.py b/tests/e2e/test_integration_release_commands.py index b5a5bb7..b418768 100644 --- a/tests/e2e/test_integration_release_commands.py +++ b/tests/e2e/test_integration_release_commands.py @@ -1,63 +1,100 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Integration tests for the `pkgmgr release` command. - -We deliberately only test a *negative* path here, to avoid mutating -the real repositories (bumping versions, editing changelogs) during -CI runs. - -The test verifies that: - - - Calling `pkgmgr release` with a non-existent repository identifier - results in a non-zero exit code and a helpful error. -""" - from __future__ import annotations +import os import runpy import sys import unittest +PROJECT_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..") +) + + class TestIntegrationReleaseCommand(unittest.TestCase): - """ - E2E tests for `pkgmgr release`. - """ - - def _run_release_expect_failure(self) -> None: - cmd_repr = "pkgmgr release patch does-not-exist-xyz" + def _run_pkgmgr( + self, + argv: list[str], + expect_success: bool, + ) -> None: + """ + Run the main entry point with the given argv and assert on success/failure. + + argv must include the program name as argv[0], e.g. "": + ["", "release", "patch", "pkgmgr", "--preview"] + """ + cmd_repr = " ".join(argv[1:]) original_argv = list(sys.argv) try: - sys.argv = [ - "pkgmgr", - "release", - "patch", - "does-not-exist-xyz", - ] + sys.argv = argv try: + # Execute main.py as if called via `python main.py ...` runpy.run_module("main", run_name="__main__") except SystemExit as exc: - code = exc.code if isinstance(exc.code, int) else str(exc.code) - # Hier wirklich verifizieren: - assert code != 0, f"{cmd_repr!r} unexpectedly succeeded with exit code 0" - print("[TEST] pkgmgr release failed as expected") - print(f"[TEST] Command : {cmd_repr}") - print(f"[TEST] Exit code : {code}") + code = exc.code if isinstance(exc.code, int) else 1 + if expect_success and code != 0: + print() + print(f"[TEST] Command : {cmd_repr}") + print(f"[TEST] Exit code : {code}") + raise AssertionError( + f"{cmd_repr!r} failed with exit code {code}. " + "Scroll up to inspect the output printed before failure." + ) from exc + if not expect_success and code == 0: + print() + print(f"[TEST] Command : {cmd_repr}") + print(f"[TEST] Exit code : {code}") + raise AssertionError( + f"{cmd_repr!r} unexpectedly succeeded with exit code 0." + ) from exc else: - # Kein SystemExit -> auf jeden Fall falsch - raise AssertionError( - f"{cmd_repr!r} returned normally (expected non-zero exit)." - ) + # No SystemExit: treat as success when expect_success is True, + # otherwise as a failure (we expected a non-zero exit). + if not expect_success: + raise AssertionError( + f"{cmd_repr!r} returned normally (expected non-zero exit)." + ) finally: sys.argv = original_argv - def test_release_for_unknown_repo_fails_cleanly(self) -> None: - self._run_release_expect_failure() + """ + Releasing a non-existent repository identifier must fail + with a non-zero exit code, but without crashing the interpreter. + """ + argv = [ + "", + "release", + "patch", + "does-not-exist-xyz", + ] + self._run_pkgmgr(argv, expect_success=False) + + def test_release_preview_for_pkgmgr_repository(self) -> None: + """ + Sanity-check the happy path for the CLI: + + - Runs `pkgmgr release patch pkgmgr --preview` + - Must exit with code 0 + - Uses the real configuration + repository selection + - Exercises the new --preview mode end-to-end. + """ + argv = [ + "", + "release", + "patch", + "pkgmgr", + "--preview", + ] + + original_cwd = os.getcwd() + try: + os.chdir(PROJECT_ROOT) + self._run_pkgmgr(argv, expect_success=True) + finally: + os.chdir(original_cwd) + if __name__ == "__main__": unittest.main() - diff --git a/tests/unit/pkgmgr/test_release.py b/tests/unit/pkgmgr/test_release.py new file mode 100644 index 0000000..13def83 --- /dev/null +++ b/tests/unit/pkgmgr/test_release.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import os +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +from pkgmgr.versioning import SemVer +from pkgmgr.release import ( + _determine_current_version, + _bump_semver, + update_pyproject_version, + update_flake_version, + update_pkgbuild_version, + update_spec_version, + update_changelog, + update_debian_changelog, + release, +) + + +class TestDetermineCurrentVersion(unittest.TestCase): + @patch("pkgmgr.release.get_tags", return_value=[]) + def test_determine_current_version_no_tags_returns_zero( + self, + mock_get_tags, + ) -> None: + ver = _determine_current_version() + self.assertIsInstance(ver, SemVer) + self.assertEqual((ver.major, ver.minor, ver.patch), (0, 0, 0)) + mock_get_tags.assert_called_once() + + @patch("pkgmgr.release.find_latest_version") + @patch("pkgmgr.release.get_tags") + def test_determine_current_version_uses_latest_semver_tag( + self, + mock_get_tags, + mock_find_latest_version, + ) -> None: + mock_get_tags.return_value = ["v0.1.0", "v1.2.3"] + mock_find_latest_version.return_value = ("v1.2.3", SemVer(1, 2, 3)) + + ver = _determine_current_version() + + self.assertEqual((ver.major, ver.minor, ver.patch), (1, 2, 3)) + mock_get_tags.assert_called_once() + mock_find_latest_version.assert_called_once_with(["v0.1.0", "v1.2.3"]) + + +class TestBumpSemVer(unittest.TestCase): + def test_bump_semver_major(self) -> None: + base = SemVer(1, 2, 3) + bumped = _bump_semver(base, "major") + self.assertEqual((bumped.major, bumped.minor, bumped.patch), (2, 0, 0)) + + def test_bump_semver_minor(self) -> None: + base = SemVer(1, 2, 3) + bumped = _bump_semver(base, "minor") + self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 3, 0)) + + def test_bump_semver_patch(self) -> None: + base = SemVer(1, 2, 3) + bumped = _bump_semver(base, "patch") + self.assertEqual((bumped.major, bumped.minor, bumped.patch), (1, 2, 4)) + + def test_bump_semver_invalid_type_raises(self) -> None: + base = SemVer(1, 2, 3) + with self.assertRaises(ValueError): + _bump_semver(base, "invalid-type") + + +class TestUpdatePyprojectVersion(unittest.TestCase): + def test_update_pyproject_version_replaces_version_line(self) -> None: + original = textwrap.dedent( + """ + [project] + name = "example" + version = "0.1.0" + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "pyproject.toml") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_pyproject_version(path, "1.2.3", preview=False) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn('version = "1.2.3"', content) + self.assertNotIn('version = "0.1.0"', content) + + def test_update_pyproject_version_preview_does_not_write(self) -> None: + original = textwrap.dedent( + """ + [project] + name = "example" + version = "0.1.0" + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "pyproject.toml") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_pyproject_version(path, "1.2.3", preview=True) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + # Content must be unchanged in preview mode + self.assertEqual(content, original) + + def test_update_pyproject_version_exits_when_no_version_line_found(self) -> None: + original = textwrap.dedent( + """ + [project] + name = "example" + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "pyproject.toml") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + with self.assertRaises(SystemExit) as cm: + update_pyproject_version(path, "1.2.3", preview=False) + + self.assertNotEqual(cm.exception.code, 0) + + +class TestUpdateFlakeVersion(unittest.TestCase): + def test_update_flake_version_normal(self) -> None: + original = 'version = "0.1.0";\n' + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "flake.nix") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_flake_version(path, "1.2.3", preview=False) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn('version = "1.2.3";', content) + self.assertNotIn('version = "0.1.0";', content) + + def test_update_flake_version_preview(self) -> None: + original = 'version = "0.1.0";\n' + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "flake.nix") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_flake_version(path, "1.2.3", preview=True) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertEqual(content, original) + + +class TestUpdatePkgbuildVersion(unittest.TestCase): + def test_update_pkgbuild_version_normal(self) -> None: + original = textwrap.dedent( + """ + pkgname=example + pkgver=0.1.0 + pkgrel=5 + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "PKGBUILD") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_pkgbuild_version(path, "1.2.3", preview=False) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("pkgver=1.2.3", content) + self.assertIn("pkgrel=1", content) + self.assertNotIn("pkgver=0.1.0", content) + + def test_update_pkgbuild_version_preview(self) -> None: + original = textwrap.dedent( + """ + pkgname=example + pkgver=0.1.0 + pkgrel=5 + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "PKGBUILD") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_pkgbuild_version(path, "1.2.3", preview=True) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertEqual(content, original) + + +class TestUpdateSpecVersion(unittest.TestCase): + def test_update_spec_version_normal(self) -> None: + original = textwrap.dedent( + """ + Name: package-manager + Version: 0.1.0 + Release: 5%{?dist} + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "package-manager.spec") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_spec_version(path, "1.2.3", preview=False) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("Version: 1.2.3", content) + self.assertIn("Release: 1%{?dist}", content) + self.assertNotIn("Version: 0.1.0", content) + self.assertNotIn("Release: 5%{?dist}", content) + + def test_update_spec_version_preview(self) -> None: + original = textwrap.dedent( + """ + Name: package-manager + Version: 0.1.0 + Release: 5%{?dist} + """ + ).strip() + "\n" + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "package-manager.spec") + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_spec_version(path, "1.2.3", preview=True) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertEqual(content, original) + + +class TestUpdateChangelog(unittest.TestCase): + def test_update_changelog_creates_file_if_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "CHANGELOG.md") + self.assertFalse(os.path.exists(path)) + + update_changelog(path, "1.2.3", message="First release", preview=False) + + self.assertTrue(os.path.exists(path)) + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("## [1.2.3]", content) + self.assertIn("First release", content) + + def test_update_changelog_prepends_entry_to_existing_content(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "CHANGELOG.md") + with open(path, "w", encoding="utf-8") as f: + f.write("## [0.1.0] - 2024-01-01\n\n* Initial content\n") + + update_changelog(path, "1.0.0", message=None, preview=False) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + # New entry must be on top + self.assertTrue(content.startswith("## [1.0.0]")) + self.assertIn("## [0.1.0] - 2024-01-01", content) + + def test_update_changelog_preview_does_not_write(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "CHANGELOG.md") + original = "## [0.1.0] - 2024-01-01\n\n* Initial content\n" + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + update_changelog(path, "1.0.0", message="Preview only", preview=True) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertEqual(content, original) + + +class TestUpdateDebianChangelog(unittest.TestCase): + def test_update_debian_changelog_creates_new_stanza(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "changelog") + # existing content + with open(path, "w", encoding="utf-8") as f: + f.write("existing content\n") + + with patch.dict( + os.environ, + { + "DEBFULLNAME": "Test Maintainer", + "DEBEMAIL": "test@example.com", + }, + clear=False, + ): + update_debian_changelog( + debian_changelog_path=path, + package_name="package-manager", + new_version="1.2.3", + message="Test debian entry", + preview=False, + ) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("package-manager (1.2.3-1) unstable; urgency=medium", content) + self.assertIn(" * Test debian entry", content) + self.assertIn("Test Maintainer ", content) + self.assertIn("existing content", content) + + def test_update_debian_changelog_preview_does_not_write(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "changelog") + original = "existing content\n" + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + with patch.dict( + os.environ, + { + "DEBFULLNAME": "Test Maintainer", + "DEBEMAIL": "test@example.com", + }, + clear=False, + ): + update_debian_changelog( + debian_changelog_path=path, + package_name="package-manager", + new_version="1.2.3", + message="Test debian entry", + preview=True, + ) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertEqual(content, original) + + +class TestReleaseOrchestration(unittest.TestCase): + @patch("pkgmgr.release._run_git_command") + @patch("pkgmgr.release.update_debian_changelog") + @patch("pkgmgr.release.update_spec_version") + @patch("pkgmgr.release.update_pkgbuild_version") + @patch("pkgmgr.release.update_flake_version") + @patch("pkgmgr.release.get_current_branch", return_value="develop") + @patch("pkgmgr.release.update_changelog") + @patch("pkgmgr.release.update_pyproject_version") + @patch("pkgmgr.release._bump_semver") + @patch("pkgmgr.release._determine_current_version") + def test_release_happy_path_uses_helpers_and_git( + self, + mock_determine_current_version, + mock_bump_semver, + mock_update_pyproject, + mock_update_changelog, + mock_get_current_branch, + mock_update_flake, + mock_update_pkgbuild, + mock_update_spec, + mock_update_debian_changelog, + mock_run_git_command, + ) -> None: + mock_determine_current_version.return_value = SemVer(1, 2, 3) + mock_bump_semver.return_value = SemVer(1, 2, 4) + + release( + pyproject_path="pyproject.toml", + changelog_path="CHANGELOG.md", + release_type="patch", + message="Test release", + preview=False, + ) + + # Current version + bump + mock_determine_current_version.assert_called_once() + mock_bump_semver.assert_called_once() + args, kwargs = mock_bump_semver.call_args + self.assertEqual(args[0], SemVer(1, 2, 3)) + self.assertEqual(args[1], "patch") + self.assertEqual(kwargs, {}) + + # pyproject update + mock_update_pyproject.assert_called_once() + args, kwargs = mock_update_pyproject.call_args + self.assertEqual(args[0], "pyproject.toml") + self.assertEqual(args[1], "1.2.4") + self.assertEqual(kwargs.get("preview"), False) + + # changelog update + mock_update_changelog.assert_called_once() + args, kwargs = mock_update_changelog.call_args + self.assertEqual(args[0], "CHANGELOG.md") + self.assertEqual(args[1], "1.2.4") + self.assertEqual(kwargs.get("message"), "Test release") + self.assertEqual(kwargs.get("preview"), False) + + # repo root is derived from pyproject path; we don't care about + # exact paths here, only that helpers are called with preview=False. + mock_update_flake.assert_called_once() + self.assertEqual(mock_update_flake.call_args[1].get("preview"), False) + + mock_update_pkgbuild.assert_called_once() + self.assertEqual(mock_update_pkgbuild.call_args[1].get("preview"), False) + + mock_update_spec.assert_called_once() + self.assertEqual(mock_update_spec.call_args[1].get("preview"), False) + + mock_update_debian_changelog.assert_called_once() + self.assertEqual( + mock_update_debian_changelog.call_args[1].get("preview"), + False, + ) + + # Git operations + mock_get_current_branch.assert_called_once() + self.assertEqual(mock_get_current_branch.return_value, "develop") + + git_calls = [c.args[0] for c in mock_run_git_command.call_args_list] + self.assertIn('git commit -am "Release version 1.2.4"', git_calls) + self.assertIn('git tag -a v1.2.4 -m "Test release"', git_calls) + self.assertIn("git push origin develop", git_calls) + self.assertIn("git push origin --tags", git_calls) + + @patch("pkgmgr.release._run_git_command") + @patch("pkgmgr.release.update_debian_changelog") + @patch("pkgmgr.release.update_spec_version") + @patch("pkgmgr.release.update_pkgbuild_version") + @patch("pkgmgr.release.update_flake_version") + @patch("pkgmgr.release.get_current_branch", return_value="develop") + @patch("pkgmgr.release.update_changelog") + @patch("pkgmgr.release.update_pyproject_version") + @patch("pkgmgr.release._bump_semver") + @patch("pkgmgr.release._determine_current_version") + def test_release_preview_mode_skips_git_and_uses_preview_flag( + self, + mock_determine_current_version, + mock_bump_semver, + mock_update_pyproject, + mock_update_changelog, + mock_get_current_branch, + mock_update_flake, + mock_update_pkgbuild, + mock_update_spec, + mock_update_debian_changelog, + mock_run_git_command, + ) -> None: + mock_determine_current_version.return_value = SemVer(1, 2, 3) + mock_bump_semver.return_value = SemVer(1, 2, 4) + + release( + pyproject_path="pyproject.toml", + changelog_path="CHANGELOG.md", + release_type="patch", + message="Preview release", + preview=True, + ) + + # All update helpers must be called with preview=True + mock_update_pyproject.assert_called_once() + self.assertTrue(mock_update_pyproject.call_args[1].get("preview")) + + mock_update_changelog.assert_called_once() + self.assertTrue(mock_update_changelog.call_args[1].get("preview")) + + mock_update_flake.assert_called_once() + self.assertTrue(mock_update_flake.call_args[1].get("preview")) + + mock_update_pkgbuild.assert_called_once() + self.assertTrue(mock_update_pkgbuild.call_args[1].get("preview")) + + mock_update_spec.assert_called_once() + self.assertTrue(mock_update_spec.call_args[1].get("preview")) + + mock_update_debian_changelog.assert_called_once() + self.assertTrue(mock_update_debian_changelog.call_args[1].get("preview")) + + # In preview mode no git commands must be executed + mock_run_git_command.assert_not_called() + + +if __name__ == "__main__": + unittest.main()