2025-12-09 21:16:10 +01:00
|
|
|
# pkgmgr/actions/branch/__init__.py
|
2025-12-08 18:37:59 +01:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
High-level helpers for branch-related operations.
|
|
|
|
|
|
|
|
|
|
This module encapsulates the actual Git logic so the CLI layer
|
Refactor: Restructure pkgmgr into actions/, core/, and cli/ (full module breakup)
This commit introduces a large-scale structural refactor of the pkgmgr
codebase. All functionality has been moved from the previous flat
top-level layout into three clearly separated namespaces:
• pkgmgr.actions – high-level operations invoked by the CLI
• pkgmgr.core – pure logic, helpers, repository utilities,
versioning, git helpers, config IO, and
command resolution
• pkgmgr.cli – parser, dispatch, context, and command
handlers
Key improvements:
- Moved all “branch”, “release”, “changelog”, repo-management
actions, installer pipelines, and proxy execution logic into
pkgmgr.actions.<domain>.
- Reworked installer structure under
pkgmgr.actions.repository.install.installers
including OS-package installers, Nix, Python, and Makefile.
- Consolidated all low-level functionality under pkgmgr.core:
• git helpers → core/git
• config load/save → core/config
• repository helpers → core/repository
• versioning & semver → core/version
• command helpers (alias, resolve, run, ink) → core/command
- Replaced pkgmgr.cli_core with pkgmgr.cli and updated all imports.
- Added minimal __init__.py files for clean package exposure.
- Updated all E2E, integration, and unit tests with new module paths.
- Fixed patch targets so mocks point to the new structure.
- Ensured backward compatibility at the CLI boundary (pkgmgr entry point unchanged).
This refactor produces a cleaner, layered architecture:
- `core` = logic
- `actions` = orchestrated behaviour
- `cli` = user interface
Reference: ChatGPT-assisted refactor discussion
https://chatgpt.com/share/6938221c-e24c-800f-8317-7732cedf39b9
2025-12-09 14:20:19 +01:00
|
|
|
(pkgmgr.cli.commands.branch) stays thin and testable.
|
2025-12-08 18:37:59 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
Refactor: Restructure pkgmgr into actions/, core/, and cli/ (full module breakup)
This commit introduces a large-scale structural refactor of the pkgmgr
codebase. All functionality has been moved from the previous flat
top-level layout into three clearly separated namespaces:
• pkgmgr.actions – high-level operations invoked by the CLI
• pkgmgr.core – pure logic, helpers, repository utilities,
versioning, git helpers, config IO, and
command resolution
• pkgmgr.cli – parser, dispatch, context, and command
handlers
Key improvements:
- Moved all “branch”, “release”, “changelog”, repo-management
actions, installer pipelines, and proxy execution logic into
pkgmgr.actions.<domain>.
- Reworked installer structure under
pkgmgr.actions.repository.install.installers
including OS-package installers, Nix, Python, and Makefile.
- Consolidated all low-level functionality under pkgmgr.core:
• git helpers → core/git
• config load/save → core/config
• repository helpers → core/repository
• versioning & semver → core/version
• command helpers (alias, resolve, run, ink) → core/command
- Replaced pkgmgr.cli_core with pkgmgr.cli and updated all imports.
- Added minimal __init__.py files for clean package exposure.
- Updated all E2E, integration, and unit tests with new module paths.
- Fixed patch targets so mocks point to the new structure.
- Ensured backward compatibility at the CLI boundary (pkgmgr entry point unchanged).
This refactor produces a cleaner, layered architecture:
- `core` = logic
- `actions` = orchestrated behaviour
- `cli` = user interface
Reference: ChatGPT-assisted refactor discussion
https://chatgpt.com/share/6938221c-e24c-800f-8317-7732cedf39b9
2025-12-09 14:20:19 +01:00
|
|
|
from pkgmgr.core.git import run_git, GitError, get_current_branch
|
2025-12-08 18:37:59 +01:00
|
|
|
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Branch creation (open)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2025-12-08 18:37:59 +01:00
|
|
|
def open_branch(
|
|
|
|
|
name: Optional[str],
|
|
|
|
|
base_branch: str = "main",
|
2025-12-09 21:16:10 +01:00
|
|
|
fallback_base: str = "master",
|
2025-12-08 18:37:59 +01:00
|
|
|
cwd: str = ".",
|
|
|
|
|
) -> None:
|
|
|
|
|
"""
|
2025-12-09 21:16:10 +01:00
|
|
|
Create and push a new feature branch on top of a base branch.
|
|
|
|
|
|
|
|
|
|
The base branch is resolved by:
|
|
|
|
|
1. Trying 'base_branch' (default: 'main')
|
|
|
|
|
2. Falling back to 'fallback_base' (default: 'master')
|
2025-12-08 18:37:59 +01:00
|
|
|
|
|
|
|
|
Steps:
|
2025-12-09 21:16:10 +01:00
|
|
|
1) git fetch origin
|
|
|
|
|
2) git checkout <resolved_base>
|
|
|
|
|
3) git pull origin <resolved_base>
|
|
|
|
|
4) git checkout -b <name>
|
|
|
|
|
5) git push -u origin <name>
|
2025-12-08 18:37:59 +01:00
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
If `name` is None or empty, the user is prompted to enter one.
|
2025-12-08 18:37:59 +01:00
|
|
|
"""
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# Request name interactively if not provided
|
2025-12-08 18:37:59 +01:00
|
|
|
if not name:
|
|
|
|
|
name = input("Enter new branch name: ").strip()
|
|
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
|
raise RuntimeError("Branch name must not be empty.")
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# Resolve which base branch to use (main or master)
|
|
|
|
|
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
|
|
|
|
|
2025-12-08 18:37:59 +01:00
|
|
|
# 1) Fetch from origin
|
|
|
|
|
try:
|
|
|
|
|
run_git(["fetch", "origin"], cwd=cwd)
|
|
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Failed to fetch from origin before creating branch {name!r}: {exc}"
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
# 2) Checkout base branch
|
|
|
|
|
try:
|
2025-12-09 21:16:10 +01:00
|
|
|
run_git(["checkout", resolved_base], cwd=cwd)
|
2025-12-08 18:37:59 +01:00
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
2025-12-09 21:16:10 +01:00
|
|
|
f"Failed to checkout base branch {resolved_base!r}: {exc}"
|
2025-12-08 18:37:59 +01:00
|
|
|
) from exc
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 3) Pull latest changes for base branch
|
2025-12-08 18:37:59 +01:00
|
|
|
try:
|
2025-12-09 21:16:10 +01:00
|
|
|
run_git(["pull", "origin", resolved_base], cwd=cwd)
|
2025-12-08 18:37:59 +01:00
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
2025-12-09 21:16:10 +01:00
|
|
|
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
|
2025-12-08 18:37:59 +01:00
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
# 4) Create new branch
|
|
|
|
|
try:
|
|
|
|
|
run_git(["checkout", "-b", name], cwd=cwd)
|
|
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
2025-12-09 21:16:10 +01:00
|
|
|
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
|
2025-12-08 18:37:59 +01:00
|
|
|
) from exc
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 5) Push new branch to origin
|
2025-12-08 18:37:59 +01:00
|
|
|
try:
|
|
|
|
|
run_git(["push", "-u", "origin", name], cwd=cwd)
|
|
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Failed to push new branch {name!r} to origin: {exc}"
|
|
|
|
|
) from exc
|
2025-12-08 23:02:43 +01:00
|
|
|
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Base branch resolver (shared by open/close)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2025-12-08 23:02:43 +01:00
|
|
|
def _resolve_base_branch(
|
|
|
|
|
preferred: str,
|
|
|
|
|
fallback: str,
|
|
|
|
|
cwd: str,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""
|
2025-12-09 21:16:10 +01:00
|
|
|
Resolve the base branch to use.
|
|
|
|
|
|
|
|
|
|
Try `preferred` first (default: main),
|
|
|
|
|
fall back to `fallback` (default: master).
|
2025-12-08 23:02:43 +01:00
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Branch closing (merge + deletion)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
2025-12-08 23:02:43 +01:00
|
|
|
def close_branch(
|
|
|
|
|
name: Optional[str],
|
|
|
|
|
base_branch: str = "main",
|
|
|
|
|
fallback_base: str = "master",
|
|
|
|
|
cwd: str = ".",
|
|
|
|
|
) -> None:
|
|
|
|
|
"""
|
2025-12-09 21:16:10 +01:00
|
|
|
Merge a feature branch into the base branch and delete it afterwards.
|
2025-12-08 23:02:43 +01:00
|
|
|
|
|
|
|
|
Steps:
|
2025-12-09 21:16:10 +01:00
|
|
|
1) Determine the branch name (argument or current branch)
|
|
|
|
|
2) Resolve base branch (main/master)
|
|
|
|
|
3) Ask for confirmation
|
|
|
|
|
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
|
|
|
|
|
10) Delete branch on origin (best effort)
|
2025-12-08 23:02:43 +01:00
|
|
|
"""
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 1) Determine which branch should be closed
|
2025-12-08 23:02:43 +01:00
|
|
|
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.")
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 2) Resolve base branch
|
2025-12-08 23:02:43 +01:00
|
|
|
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."
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 3) Ask user for confirmation
|
2025-12-08 23:02:43 +01:00
|
|
|
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
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 5) Checkout base
|
2025-12-08 23:02:43 +01:00
|
|
|
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
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 6) Pull latest base state
|
2025-12-08 23:02:43 +01:00
|
|
|
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
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 7) Merge the feature branch
|
2025-12-08 23:02:43 +01:00
|
|
|
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(
|
2025-12-09 21:16:10 +01:00
|
|
|
f"Failed to push base branch {target_base!r} after merge: {exc}"
|
2025-12-08 23:02:43 +01:00
|
|
|
) from exc
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 9) Delete branch locally
|
2025-12-08 23:02:43 +01:00
|
|
|
try:
|
|
|
|
|
run_git(["branch", "-d", name], cwd=cwd)
|
|
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
2025-12-09 21:16:10 +01:00
|
|
|
f"Failed to delete local branch {name!r}: {exc}"
|
2025-12-08 23:02:43 +01:00
|
|
|
) from exc
|
|
|
|
|
|
2025-12-09 21:16:10 +01:00
|
|
|
# 10) Delete branch on origin (best effort)
|
2025-12-08 23:02:43 +01:00
|
|
|
try:
|
|
|
|
|
run_git(["push", "origin", "--delete", name], cwd=cwd)
|
|
|
|
|
except GitError as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
|
|
|
|
|
) from exc
|