Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76b7f84989 | ||
|
|
1b53263f87 | ||
|
|
71823c2f48 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,3 +1,18 @@
|
|||||||
|
## [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.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)
|
||||||
|
|
||||||
|
|
||||||
## [0.1.0] - 2025-12-08
|
## [0.1.0] - 2025-12-08
|
||||||
|
|
||||||
* Updated to correct version
|
* Updated to correct version
|
||||||
|
|||||||
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.1.0
|
pkgver=0.4.1
|
||||||
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')
|
||||||
|
|||||||
18
debian/changelog
vendored
18
debian/changelog
vendored
@@ -1,3 +1,21 @@
|
|||||||
|
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 <kevin@veen.world> 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 <kevin@veen.world> Mon, 08 Dec 2025 23:02:43 +0100
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 08 Dec 2025 20:31:19 +0100
|
||||||
|
|
||||||
package-manager (0.1.0-1) unstable; urgency=medium
|
package-manager (0.1.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* Updated to correct version
|
* Updated to correct version
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "0.1.0";
|
version = "0.4.1";
|
||||||
|
|
||||||
# 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.1.0
|
Version: 0.4.1
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# pkgmgr/branch_commands.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -12,7 +13,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 +79,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
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
# pkgmgr/cli_core/commands/branch.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pkgmgr.cli_core.context import CLIContext
|
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:
|
def handle_branch(args, ctx: CLIContext) -> None:
|
||||||
@@ -12,6 +13,7 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
|||||||
|
|
||||||
Currently supported:
|
Currently supported:
|
||||||
- pkgmgr branch open [<name>] [--base <branch>]
|
- pkgmgr branch open [<name>] [--base <branch>]
|
||||||
|
- pkgmgr branch close [<name>] [--base <branch>]
|
||||||
"""
|
"""
|
||||||
if args.subcommand == "open":
|
if args.subcommand == "open":
|
||||||
open_branch(
|
open_branch(
|
||||||
@@ -21,5 +23,13 @@ def handle_branch(args, ctx: CLIContext) -> None:
|
|||||||
)
|
)
|
||||||
return
|
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}")
|
print(f"Unknown branch subcommand: {args.subcommand}")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# pkgmgr/release.py
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
@@ -18,6 +19,14 @@ Additional behaviour:
|
|||||||
- If `preview=True` (from --preview), no files are written and no
|
- If `preview=True` (from --preview), no files are written and no
|
||||||
Git commands are executed. Instead, a detailed summary of the
|
Git commands are executed. Instead, a detailed summary of the
|
||||||
planned changes and commands is printed.
|
planned changes and commands is printed.
|
||||||
|
- If `preview=False` and not forced, the release is executed in two
|
||||||
|
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.
|
||||||
|
- 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
|
||||||
@@ -32,6 +41,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,
|
||||||
@@ -142,8 +152,14 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
|
|||||||
tmp.write(initial_message.strip() + "\n")
|
tmp.write(initial_message.strip() + "\n")
|
||||||
tmp.flush()
|
tmp.flush()
|
||||||
|
|
||||||
# Open editor
|
# Open editor (best-effort; fall back gracefully if not available)
|
||||||
|
try:
|
||||||
subprocess.call([editor, tmp_path])
|
subprocess.call([editor, tmp_path])
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(
|
||||||
|
f"[WARN] Editor {editor!r} not found; proceeding without "
|
||||||
|
"interactive changelog message."
|
||||||
|
)
|
||||||
|
|
||||||
# Read back content
|
# Read back content
|
||||||
try:
|
try:
|
||||||
@@ -598,34 +614,31 @@ def update_debian_changelog(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public release entry point
|
# Internal implementation (single-phase, preview or real)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def release(
|
def _release_impl(
|
||||||
pyproject_path: str = "pyproject.toml",
|
pyproject_path: str = "pyproject.toml",
|
||||||
changelog_path: str = "CHANGELOG.md",
|
changelog_path: str = "CHANGELOG.md",
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
Perform a release by:
|
Internal implementation that performs a single-phase release.
|
||||||
|
|
||||||
1. Determining the current version from Git tags.
|
If `preview` is True:
|
||||||
2. Computing the next version (major/minor/patch).
|
- No files are written.
|
||||||
3. Updating pyproject.toml with the new version.
|
- No git commands are executed.
|
||||||
4. Updating CHANGELOG.md with a new entry.
|
- Planned actions are printed.
|
||||||
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
|
If `preview` is False:
|
||||||
are executed. Instead, the planned actions are printed.
|
- Files are updated.
|
||||||
|
- 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()
|
||||||
@@ -645,8 +658,8 @@ def release(
|
|||||||
|
|
||||||
# 2) Update files.
|
# 2) Update files.
|
||||||
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
|
||||||
# Let update_changelog resolve or edit the message; reuse it for debian.
|
# Let update_changelog resolve or edit the message; capture it separately.
|
||||||
message = update_changelog(
|
changelog_message = update_changelog(
|
||||||
changelog_path,
|
changelog_path,
|
||||||
new_ver_str,
|
new_ver_str,
|
||||||
message=message,
|
message=message,
|
||||||
@@ -663,6 +676,12 @@ def release(
|
|||||||
spec_path = os.path.join(repo_root, "package-manager.spec")
|
spec_path = os.path.join(repo_root, "package-manager.spec")
|
||||||
update_spec_version(spec_path, new_ver_str, preview=preview)
|
update_spec_version(spec_path, new_ver_str, preview=preview)
|
||||||
|
|
||||||
|
# Determine an effective message for tag & Debian changelog.
|
||||||
|
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")
|
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
|
||||||
# Use repo directory name as a simple default for package name
|
# Use repo directory name as a simple default for package name
|
||||||
package_name = os.path.basename(repo_root) or "package-manager"
|
package_name = os.path.basename(repo_root) or "package-manager"
|
||||||
@@ -670,13 +689,13 @@ def release(
|
|||||||
debian_changelog_path,
|
debian_changelog_path,
|
||||||
package_name=package_name,
|
package_name=package_name,
|
||||||
new_version=new_ver_str,
|
new_version=new_ver_str,
|
||||||
message=message,
|
message=effective_message,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3) Git operations: stage, commit, tag, push.
|
# 3) Git operations: stage, commit, tag, push.
|
||||||
commit_msg = f"Release version {new_ver_str}"
|
commit_msg = f"Release version {new_ver_str}"
|
||||||
tag_msg = message or commit_msg
|
tag_msg = effective_message or commit_msg
|
||||||
|
|
||||||
try:
|
try:
|
||||||
branch = get_current_branch() or "main"
|
branch = get_current_branch() or "main"
|
||||||
@@ -702,6 +721,18 @@ def release(
|
|||||||
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
|
||||||
|
|
||||||
@@ -715,6 +746,136 @@ def release(
|
|||||||
|
|
||||||
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)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def release(
|
||||||
|
pyproject_path: str = "pyproject.toml",
|
||||||
|
changelog_path: str = "CHANGELOG.md",
|
||||||
|
release_type: str = "patch",
|
||||||
|
message: Optional[str] = None,
|
||||||
|
preview: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
close: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
High-level release entry point.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
|
||||||
|
- 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().
|
||||||
|
|
||||||
|
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.
|
||||||
|
if preview:
|
||||||
|
_release_impl(
|
||||||
|
pyproject_path=pyproject_path,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
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,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
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,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
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,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
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):
|
||||||
|
print("\n[INFO] Release aborted (no confirmation).")
|
||||||
|
return
|
||||||
|
|
||||||
|
if answer not in ("y", "yes"):
|
||||||
|
print("Release aborted by user. No changes were made.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n[INFO] Running REAL release...\n")
|
||||||
|
_release_impl(
|
||||||
|
pyproject_path=pyproject_path,
|
||||||
|
changelog_path=changelog_path,
|
||||||
|
release_type=release_type,
|
||||||
|
message=message,
|
||||||
|
preview=False,
|
||||||
|
close=close,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI entry point for standalone use
|
# CLI entry point for standalone use
|
||||||
@@ -750,7 +911,30 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--preview",
|
"--preview",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Preview release changes without modifying files or running git.",
|
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."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -763,4 +947,6 @@ if __name__ == "__main__":
|
|||||||
release_type=args.release_type,
|
release_type=args.release_type,
|
||||||
message=args.message,
|
message=args.message,
|
||||||
preview=args.preview,
|
preview=args.preview,
|
||||||
|
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.1.0"
|
version = "0.4.1"
|
||||||
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