diff --git a/CHANGELOG.md b/CHANGELOG.md index 019da2a..a128505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [0.4.3] - 2025-12-09 + +* Implement current-directory repository selection for release and proxy commands, unify selection semantics across CLI layers, extend release workflow with --close, integrate branch closing logic, fix wiring for get_repo_identifier/get_repo_dir, update packaging files (PKGBUILD, spec, flake.nix, pyproject), and add comprehensive unit/e2e tests for release and branch commands (see ChatGPT conversation: https://chatgpt.com/share/69375cfe-9e00-800f-bd65-1bd5937e1696) + + +## [0.4.2] - 2025-12-09 + +* Wire pkgmgr release CLI to new helper and add unit tests (see ChatGPT conversation: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62) + + +## [0.4.1] - 2025-12-08 + +* Add branch close subcommand and integrate release close/editor flow (ChatGPT: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62) + + +## [0.4.0] - 2025-12-08 + +* Add branch closing helper and --close flag to release command, including CLI wiring and tests (see https://chatgpt.com/share/69374aec-74ec-800f-bde3-5d91dfdb9b91) + ## [0.3.0] - 2025-12-08 * Massive refactor and feature expansion: @@ -10,7 +29,6 @@ - Expanded E2E tests for list, proxy, and selection logic Konversation: https://chatgpt.com/share/693745c3-b8d8-800f-aa29-c8481a2ffae1 - ## [0.2.0] - 2025-12-08 * Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630) diff --git a/PKGBUILD b/PKGBUILD index 5ac643b..43c7a7f 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Kevin Veen-Birkenbach pkgname=package-manager -pkgver=0.3.0 +pkgver=0.4.3 pkgrel=1 pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." arch=('any') diff --git a/debian/changelog b/debian/changelog index afd90f3..b926f13 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,27 @@ +package-manager (0.4.3-1) unstable; urgency=medium + + * Implement current-directory repository selection for release and proxy commands, unify selection semantics across CLI layers, extend release workflow with --close, integrate branch closing logic, fix wiring for get_repo_identifier/get_repo_dir, update packaging files (PKGBUILD, spec, flake.nix, pyproject), and add comprehensive unit/e2e tests for release and branch commands (see ChatGPT conversation: https://chatgpt.com/share/69375cfe-9e00-800f-bd65-1bd5937e1696) + + -- Kevin Veen-Birkenbach Tue, 09 Dec 2025 00:29:08 +0100 + +package-manager (0.4.2-1) unstable; urgency=medium + + * Wire pkgmgr release CLI to new helper and add unit tests (see ChatGPT conversation: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62) + + -- Kevin Veen-Birkenbach Tue, 09 Dec 2025 00:03:46 +0100 + +package-manager (0.4.1-1) unstable; urgency=medium + + * Add branch close subcommand and integrate release close/editor flow (ChatGPT: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62) + + -- Kevin Veen-Birkenbach Mon, 08 Dec 2025 23:20:28 +0100 + +package-manager (0.4.0-1) unstable; urgency=medium + + * Add branch closing helper and --close flag to release command, including CLI wiring and tests (see https://chatgpt.com/share/69374aec-74ec-800f-bde3-5d91dfdb9b91) + + -- Kevin Veen-Birkenbach Mon, 08 Dec 2025 23:02:43 +0100 + package-manager (0.3.0-1) unstable; urgency=medium * Massive refactor and feature expansion: diff --git a/flake.nix b/flake.nix index a7afead..75510bf 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ rec { pkgmgr = pyPkgs.buildPythonApplication { pname = "package-manager"; - version = "0.3.0"; + version = "0.4.3"; # Use the git repo as source src = ./.; diff --git a/package-manager.spec b/package-manager.spec index 0e775df..fd3db8b 100644 --- a/package-manager.spec +++ b/package-manager.spec @@ -1,5 +1,5 @@ Name: package-manager -Version: 0.3.0 +Version: 0.4.3 Release: 1%{?dist} Summary: Wrapper that runs Kevin's package-manager via Nix flake diff --git a/pkgmgr/branch_commands.py b/pkgmgr/branch_commands.py index 480df1d..d0fe43c 100644 --- a/pkgmgr/branch_commands.py +++ b/pkgmgr/branch_commands.py @@ -1,3 +1,4 @@ +# pkgmgr/branch_commands.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- @@ -12,7 +13,7 @@ from __future__ import annotations from typing import Optional -from pkgmgr.git_utils import run_git, GitError +from pkgmgr.git_utils import run_git, GitError, get_current_branch def open_branch( @@ -78,3 +79,136 @@ def open_branch( raise RuntimeError( f"Failed to push new branch {name!r} to origin: {exc}" ) from exc + + +def _resolve_base_branch( + preferred: str, + fallback: str, + cwd: str, +) -> str: + """ + Resolve the base branch to use for merging. + + Try `preferred` (default: main) first, then `fallback` (default: master). + Raise RuntimeError if neither exists. + """ + for candidate in (preferred, fallback): + try: + run_git(["rev-parse", "--verify", candidate], cwd=cwd) + return candidate + except GitError: + continue + + raise RuntimeError( + f"Neither {preferred!r} nor {fallback!r} exist in this repository." + ) + + +def close_branch( + name: Optional[str], + base_branch: str = "main", + fallback_base: str = "master", + cwd: str = ".", +) -> None: + """ + Merge a feature branch into the main/master branch and optionally delete it. + + Steps: + 1) Determine branch name (argument or current branch) + 2) Resolve base branch (prefers `base_branch`, falls back to `fallback_base`) + 3) Ask for confirmation (y/N) + 4) git fetch origin + 5) git checkout + 6) git pull origin + 7) git merge --no-ff + 8) git push origin + 9) Delete branch locally and on origin + + If the user does not confirm with 'y', the operation is aborted. + """ + + # 1) Determine which branch to close + if not name: + try: + name = get_current_branch(cwd=cwd) + except GitError as exc: + raise RuntimeError(f"Failed to detect current branch: {exc}") from exc + + if not name: + raise RuntimeError("Branch name must not be empty.") + + # 2) Resolve base branch (main/master) + target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd) + + if name == target_base: + raise RuntimeError( + f"Refusing to close base branch {target_base!r}. " + "Please specify a feature branch." + ) + + # 3) Confirmation prompt + prompt = ( + f"Merge branch '{name}' into '{target_base}' and delete it afterwards? " + "(y/N): " + ) + answer = input(prompt).strip().lower() + if answer != "y": + print("Aborted closing branch.") + return + + # 4) Fetch from origin + try: + run_git(["fetch", "origin"], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to fetch from origin before closing branch {name!r}: {exc}" + ) from exc + + # 5) Checkout base branch + try: + run_git(["checkout", target_base], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to checkout base branch {target_base!r}: {exc}" + ) from exc + + # 6) Pull latest base + try: + run_git(["pull", "origin", target_base], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to pull latest changes for base branch {target_base!r}: {exc}" + ) from exc + + # 7) Merge feature branch into base + try: + run_git(["merge", "--no-ff", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to merge branch {name!r} into {target_base!r}: {exc}" + ) from exc + + # 8) Push updated base + try: + run_git(["push", "origin", target_base], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to push base branch {target_base!r} to origin after merge: {exc}" + ) from exc + + # 9) Delete feature branch locally + try: + run_git(["branch", "-d", name], cwd=cwd) + except GitError as exc: + raise RuntimeError( + f"Failed to delete local branch {name!r} after merge: {exc}" + ) from exc + + # 10) Delete feature branch on origin (best effort) + try: + run_git(["push", "origin", "--delete", name], cwd=cwd) + except GitError as exc: + # Remote delete is nice-to-have; surface as RuntimeError for clarity. + raise RuntimeError( + f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}" + ) from exc diff --git a/pkgmgr/cli_core/commands/branch.py b/pkgmgr/cli_core/commands/branch.py index 4d95e96..ed0a1d3 100644 --- a/pkgmgr/cli_core/commands/branch.py +++ b/pkgmgr/cli_core/commands/branch.py @@ -1,9 +1,10 @@ +# pkgmgr/cli_core/commands/branch.py from __future__ import annotations import sys from pkgmgr.cli_core.context import CLIContext -from pkgmgr.branch_commands import open_branch +from pkgmgr.branch_commands import open_branch, close_branch def handle_branch(args, ctx: CLIContext) -> None: @@ -11,7 +12,8 @@ def handle_branch(args, ctx: CLIContext) -> None: Handle `pkgmgr branch` subcommands. Currently supported: - - pkgmgr branch open [] [--base ] + - pkgmgr branch open [] [--base ] + - pkgmgr branch close [] [--base ] """ if args.subcommand == "open": open_branch( @@ -21,5 +23,13 @@ def handle_branch(args, ctx: CLIContext) -> None: ) return + if args.subcommand == "close": + close_branch( + name=getattr(args, "name", None), + base_branch=getattr(args, "base", "main"), + cwd=".", + ) + return + print(f"Unknown branch subcommand: {args.subcommand}") sys.exit(2) diff --git a/pkgmgr/cli_core/commands/release.py b/pkgmgr/cli_core/commands/release.py index b6afaf9..92828f6 100644 --- a/pkgmgr/cli_core/commands/release.py +++ b/pkgmgr/cli_core/commands/release.py @@ -1,12 +1,31 @@ +# pkgmgr/cli_core/commands/release.py +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Release command wiring for the pkgmgr CLI. + +This module implements the `pkgmgr release` subcommand on top of the +generic selection logic from cli_core.dispatch. It does not define its +own subparser; the CLI surface is configured in cli_core.parser. + +Responsibilities: + - Take the parsed argparse.Namespace for the `release` command. + - Use the list of selected repositories provided by dispatch_command(). + - Optionally list affected repositories when --list is set. + - For each selected repository, run pkgmgr.release.release(...) in + the context of that repository directory. +""" + from __future__ import annotations import os -import sys -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from pkgmgr.cli_core.context import CLIContext from pkgmgr.get_repo_dir import get_repo_dir -from pkgmgr import release as rel +from pkgmgr.get_repo_identifier import get_repo_identifier +from pkgmgr.release import release as run_release Repository = Dict[str, Any] @@ -18,59 +37,63 @@ def handle_release( selected: List[Repository], ) -> None: """ - Handle the 'release' command. + Handle the `pkgmgr release` subcommand. - Creates a release by incrementing the version and updating the changelog - 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. + Flow: + 1) Use the `selected` repositories as computed by dispatch_command(). + 2) If --list is given, print the identifiers of the selected repos + and return without running any release. + 3) For each selected repository: + - Resolve its identifier and local directory. + - Change into that directory. + - Call pkgmgr.release.release(...) with the parsed options. """ - if not selected: - print("No repositories selected for release.") - sys.exit(1) + print("[pkgmgr] No repositories selected for release.") + return + + # List-only mode: show which repositories would be affected. + if getattr(args, "list", False): + print("[pkgmgr] Repositories that would be affected by this release:") + for repo in selected: + identifier = get_repo_identifier(repo, ctx.all_repositories) + print(f" - {identifier}") + return + + for repo in selected: + identifier = get_repo_identifier(repo, ctx.all_repositories) + + repo_dir = repo.get("directory") + if not repo_dir: + try: + repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) + except Exception: + repo_dir = None + + if not repo_dir or not os.path.isdir(repo_dir): + print( + f"[WARN] Skipping repository {identifier}: " + "local directory does not exist." + ) + continue - 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'." + f"[pkgmgr] Running release for repository {identifier} " + f"in '{repo_dir}'..." ) - sys.exit(1) - original_dir = os.getcwd() - - repo = selected[0] - - 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"[ERROR] Repository directory does not exist locally: {repo_dir}" - ) - sys.exit(1) - - 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) + # Change to repo directory and invoke the helper. + cwd_before = os.getcwd() + try: + os.chdir(repo_dir) + run_release( + pyproject_path="pyproject.toml", + changelog_path="CHANGELOG.md", + release_type=args.release_type, + message=args.message or None, + preview=getattr(args, "preview", False), + force=getattr(args, "force", False), + close=getattr(args, "close", False), + ) + finally: + os.chdir(cwd_before) diff --git a/pkgmgr/cli_core/dispatch.py b/pkgmgr/cli_core/dispatch.py index 6bed915..dd4f14a 100644 --- a/pkgmgr/cli_core/dispatch.py +++ b/pkgmgr/cli_core/dispatch.py @@ -3,12 +3,14 @@ from __future__ import annotations +import os import sys -from typing import List +from typing import List, Dict, Any from pkgmgr.cli_core.context import CLIContext from pkgmgr.cli_core.proxy import maybe_handle_proxy from pkgmgr.get_selected_repos import get_selected_repos +from pkgmgr.get_repo_dir import get_repo_dir from pkgmgr.cli_core.commands import ( handle_repos_command, @@ -22,6 +24,63 @@ from pkgmgr.cli_core.commands import ( ) +def _has_explicit_selection(args) -> bool: + """ + Return True if the user explicitly selected repositories via + identifiers / --all / --category / --tag / --string. + """ + identifiers = getattr(args, "identifiers", []) or [] + use_all = getattr(args, "all", False) + categories = getattr(args, "category", []) or [] + tags = getattr(args, "tag", []) or [] + string_filter = getattr(args, "string", "") or "" + + return bool( + use_all + or identifiers + or categories + or tags + or string_filter + ) + + +def _select_repo_for_current_directory( + ctx: CLIContext, +) -> List[Dict[str, Any]]: + """ + Heuristic: find the repository whose local directory matches the + current working directory or is the closest parent. + + Example: + - Repo directory: /home/kevin/Repositories/foo + - CWD: /home/kevin/Repositories/foo/subdir + → 'foo' is selected. + """ + cwd = os.path.abspath(os.getcwd()) + candidates: List[tuple[str, Dict[str, Any]]] = [] + + for repo in ctx.all_repositories: + repo_dir = repo.get("directory") + if not repo_dir: + try: + repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) + except Exception: + repo_dir = None + if not repo_dir: + continue + + repo_dir_abs = os.path.abspath(os.path.expanduser(repo_dir)) + if cwd == repo_dir_abs or cwd.startswith(repo_dir_abs + os.sep): + candidates.append((repo_dir_abs, repo)) + + if not candidates: + return [] + + # Pick the repo with the longest (most specific) path. + candidates.sort(key=lambda item: len(item[0]), reverse=True) + return [candidates[0][1]] + + def dispatch_command(args, ctx: CLIContext) -> None: """ Dispatch the parsed arguments to the appropriate command handler. @@ -52,7 +111,15 @@ def dispatch_command(args, ctx: CLIContext) -> None: ] if getattr(args, "command", None) in commands_with_selection: - selected = get_selected_repos(args, ctx.all_repositories) + if _has_explicit_selection(args): + # Classic selection logic (identifiers / --all / filters) + selected = get_selected_repos(args, ctx.all_repositories) + else: + # Default per help text: repository of current folder. + selected = _select_repo_for_current_directory(ctx) + # If none is found, leave 'selected' empty. + # Individual handlers will then emit a clear message instead + # of silently picking an unrelated repository. else: selected = [] diff --git a/pkgmgr/cli_core/parser.py b/pkgmgr/cli_core/parser.py index 29c4655..4c57032 100644 --- a/pkgmgr/cli_core/parser.py +++ b/pkgmgr/cli_core/parser.py @@ -360,10 +360,32 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: release_parser.add_argument( "-m", "--message", - default="", - help="Optional release message to add to the changelog and tag.", + default=None, + help=( + "Optional release message to add to the changelog and tag." + ), ) + # Generic selection / preview / list / extra_args add_identifier_arguments(release_parser) + # Close current branch after successful release + release_parser.add_argument( + "--close", + action="store_true", + help=( + "Close the current branch after a successful release in each " + "repository, if it is not main/master." + ), + ) + # Force: skip preview+confirmation and run release directly + release_parser.add_argument( + "-f", + "--force", + action="store_true", + help=( + "Skip the interactive preview+confirmation step and run the " + "release directly." + ), + ) # ------------------------------------------------------------ # version diff --git a/pkgmgr/cli_core/proxy.py b/pkgmgr/cli_core/proxy.py index b6e2a0d..1b7f709 100644 --- a/pkgmgr/cli_core/proxy.py +++ b/pkgmgr/cli_core/proxy.py @@ -4,14 +4,16 @@ from __future__ import annotations import argparse +import os import sys -from typing import Dict, List +from typing import Dict, List, Any from pkgmgr.cli_core.context import CLIContext from pkgmgr.clone_repos import clone_repos from pkgmgr.exec_proxy_command import exec_proxy_command from pkgmgr.pull_with_verification import pull_with_verification from pkgmgr.get_selected_repos import get_selected_repos +from pkgmgr.get_repo_dir import get_repo_dir PROXY_COMMANDS: Dict[str, List[str]] = { @@ -104,6 +106,57 @@ def _add_proxy_identifier_arguments(parser: argparse.ArgumentParser) -> None: ) +def _proxy_has_explicit_selection(args: argparse.Namespace) -> bool: + """ + Same semantics as in the main dispatch: + True if the user explicitly selected repositories. + """ + identifiers = getattr(args, "identifiers", []) or [] + use_all = getattr(args, "all", False) + categories = getattr(args, "category", []) or [] + string_filter = getattr(args, "string", "") or "" + + # Proxy commands currently do not support --tag, so it is not checked here. + return bool( + use_all + or identifiers + or categories + or string_filter + ) + + +def _select_repo_for_current_directory( + ctx: CLIContext, +) -> List[Dict[str, Any]]: + """ + Heuristic: find the repository whose local directory matches the + current working directory or is the closest parent. + """ + cwd = os.path.abspath(os.getcwd()) + candidates: List[tuple[str, Dict[str, Any]]] = [] + + for repo in ctx.all_repositories: + repo_dir = repo.get("directory") + if not repo_dir: + try: + repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) + except Exception: + repo_dir = None + if not repo_dir: + continue + + repo_dir_abs = os.path.abspath(os.path.expanduser(repo_dir)) + if cwd == repo_dir_abs or cwd.startswith(repo_dir_abs + os.sep): + candidates.append((repo_dir_abs, repo)) + + if not candidates: + return [] + + # Pick the repo with the longest (most specific) path. + candidates.sort(key=lambda item: len(item[0]), reverse=True) + return [candidates[0][1]] + + def register_proxy_commands( subparsers: argparse._SubParsersAction, ) -> None: @@ -157,7 +210,17 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool: if args.command not in all_proxy_subcommands: return False - selected = get_selected_repos(args, ctx.all_repositories) + # Default semantics: without explicit selection → repo of current folder. + if _proxy_has_explicit_selection(args): + selected = get_selected_repos(args, ctx.all_repositories) + else: + selected = _select_repo_for_current_directory(ctx) + if not selected: + print( + "[ERROR] No repository matches the current directory. " + "Specify identifiers or use --all/--category/--string." + ) + sys.exit(1) for command, subcommands in PROXY_COMMANDS.items(): if args.command not in subcommands: @@ -194,4 +257,4 @@ def maybe_handle_proxy(args: argparse.Namespace, ctx: CLIContext) -> bool: sys.exit(0) - return True \ No newline at end of file + return True diff --git a/pkgmgr/release.py b/pkgmgr/release.py index 863654e..ede1eef 100644 --- a/pkgmgr/release.py +++ b/pkgmgr/release.py @@ -1,3 +1,4 @@ +# pkgmgr/release.py #!/usr/bin/env python3 # -*- coding: utf-8 -*- @@ -22,12 +23,14 @@ Additional behaviour: phases: 1) Preview-only run (dry-run). 2) Interactive confirmation, then real release if confirmed. - This confirmation can be skipped with the `-f/--force` flag. + This confirmation can be skipped with the `force=True` flag. + - If `close=True` is used and the current branch is not main/master, + the branch will be closed via branch_commands.close_branch() after + a successful release. """ from __future__ import annotations -import argparse import os import re import subprocess @@ -37,6 +40,7 @@ from datetime import date, datetime from typing import Optional, Tuple from pkgmgr.git_utils import get_tags, get_current_branch, GitError +from pkgmgr.branch_commands import close_branch from pkgmgr.versioning import ( SemVer, find_latest_version, @@ -137,7 +141,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: 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" @@ -147,10 +150,14 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: tmp.write(initial_message.strip() + "\n") tmp.flush() - # Open editor - subprocess.call([editor, tmp_path]) + try: + subprocess.call([editor, tmp_path]) + except FileNotFoundError: + print( + f"[WARN] Editor {editor!r} not found; proceeding without " + "interactive changelog message." + ) - # Read back content try: with open(tmp_path, "r", encoding="utf-8") as f: content = f.read() @@ -160,7 +167,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: except OSError: pass - # Filter out commented lines and return joined text lines = [ line for line in content.splitlines() if not line.strip().startswith("#") @@ -186,14 +192,6 @@ def update_pyproject_version( version = "X.Y.Z" 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", encoding="utf-8") as f: @@ -231,13 +229,6 @@ def update_flake_version( ) -> 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.") @@ -282,13 +273,6 @@ def update_pkgbuild_version( 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.") @@ -301,7 +285,6 @@ def update_pkgbuild_version( 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, @@ -312,9 +295,8 @@ def update_pkgbuild_version( if ver_count == 0: print("[WARN] No pkgver line found in PKGBUILD.") - new_content = content # revert to original if we didn't change anything + new_content = content - # Reset pkgrel to 1 rel_pattern = r"^(pkgrel\s*=\s*)(.+)$" new_content, rel_count = re.subn( rel_pattern, @@ -343,19 +325,6 @@ def update_spec_version( ) -> 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.") @@ -368,7 +337,6 @@ def update_spec_version( 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, @@ -380,12 +348,10 @@ def update_spec_version( 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) @@ -428,21 +394,11 @@ def update_changelog( """ 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( @@ -470,7 +426,6 @@ def update_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") @@ -495,8 +450,6 @@ def update_changelog( 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( @@ -515,12 +468,6 @@ def _get_git_config_value(key: str) -> Optional[str]: 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") @@ -552,12 +499,6 @@ def update_debian_changelog( ) -> 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.") @@ -565,15 +506,12 @@ def update_debian_changelog( 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}." - ) + body_line = message.strip() if message else f"Automated release {new_version}." stanza = ( f"{first_line}\n\n" f" * {body_line}\n\n" @@ -613,23 +551,12 @@ def _release_impl( release_type: str = "patch", message: Optional[str] = None, preview: bool = False, + close: bool = False, ) -> None: """ Internal implementation that performs a single-phase release. - - If `preview` is True: - - No files are written. - - No git commands are executed. - - Planned actions are printed. - - If `preview` is False: - - Files are updated. - - Git commit, tag, and push are executed. """ - # 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) @@ -639,20 +566,16 @@ def _release_impl( 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_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) @@ -662,20 +585,23 @@ def _release_impl( spec_path = os.path.join(repo_root, "package-manager.spec") update_spec_version(spec_path, new_ver_str, preview=preview) + effective_message: Optional[str] = message + if effective_message is None and isinstance(changelog_message, str): + if changelog_message.strip(): + effective_message = changelog_message.strip() + 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, + message=effective_message, preview=preview, ) - # 3) Git operations: stage, commit, tag, push. commit_msg = f"Release version {new_ver_str}" - tag_msg = message or commit_msg + tag_msg = effective_message or commit_msg try: branch = get_current_branch() or "main" @@ -683,7 +609,6 @@ def _release_impl( 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, @@ -701,6 +626,18 @@ def _release_impl( 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") + + if close and branch not in ("main", "master"): + print( + f"[PREVIEW] Would also close branch {branch} after the release " + "(close=True and branch is not main/master)." + ) + elif close: + print( + f"[PREVIEW] close=True but current branch is {branch}; " + "no branch would be closed." + ) + print("Preview completed. No changes were made.") return @@ -714,9 +651,26 @@ def _release_impl( print(f"Release {new_ver_str} completed.") + if close: + if branch in ("main", "master"): + print( + f"[INFO] close=True but current branch is {branch}; " + "nothing to close." + ) + return + + print( + f"[INFO] Closing branch {branch} after successful release " + "(close=True and branch is not main/master)..." + ) + try: + close_branch(name=branch, base_branch="main", cwd=".") + except Exception as exc: # pragma: no cover + print(f"[WARN] Failed to close branch {branch} automatically: {exc}") + # --------------------------------------------------------------------------- -# Public release entry point (with preview-first + confirmation logic) +# Public release entry point # --------------------------------------------------------------------------- @@ -727,6 +681,7 @@ def release( message: Optional[str] = None, preview: bool = False, force: bool = False, + close: bool = False, ) -> None: """ High-level release entry point. @@ -735,26 +690,13 @@ def release( - preview=True: * Single-phase PREVIEW only. - * No files are changed, no git commands are executed. - * `force` is ignored in this mode. - preview=False, force=True: * Single-phase REAL release, no interactive preview. - * Files are changed and git commands are executed immediately. - preview=False, force=False: - * Two-phase flow (intended default for interactive CLI use): - 1) PREVIEW: dry-run, printing all planned actions. - 2) Ask the user for confirmation: - "Proceed with the actual release? [y/N]: " - If confirmed, perform the REAL release. - Otherwise, abort without changes. - - * In non-interactive environments (stdin not a TTY), the - confirmation step is skipped automatically and a single - REAL phase is executed, to avoid blocking on input(). + * Two-phase flow (intended default for interactive CLI use). """ - # Explicit preview mode: just do a single PREVIEW phase and exit. if preview: _release_impl( pyproject_path=pyproject_path, @@ -762,10 +704,10 @@ def release( release_type=release_type, message=message, preview=True, + close=close, ) return - # Non-preview, but forced: run REAL release directly. if force: _release_impl( pyproject_path=pyproject_path, @@ -773,10 +715,10 @@ def release( release_type=release_type, message=message, preview=False, + close=close, ) return - # Non-interactive environment? Skip confirmation to avoid blocking. if not sys.stdin.isatty(): _release_impl( pyproject_path=pyproject_path, @@ -784,10 +726,10 @@ def release( release_type=release_type, message=message, preview=False, + close=close, ) return - # Interactive two-phase flow: print("[INFO] Running preview before actual release...\n") _release_impl( pyproject_path=pyproject_path, @@ -795,9 +737,9 @@ def release( release_type=release_type, message=message, preview=True, + close=close, ) - # Ask for confirmation try: answer = input("Proceed with the actual release? [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): @@ -815,68 +757,5 @@ def release( release_type=release_type, message=message, preview=False, - ) - - -# --------------------------------------------------------------------------- -# 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. " - "This mode never executes the real release." - ), - ) - parser.add_argument( - "-f", - "--force", - dest="force", - action="store_true", - help=( - "Skip the interactive preview+confirmation step and run the " - "release directly." - ), - ) - 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, - force=args.force, + close=close, ) diff --git a/pyproject.toml b/pyproject.toml index c4e1da0..918a25f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "package-manager" -version = "0.3.0" +version = "0.4.3" 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 69fc7f0..e2d8e5e 100644 --- a/tests/e2e/test_integration_branch_commands.py +++ b/tests/e2e/test_integration_branch_commands.py @@ -11,8 +11,8 @@ class TestIntegrationBranchCommands(unittest.TestCase): Integration tests for the `pkgmgr branch` CLI wiring. 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. + the high-level helpers to ensure that argument parsing and + dispatch behave as expected. """ def _run_pkgmgr(self, extra_args: list[str]) -> None: @@ -64,6 +64,46 @@ class TestIntegrationBranchCommands(unittest.TestCase): self.assertEqual(kwargs.get("base_branch"), "main") self.assertEqual(kwargs.get("cwd"), ".") + # ------------------------------------------------------------------ + # close subcommand + # ------------------------------------------------------------------ + + @patch("pkgmgr.cli_core.commands.branch.close_branch") + def test_branch_close_with_name_and_base(self, mock_close_branch) -> None: + """ + `pkgmgr branch close feature/test --base develop` must forward + the name and base branch to close_branch() with cwd=".". + """ + self._run_pkgmgr( + ["branch", "close", "feature/test", "--base", "develop"] + ) + + mock_close_branch.assert_called_once() + _, kwargs = mock_close_branch.call_args + self.assertEqual(kwargs.get("name"), "feature/test") + self.assertEqual(kwargs.get("base_branch"), "develop") + self.assertEqual(kwargs.get("cwd"), ".") + + @patch("pkgmgr.cli_core.commands.branch.close_branch") + def test_branch_close_without_name_uses_default_base( + self, + mock_close_branch, + ) -> None: + """ + `pkgmgr branch close` without a name must still call close_branch(), + passing name=None and the default base branch 'main'. + + The branch helper will then resolve the actual base (main/master) + internally. + """ + self._run_pkgmgr(["branch", "close"]) + + mock_close_branch.assert_called_once() + _, kwargs = mock_close_branch.call_args + self.assertIsNone(kwargs.get("name")) + self.assertEqual(kwargs.get("base_branch"), "main") + self.assertEqual(kwargs.get("cwd"), ".") + if __name__ == "__main__": unittest.main() diff --git a/tests/e2e/test_integration_release_commands.py b/tests/e2e/test_integration_release_commands.py index b418768..03f9064 100644 --- a/tests/e2e/test_integration_release_commands.py +++ b/tests/e2e/test_integration_release_commands.py @@ -1,99 +1,75 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +End-to-end style integration tests for the `pkgmgr release` CLI command. + +These tests exercise the top-level `pkgmgr` entry point by invoking +the module as `__main__` and verifying that the underlying +`pkgmgr.release.release()` function is called with the expected +arguments, in particular the new `close` flag. +""" + from __future__ import annotations -import os import runpy import sys import unittest - - -PROJECT_ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..") -) +from unittest.mock import patch class TestIntegrationReleaseCommand(unittest.TestCase): - 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. + """Integration tests for `pkgmgr release` wiring.""" - argv must include the program name as argv[0], e.g. "": - ["", "release", "patch", "pkgmgr", "--preview"] + def _run_pkgmgr(self, argv: list[str]) -> None: + """ + Helper to invoke the `pkgmgr` console script via `run_module`. + + This simulates a real CLI call like: + + pkgmgr release minor --preview --close """ - cmd_repr = " ".join(argv[1:]) original_argv = list(sys.argv) - try: 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 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: - # 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)." - ) + # Entry point: the `pkgmgr` module is the console script. + runpy.run_module("pkgmgr", run_name="__main__") finally: sys.argv = original_argv - def test_release_for_unknown_repo_fails_cleanly(self) -> None: + @patch("pkgmgr.release.release") + def test_release_without_close_flag(self, mock_release) -> None: """ - Releasing a non-existent repository identifier must fail - with a non-zero exit code, but without crashing the interpreter. + Calling `pkgmgr release patch --preview` should *not* enable + the `close` flag by default. """ - argv = [ - "", - "release", - "patch", - "does-not-exist-xyz", - ] - self._run_pkgmgr(argv, expect_success=False) + self._run_pkgmgr(["pkgmgr", "release", "patch", "--preview"]) - def test_release_preview_for_pkgmgr_repository(self) -> None: - """ - Sanity-check the happy path for the CLI: + mock_release.assert_called_once() + _args, kwargs = mock_release.call_args - - 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", - ] + # CLI wiring + self.assertEqual(kwargs.get("release_type"), "patch") + self.assertTrue(kwargs.get("preview"), "preview should be True when --preview is used") + # Default: no --close → close=False + self.assertFalse(kwargs.get("close"), "close must be False when --close is not given") - original_cwd = os.getcwd() - try: - os.chdir(PROJECT_ROOT) - self._run_pkgmgr(argv, expect_success=True) - finally: - os.chdir(original_cwd) + @patch("pkgmgr.release.release") + def test_release_with_close_flag(self, mock_release) -> None: + """ + Calling `pkgmgr release minor --preview --close` should pass + close=True into pkgmgr.release.release(). + """ + self._run_pkgmgr(["pkgmgr", "release", "minor", "--preview", "--close"]) + + mock_release.assert_called_once() + _args, kwargs = mock_release.call_args + + # CLI wiring + self.assertEqual(kwargs.get("release_type"), "minor") + self.assertTrue(kwargs.get("preview"), "preview should be True when --preview is used") + # With --close → close=True + self.assertTrue(kwargs.get("close"), "close must be True when --close is given") if __name__ == "__main__": diff --git a/tests/unit/pkgmgr/cli_core/__init__.py b/tests/unit/pkgmgr/cli_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/cli_core/commands/__init__.py b/tests/unit/pkgmgr/cli_core/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/cli_core/commands/test_release.py b/tests/unit/pkgmgr/cli_core/commands/test_release.py new file mode 100644 index 0000000..0c2e983 --- /dev/null +++ b/tests/unit/pkgmgr/cli_core/commands/test_release.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Unit tests for pkgmgr.cli_core.commands.release. + +These tests focus on the wiring layer: + - Argument handling for the release command as defined by the + top-level parser (cli_core.parser.create_parser). + - Correct invocation of pkgmgr.release.release(...) for the + selected repositories. + - Behaviour of --preview, --list, --close, and -f/--force. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import List +from unittest.mock import patch, call + +import argparse +import unittest + + +class TestReleaseCommand(unittest.TestCase): + """ + Tests for the `pkgmgr release` CLI wiring. + """ + + def _make_ctx(self, all_repos: List[dict]) -> SimpleNamespace: + """ + Create a minimal CLIContext-like object for tests. + + Only the attributes that handle_release() uses are provided. + """ + return SimpleNamespace( + config_merged={}, + repositories_base_dir="/base/dir", + all_repositories=all_repos, + binaries_dir="/bin", + user_config_path="/tmp/config.yaml", + ) + + def _parse_release_args(self, argv: List[str]) -> argparse.Namespace: + """ + Build a real top-level parser and parse the given argv list + to obtain the Namespace for the `release` command. + """ + from pkgmgr.cli_core.parser import create_parser + + parser = create_parser("test parser") + args = parser.parse_args(argv) + self.assertEqual(args.command, "release") + return args + + @patch("pkgmgr.cli_core.commands.release.os.path.isdir", return_value=True) + @patch("pkgmgr.cli_core.commands.release.run_release") + @patch("pkgmgr.cli_core.commands.release.get_repo_dir") + @patch("pkgmgr.cli_core.commands.release.get_repo_identifier") + @patch("pkgmgr.cli_core.commands.release.os.chdir") + @patch("pkgmgr.cli_core.commands.release.os.getcwd", return_value="/cwd") + def test_release_with_close_and_message( + self, + mock_getcwd, + mock_chdir, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_run_release, + mock_isdir, + ) -> None: + """ + The release handler should call pkgmgr.release.release() with: + - release_type (e.g. minor) + - provided message + - preview flag + - force flag + - close flag + + It must change into the repository directory and then back. + """ + from pkgmgr.cli_core.commands.release import handle_release + + repo = {"name": "dummy-repo"} + selected = [repo] + ctx = self._make_ctx(selected) + + mock_get_repo_identifier.return_value = "dummy-id" + mock_get_repo_dir.return_value = "/repos/dummy" + + argv = [ + "release", + "minor", + "dummy-id", + "-m", + "Close branch after minor release", + "--close", + "-f", + ] + args = self._parse_release_args(argv) + + handle_release(args, ctx, selected) + + # We should have changed into the repo dir and then back. + mock_chdir.assert_has_calls( + [call("/repos/dummy"), call("/cwd")] + ) + + # And run_release should be invoked once with the expected parameters. + mock_run_release.assert_called_once_with( + pyproject_path="pyproject.toml", + changelog_path="CHANGELOG.md", + release_type="minor", + message="Close branch after minor release", + preview=False, + force=True, + close=True, + ) + + @patch("pkgmgr.cli_core.commands.release.os.path.isdir", return_value=True) + @patch("pkgmgr.cli_core.commands.release.run_release") + @patch("pkgmgr.cli_core.commands.release.get_repo_dir") + @patch("pkgmgr.cli_core.commands.release.get_repo_identifier") + @patch("pkgmgr.cli_core.commands.release.os.chdir") + @patch("pkgmgr.cli_core.commands.release.os.getcwd", return_value="/cwd") + def test_release_preview_mode( + self, + mock_getcwd, + mock_chdir, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_run_release, + mock_isdir, + ) -> None: + """ + In preview mode, the handler should pass preview=True to the + release helper and force=False by default. + """ + from pkgmgr.cli_core.commands.release import handle_release + + repo = {"name": "dummy-repo"} + selected = [repo] + ctx = self._make_ctx(selected) + + mock_get_repo_identifier.return_value = "dummy-id" + mock_get_repo_dir.return_value = "/repos/dummy" + + argv = [ + "release", + "patch", + "dummy-id", + "--preview", + ] + args = self._parse_release_args(argv) + + handle_release(args, ctx, selected) + + mock_run_release.assert_called_once_with( + pyproject_path="pyproject.toml", + changelog_path="CHANGELOG.md", + release_type="patch", + message=None, + preview=True, + force=False, + close=False, + ) + + @patch("pkgmgr.cli_core.commands.release.run_release") + @patch("pkgmgr.cli_core.commands.release.get_repo_dir") + @patch("pkgmgr.cli_core.commands.release.get_repo_identifier") + def test_release_list_mode_does_not_invoke_helper( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_run_release, + ) -> None: + """ + When --list is provided, the handler should print the list of affected + repositories and must NOT invoke run_release(). + """ + from pkgmgr.cli_core.commands.release import handle_release + + repo1 = {"name": "repo-1"} + repo2 = {"name": "repo-2"} + selected = [repo1, repo2] + ctx = self._make_ctx(selected) + + mock_get_repo_identifier.side_effect = ["id-1", "id-2"] + + argv = [ + "release", + "major", + "--list", + ] + args = self._parse_release_args(argv) + + handle_release(args, ctx, selected) + + mock_run_release.assert_not_called() + self.assertEqual( + mock_get_repo_identifier.call_args_list, + [call(repo1, selected), call(repo2, selected)], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/test_cli_branch.py b/tests/unit/pkgmgr/test_cli_branch.py index f58ab9d..22e1561 100644 --- a/tests/unit/pkgmgr/test_cli_branch.py +++ b/tests/unit/pkgmgr/test_cli_branch.py @@ -66,6 +66,55 @@ class TestCliBranch(unittest.TestCase): self.assertEqual(call_kwargs.get("base_branch"), "main") self.assertEqual(call_kwargs.get("cwd"), ".") + # ------------------------------------------------------------------ + # close subcommand + # ------------------------------------------------------------------ + + @patch("pkgmgr.cli_core.commands.branch.close_branch") + def test_handle_branch_close_forwards_args_to_close_branch(self, mock_close_branch) -> None: + """ + handle_branch('close') should call close_branch with name, base and cwd='.'. + """ + args = SimpleNamespace( + command="branch", + subcommand="close", + name="feature/cli-close", + base="develop", + ) + + ctx = self._dummy_ctx() + + handle_branch(args, ctx) + + mock_close_branch.assert_called_once() + _, call_kwargs = mock_close_branch.call_args + self.assertEqual(call_kwargs.get("name"), "feature/cli-close") + self.assertEqual(call_kwargs.get("base_branch"), "develop") + self.assertEqual(call_kwargs.get("cwd"), ".") + + @patch("pkgmgr.cli_core.commands.branch.close_branch") + def test_handle_branch_close_uses_default_base_when_not_set(self, mock_close_branch) -> None: + """ + If --base is not passed for 'close', argparse gives base='main' + (default), and handle_branch should propagate that to close_branch. + """ + args = SimpleNamespace( + command="branch", + subcommand="close", + name=None, + base="main", + ) + + ctx = self._dummy_ctx() + + handle_branch(args, ctx) + + mock_close_branch.assert_called_once() + _, call_kwargs = mock_close_branch.call_args + self.assertIsNone(call_kwargs.get("name")) + self.assertEqual(call_kwargs.get("base_branch"), "main") + self.assertEqual(call_kwargs.get("cwd"), ".") + def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None: """ Unknown branch subcommand should result in SystemExit(2). diff --git a/tests/unit/pkgmgr/test_release.py b/tests/unit/pkgmgr/test_release.py index 13def83..4231c86 100644 --- a/tests/unit/pkgmgr/test_release.py +++ b/tests/unit/pkgmgr/test_release.py @@ -365,6 +365,7 @@ class TestUpdateDebianChangelog(unittest.TestCase): class TestReleaseOrchestration(unittest.TestCase): + @patch("pkgmgr.release.sys.stdin.isatty", return_value=False) @patch("pkgmgr.release._run_git_command") @patch("pkgmgr.release.update_debian_changelog") @patch("pkgmgr.release.update_spec_version") @@ -387,6 +388,7 @@ class TestReleaseOrchestration(unittest.TestCase): mock_update_spec, mock_update_debian_changelog, mock_run_git_command, + mock_isatty, ) -> None: mock_determine_current_version.return_value = SemVer(1, 2, 3) mock_bump_semver.return_value = SemVer(1, 2, 4) @@ -449,6 +451,7 @@ class TestReleaseOrchestration(unittest.TestCase): self.assertIn("git push origin develop", git_calls) self.assertIn("git push origin --tags", git_calls) + @patch("pkgmgr.release.sys.stdin.isatty", return_value=False) @patch("pkgmgr.release._run_git_command") @patch("pkgmgr.release.update_debian_changelog") @patch("pkgmgr.release.update_spec_version") @@ -471,6 +474,7 @@ class TestReleaseOrchestration(unittest.TestCase): mock_update_spec, mock_update_debian_changelog, mock_run_git_command, + mock_isatty, ) -> None: mock_determine_current_version.return_value = SemVer(1, 2, 3) mock_bump_semver.return_value = SemVer(1, 2, 4)