Release version 0.4.0
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
|
## [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.2.0] - 2025-12-08
|
## [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)
|
* Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||||
|
|
||||||
pkgname=package-manager
|
pkgname=package-manager
|
||||||
pkgver=0.2.0
|
pkgver=0.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
6
debian/changelog
vendored
6
debian/changelog
vendored
@@ -1,3 +1,9 @@
|
|||||||
|
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 <kevin@veen.world> Mon, 08 Dec 2025 23:02:43 +0100
|
||||||
|
|
||||||
package-manager (0.2.0-1) unstable; urgency=medium
|
package-manager (0.2.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)
|
* Add preview-first release workflow and extended packaging support (see ChatGPT conversation: https://chatgpt.com/share/693722b4-af9c-800f-bccc-8a4036e99630)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "0.2.0";
|
version = "0.4.0";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 0.2.0
|
Version: 0.4.0
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
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(
|
def open_branch(
|
||||||
@@ -78,3 +78,136 @@ def open_branch(
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to push new branch {name!r} to origin: {exc}"
|
f"Failed to push new branch {name!r} to origin: {exc}"
|
||||||
) from 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 <base>
|
||||||
|
6) git pull origin <base>
|
||||||
|
7) git merge --no-ff <name>
|
||||||
|
8) git push origin <base>
|
||||||
|
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
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ Additional behaviour:
|
|||||||
1) Preview-only run (dry-run).
|
1) Preview-only run (dry-run).
|
||||||
2) Interactive confirmation, then real release if confirmed.
|
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 `-f/--force` flag.
|
||||||
|
- If `-c/--close` 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
|
from __future__ import annotations
|
||||||
@@ -37,6 +40,7 @@ from datetime import date, datetime
|
|||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from pkgmgr.git_utils import get_tags, get_current_branch, GitError
|
from pkgmgr.git_utils import get_tags, get_current_branch, GitError
|
||||||
|
from pkgmgr.branch_commands import close_branch
|
||||||
from pkgmgr.versioning import (
|
from pkgmgr.versioning import (
|
||||||
SemVer,
|
SemVer,
|
||||||
find_latest_version,
|
find_latest_version,
|
||||||
@@ -613,6 +617,7 @@ def _release_impl(
|
|||||||
release_type: str = "patch",
|
release_type: str = "patch",
|
||||||
message: Optional[str] = None,
|
message: Optional[str] = None,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
|
close: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Internal implementation that performs a single-phase release.
|
Internal implementation that performs a single-phase release.
|
||||||
@@ -625,6 +630,8 @@ def _release_impl(
|
|||||||
If `preview` is False:
|
If `preview` is False:
|
||||||
- Files are updated.
|
- Files are updated.
|
||||||
- Git commit, tag, and push are executed.
|
- Git commit, tag, and push are executed.
|
||||||
|
- If `close` is True and the current branch is not main/master,
|
||||||
|
the branch will be closed after a successful release.
|
||||||
"""
|
"""
|
||||||
# 1) Determine the current version from Git tags.
|
# 1) Determine the current version from Git tags.
|
||||||
current_ver = _determine_current_version()
|
current_ver = _determine_current_version()
|
||||||
@@ -701,6 +708,18 @@ def _release_impl(
|
|||||||
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
print(f'[PREVIEW] Would run: git tag -a {new_tag} -m "{tag_msg}"')
|
||||||
print(f"[PREVIEW] Would run: git push origin {branch}")
|
print(f"[PREVIEW] Would run: git push origin {branch}")
|
||||||
print("[PREVIEW] Would run: git push origin --tags")
|
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 specified and branch is not main/master)."
|
||||||
|
)
|
||||||
|
elif close:
|
||||||
|
print(
|
||||||
|
f"[PREVIEW] --close specified but current branch is {branch}; "
|
||||||
|
"no branch would be closed."
|
||||||
|
)
|
||||||
|
|
||||||
print("Preview completed. No changes were made.")
|
print("Preview completed. No changes were made.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -714,6 +733,24 @@ def _release_impl(
|
|||||||
|
|
||||||
print(f"Release {new_ver_str} completed.")
|
print(f"Release {new_ver_str} completed.")
|
||||||
|
|
||||||
|
# Optional: close the current branch after a successful release.
|
||||||
|
if close:
|
||||||
|
if branch in ("main", "master"):
|
||||||
|
print(
|
||||||
|
f"[INFO] --close specified but current branch is {branch}; "
|
||||||
|
"nothing to close."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] Closing branch {branch} after successful release "
|
||||||
|
"(--close enabled and branch is not main/master)..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
close_branch(name=branch, base_branch="main", cwd=".")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public release entry point (with preview-first + confirmation logic)
|
# Public release entry point (with preview-first + confirmation logic)
|
||||||
@@ -727,6 +764,7 @@ def release(
|
|||||||
message: Optional[str] = None,
|
message: Optional[str] = None,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
close: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
High-level release entry point.
|
High-level release entry point.
|
||||||
@@ -753,6 +791,9 @@ def release(
|
|||||||
* In non-interactive environments (stdin not a TTY), the
|
* In non-interactive environments (stdin not a TTY), the
|
||||||
confirmation step is skipped automatically and a single
|
confirmation step is skipped automatically and a single
|
||||||
REAL phase is executed, to avoid blocking on input().
|
REAL phase is executed, to avoid blocking on input().
|
||||||
|
|
||||||
|
The `close` flag controls whether the current branch should be
|
||||||
|
closed after a successful REAL release (only if it is not main/master).
|
||||||
"""
|
"""
|
||||||
# Explicit preview mode: just do a single PREVIEW phase and exit.
|
# Explicit preview mode: just do a single PREVIEW phase and exit.
|
||||||
if preview:
|
if preview:
|
||||||
@@ -762,6 +803,7 @@ def release(
|
|||||||
release_type=release_type,
|
release_type=release_type,
|
||||||
message=message,
|
message=message,
|
||||||
preview=True,
|
preview=True,
|
||||||
|
close=close,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -773,6 +815,7 @@ def release(
|
|||||||
release_type=release_type,
|
release_type=release_type,
|
||||||
message=message,
|
message=message,
|
||||||
preview=False,
|
preview=False,
|
||||||
|
close=close,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -784,6 +827,7 @@ def release(
|
|||||||
release_type=release_type,
|
release_type=release_type,
|
||||||
message=message,
|
message=message,
|
||||||
preview=False,
|
preview=False,
|
||||||
|
close=close,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -795,6 +839,7 @@ def release(
|
|||||||
release_type=release_type,
|
release_type=release_type,
|
||||||
message=message,
|
message=message,
|
||||||
preview=True,
|
preview=True,
|
||||||
|
close=close,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ask for confirmation
|
# Ask for confirmation
|
||||||
@@ -815,6 +860,7 @@ def release(
|
|||||||
release_type=release_type,
|
release_type=release_type,
|
||||||
message=message,
|
message=message,
|
||||||
preview=False,
|
preview=False,
|
||||||
|
close=close,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -867,6 +913,16 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
|||||||
"release directly."
|
"release directly."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--close",
|
||||||
|
dest="close",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Close the current branch after a successful release, "
|
||||||
|
"if it is not main/master."
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -879,4 +935,5 @@ if __name__ == "__main__":
|
|||||||
message=args.message,
|
message=args.message,
|
||||||
preview=args.preview,
|
preview=args.preview,
|
||||||
force=args.force,
|
force=args.force,
|
||||||
|
close=args.close,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "package-manager"
|
name = "package-manager"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
description = "Kevin's package-manager tool (pkgmgr)"
|
description = "Kevin's package-manager tool (pkgmgr)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ class TestIntegrationBranchCommands(unittest.TestCase):
|
|||||||
Integration tests for the `pkgmgr branch` CLI wiring.
|
Integration tests for the `pkgmgr branch` CLI wiring.
|
||||||
|
|
||||||
These tests execute the real entry point (main.py) and mock
|
These tests execute the real entry point (main.py) and mock
|
||||||
the high-level `open_branch` helper to ensure that argument
|
the high-level helpers to ensure that argument parsing and
|
||||||
parsing and dispatch behave as expected.
|
dispatch behave as expected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _run_pkgmgr(self, extra_args: list[str]) -> None:
|
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("base_branch"), "main")
|
||||||
self.assertEqual(kwargs.get("cwd"), ".")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import runpy
|
import runpy
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
PROJECT_ROOT = os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIntegrationReleaseCommand(unittest.TestCase):
|
class TestIntegrationReleaseCommand(unittest.TestCase):
|
||||||
def _run_pkgmgr(
|
"""Integration tests for `pkgmgr release` wiring."""
|
||||||
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. "":
|
def _run_pkgmgr(self, argv: list[str]) -> None:
|
||||||
["", "release", "patch", "pkgmgr", "--preview"]
|
"""
|
||||||
|
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)
|
original_argv = list(sys.argv)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sys.argv = argv
|
sys.argv = argv
|
||||||
try:
|
# Entry point: the `pkgmgr` module is the console script.
|
||||||
# Execute main.py as if called via `python main.py ...`
|
runpy.run_module("pkgmgr", run_name="__main__")
|
||||||
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)."
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
sys.argv = original_argv
|
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
|
Calling `pkgmgr release patch --preview` should *not* enable
|
||||||
with a non-zero exit code, but without crashing the interpreter.
|
the `close` flag by default.
|
||||||
"""
|
"""
|
||||||
argv = [
|
self._run_pkgmgr(["pkgmgr", "release", "patch", "--preview"])
|
||||||
"",
|
|
||||||
"release",
|
|
||||||
"patch",
|
|
||||||
"does-not-exist-xyz",
|
|
||||||
]
|
|
||||||
self._run_pkgmgr(argv, expect_success=False)
|
|
||||||
|
|
||||||
def test_release_preview_for_pkgmgr_repository(self) -> None:
|
mock_release.assert_called_once()
|
||||||
"""
|
_args, kwargs = mock_release.call_args
|
||||||
Sanity-check the happy path for the CLI:
|
|
||||||
|
|
||||||
- Runs `pkgmgr release patch pkgmgr --preview`
|
# CLI wiring
|
||||||
- Must exit with code 0
|
self.assertEqual(kwargs.get("release_type"), "patch")
|
||||||
- Uses the real configuration + repository selection
|
self.assertTrue(kwargs.get("preview"), "preview should be True when --preview is used")
|
||||||
- Exercises the new --preview mode end-to-end.
|
# Default: no --close → close=False
|
||||||
"""
|
self.assertFalse(kwargs.get("close"), "close must be False when --close is not given")
|
||||||
argv = [
|
|
||||||
"",
|
|
||||||
"release",
|
|
||||||
"patch",
|
|
||||||
"pkgmgr",
|
|
||||||
"--preview",
|
|
||||||
]
|
|
||||||
|
|
||||||
original_cwd = os.getcwd()
|
@patch("pkgmgr.release.release")
|
||||||
try:
|
def test_release_with_close_flag(self, mock_release) -> None:
|
||||||
os.chdir(PROJECT_ROOT)
|
"""
|
||||||
self._run_pkgmgr(argv, expect_success=True)
|
Calling `pkgmgr release minor --preview --close` should pass
|
||||||
finally:
|
close=True into pkgmgr.release.release().
|
||||||
os.chdir(original_cwd)
|
"""
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -66,6 +66,55 @@ class TestCliBranch(unittest.TestCase):
|
|||||||
self.assertEqual(call_kwargs.get("base_branch"), "main")
|
self.assertEqual(call_kwargs.get("base_branch"), "main")
|
||||||
self.assertEqual(call_kwargs.get("cwd"), ".")
|
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:
|
def test_handle_branch_unknown_subcommand_exits_with_code_2(self) -> None:
|
||||||
"""
|
"""
|
||||||
Unknown branch subcommand should result in SystemExit(2).
|
Unknown branch subcommand should result in SystemExit(2).
|
||||||
|
|||||||
Reference in New Issue
Block a user