Compare commits
3 Commits
9485bc9e3f
...
0119af330f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0119af330f | ||
|
|
e117115b7f | ||
|
|
755b78fcb7 |
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# expose subpackages for patch() / resolve_name() friendliness
|
||||
from . import release as release # noqa: F401
|
||||
|
||||
__all__ = ["release"]
|
||||
@@ -1,7 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from pkgmgr.core.git import run_git, GitError, get_current_branch
|
||||
from .utils import _resolve_base_branch
|
||||
|
||||
from pkgmgr.core.git.errors import GitError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.git.commands import (
|
||||
GitDeleteRemoteBranchError,
|
||||
checkout,
|
||||
delete_local_branch,
|
||||
delete_remote_branch,
|
||||
fetch,
|
||||
merge_no_ff,
|
||||
pull,
|
||||
push,
|
||||
)
|
||||
|
||||
from pkgmgr.core.git.queries import resolve_base_branch
|
||||
|
||||
|
||||
def close_branch(
|
||||
@@ -14,7 +28,6 @@ def close_branch(
|
||||
"""
|
||||
Merge a feature branch into the base branch and delete it afterwards.
|
||||
"""
|
||||
|
||||
# Determine branch name
|
||||
if not name:
|
||||
try:
|
||||
@@ -25,7 +38,7 @@ def close_branch(
|
||||
if not name:
|
||||
raise RuntimeError("Branch name must not be empty.")
|
||||
|
||||
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
target_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
|
||||
if name == target_base:
|
||||
raise RuntimeError(
|
||||
@@ -42,58 +55,20 @@ def close_branch(
|
||||
print("Aborted closing branch.")
|
||||
return
|
||||
|
||||
# Fetch
|
||||
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
|
||||
# Execute workflow (commands raise specific GitError subclasses)
|
||||
fetch("origin", cwd=cwd)
|
||||
checkout(target_base, cwd=cwd)
|
||||
pull("origin", target_base, cwd=cwd)
|
||||
merge_no_ff(name, cwd=cwd)
|
||||
push("origin", target_base, cwd=cwd)
|
||||
|
||||
# Checkout base
|
||||
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
|
||||
# Delete local branch (safe delete by default)
|
||||
delete_local_branch(name, cwd=cwd, force=False)
|
||||
|
||||
# Pull latest
|
||||
# Delete remote branch (special-case error message)
|
||||
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
|
||||
|
||||
# Merge
|
||||
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
|
||||
|
||||
# Push result
|
||||
try:
|
||||
run_git(["push", "origin", target_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to push base branch {target_base!r} after merge: {exc}"
|
||||
) from exc
|
||||
|
||||
# Delete local
|
||||
try:
|
||||
run_git(["branch", "-d", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to delete local branch {name!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# Delete remote
|
||||
try:
|
||||
run_git(["push", "origin", "--delete", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
delete_remote_branch("origin", name, cwd=cwd)
|
||||
except GitDeleteRemoteBranchError as exc:
|
||||
raise RuntimeError(
|
||||
f"Branch {name!r} deleted locally, but remote deletion failed: {exc}"
|
||||
) from exc
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from pkgmgr.core.git import run_git, GitError, get_current_branch
|
||||
from .utils import _resolve_base_branch
|
||||
|
||||
from pkgmgr.core.git.errors import GitError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.git.commands import (
|
||||
GitDeleteRemoteBranchError,
|
||||
delete_local_branch,
|
||||
delete_remote_branch,
|
||||
)
|
||||
|
||||
from pkgmgr.core.git.queries import resolve_base_branch
|
||||
|
||||
|
||||
def drop_branch(
|
||||
@@ -14,7 +23,6 @@ def drop_branch(
|
||||
"""
|
||||
Delete a branch locally and remotely without merging.
|
||||
"""
|
||||
|
||||
if not name:
|
||||
try:
|
||||
name = get_current_branch(cwd=cwd)
|
||||
@@ -24,7 +32,7 @@ def drop_branch(
|
||||
if not name:
|
||||
raise RuntimeError("Branch name must not be empty.")
|
||||
|
||||
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
target_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
|
||||
if name == target_base:
|
||||
raise RuntimeError(
|
||||
@@ -40,16 +48,12 @@ def drop_branch(
|
||||
print("Aborted dropping branch.")
|
||||
return
|
||||
|
||||
# Local delete
|
||||
delete_local_branch(name, cwd=cwd, force=False)
|
||||
|
||||
# Remote delete (special-case message)
|
||||
try:
|
||||
run_git(["branch", "-d", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(f"Failed to delete local branch {name!r}: {exc}") from exc
|
||||
|
||||
# Remote delete
|
||||
try:
|
||||
run_git(["push", "origin", "--delete", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
delete_remote_branch("origin", name, cwd=cwd)
|
||||
except GitDeleteRemoteBranchError as exc:
|
||||
raise RuntimeError(
|
||||
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
|
||||
) from exc
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from pkgmgr.core.git import run_git, GitError
|
||||
from .utils import _resolve_base_branch
|
||||
|
||||
from pkgmgr.core.git.commands import (
|
||||
checkout,
|
||||
create_branch,
|
||||
fetch,
|
||||
pull,
|
||||
push_upstream,
|
||||
)
|
||||
from pkgmgr.core.git.queries import resolve_base_branch
|
||||
|
||||
|
||||
def open_branch(
|
||||
@@ -13,7 +21,6 @@ def open_branch(
|
||||
"""
|
||||
Create and push a new feature branch on top of a base branch.
|
||||
"""
|
||||
|
||||
# Request name interactively if not provided
|
||||
if not name:
|
||||
name = input("Enter new branch name: ").strip()
|
||||
@@ -21,44 +28,13 @@ def open_branch(
|
||||
if not name:
|
||||
raise RuntimeError("Branch name must not be empty.")
|
||||
|
||||
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
resolved_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
|
||||
|
||||
# 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
|
||||
# Workflow (commands raise specific GitError subclasses)
|
||||
fetch("origin", cwd=cwd)
|
||||
checkout(resolved_base, cwd=cwd)
|
||||
pull("origin", resolved_base, cwd=cwd)
|
||||
|
||||
# 2) Checkout base branch
|
||||
try:
|
||||
run_git(["checkout", resolved_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to checkout base branch {resolved_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 3) Pull latest changes
|
||||
try:
|
||||
run_git(["pull", "origin", resolved_base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 4) Create new branch
|
||||
try:
|
||||
run_git(["checkout", "-b", name], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 5) Push new branch
|
||||
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
|
||||
# Create new branch from resolved base and push it with upstream tracking
|
||||
create_branch(name, resolved_base, cwd=cwd)
|
||||
push_upstream("origin", name, cwd=cwd)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
from __future__ import annotations
|
||||
from pkgmgr.core.git import run_git, GitError
|
||||
|
||||
|
||||
def _resolve_base_branch(
|
||||
preferred: str,
|
||||
fallback: str,
|
||||
cwd: str,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the base branch to use.
|
||||
|
||||
Try `preferred` first (default: main),
|
||||
fall back to `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."
|
||||
)
|
||||
@@ -3,17 +3,16 @@
|
||||
|
||||
"""
|
||||
Helpers to generate changelog information from Git history.
|
||||
|
||||
This module provides a small abstraction around `git log` so that
|
||||
CLI commands can request a changelog between two refs (tags, branches,
|
||||
commits) without dealing with raw subprocess calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.core.git import run_git, GitError
|
||||
from pkgmgr.core.git.queries import (
|
||||
get_changelog,
|
||||
GitChangelogQueryError,
|
||||
)
|
||||
|
||||
|
||||
def generate_changelog(
|
||||
@@ -25,48 +24,20 @@ def generate_changelog(
|
||||
"""
|
||||
Generate a plain-text changelog between two Git refs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cwd:
|
||||
Repository directory in which to run Git commands.
|
||||
from_ref:
|
||||
Optional starting reference (exclusive). If provided together
|
||||
with `to_ref`, the range `from_ref..to_ref` is used.
|
||||
If only `from_ref` is given, the range `from_ref..HEAD` is used.
|
||||
to_ref:
|
||||
Optional end reference (inclusive). If omitted, `HEAD` is used.
|
||||
include_merges:
|
||||
If False (default), merge commits are filtered out.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The output of `git log` formatted as a simple text changelog.
|
||||
If no commits are found or Git fails, an explanatory message
|
||||
is returned instead of raising.
|
||||
Returns a human-readable message instead of raising.
|
||||
"""
|
||||
# Determine the revision range
|
||||
if to_ref is None:
|
||||
to_ref = "HEAD"
|
||||
|
||||
if from_ref:
|
||||
rev_range = f"{from_ref}..{to_ref}"
|
||||
else:
|
||||
rev_range = to_ref
|
||||
|
||||
# Use a custom pretty format that includes tags/refs (%d)
|
||||
cmd = [
|
||||
"log",
|
||||
"--pretty=format:%h %d %s",
|
||||
]
|
||||
if not include_merges:
|
||||
cmd.append("--no-merges")
|
||||
cmd.append(rev_range)
|
||||
|
||||
rev_range = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
||||
try:
|
||||
output = run_git(cmd, cwd=cwd)
|
||||
except GitError as exc:
|
||||
# Do not raise to the CLI, return a human-readable error instead.
|
||||
output = get_changelog(
|
||||
cwd=cwd,
|
||||
from_ref=from_ref,
|
||||
to_ref=to_ref,
|
||||
include_merges=include_merges,
|
||||
)
|
||||
except GitChangelogQueryError as exc:
|
||||
return (
|
||||
f"[ERROR] Failed to generate changelog in {cwd!r} "
|
||||
f"for range {rev_range!r}:\n{exc}"
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Set
|
||||
from typing import Optional, Set
|
||||
|
||||
from pkgmgr.core.command.run import run_command
|
||||
from pkgmgr.core.git import GitError, run_git
|
||||
from pkgmgr.core.git.errors import GitError
|
||||
from pkgmgr.core.git.commands import (
|
||||
GitAddRemoteError,
|
||||
GitAddRemotePushUrlError,
|
||||
GitSetRemoteUrlError,
|
||||
add_remote,
|
||||
add_remote_push_url,
|
||||
set_remote_url,
|
||||
)
|
||||
from pkgmgr.core.git.queries import (
|
||||
get_remote_push_urls,
|
||||
list_remotes,
|
||||
)
|
||||
|
||||
from .types import MirrorMap, RepoMirrorContext, Repository
|
||||
|
||||
@@ -48,29 +59,20 @@ def determine_primary_remote_url(
|
||||
return build_default_ssh_url(repo)
|
||||
|
||||
|
||||
def _safe_git_output(args: List[str], cwd: str) -> Optional[str]:
|
||||
try:
|
||||
return run_git(args, cwd=cwd)
|
||||
except GitError:
|
||||
return None
|
||||
|
||||
|
||||
def has_origin_remote(repo_dir: str) -> bool:
|
||||
out = _safe_git_output(["remote"], cwd=repo_dir)
|
||||
return bool(out and "origin" in out.split())
|
||||
try:
|
||||
return "origin" in list_remotes(cwd=repo_dir)
|
||||
except GitError:
|
||||
return False
|
||||
|
||||
|
||||
def _set_origin_fetch_and_push(repo_dir: str, url: str, preview: bool) -> None:
|
||||
fetch = f"git remote set-url origin {url}"
|
||||
push = f"git remote set-url --push origin {url}"
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {fetch}")
|
||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {push}")
|
||||
return
|
||||
|
||||
run_command(fetch, cwd=repo_dir, preview=False)
|
||||
run_command(push, cwd=repo_dir, preview=False)
|
||||
"""
|
||||
Ensure origin has fetch URL and push URL set to the primary URL.
|
||||
Preview is handled by the underlying git runner.
|
||||
"""
|
||||
set_remote_url("origin", url, cwd=repo_dir, push=False, preview=preview)
|
||||
set_remote_url("origin", url, cwd=repo_dir, push=True, preview=preview)
|
||||
|
||||
|
||||
def _ensure_additional_push_urls(
|
||||
@@ -79,22 +81,21 @@ def _ensure_additional_push_urls(
|
||||
primary: str,
|
||||
preview: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure all mirror URLs (except primary) are configured as additional push URLs for origin.
|
||||
Preview is handled by the underlying git runner.
|
||||
"""
|
||||
desired: Set[str] = {u for u in mirrors.values() if u and u != primary}
|
||||
if not desired:
|
||||
return
|
||||
|
||||
out = _safe_git_output(
|
||||
["remote", "get-url", "--push", "--all", "origin"],
|
||||
cwd=repo_dir,
|
||||
)
|
||||
existing = set(out.splitlines()) if out else set()
|
||||
try:
|
||||
existing = get_remote_push_urls("origin", cwd=repo_dir)
|
||||
except GitError:
|
||||
existing = set()
|
||||
|
||||
for url in sorted(desired - existing):
|
||||
cmd = f"git remote set-url --add --push origin {url}"
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
|
||||
else:
|
||||
run_command(cmd, cwd=repo_dir, preview=False)
|
||||
add_remote_push_url("origin", url, cwd=repo_dir, preview=preview)
|
||||
|
||||
|
||||
def ensure_origin_remote(
|
||||
@@ -113,21 +114,23 @@ def ensure_origin_remote(
|
||||
print("[WARN] No primary mirror URL could be determined.")
|
||||
return
|
||||
|
||||
# 1) Ensure origin exists
|
||||
if not has_origin_remote(repo_dir):
|
||||
cmd = f"git remote add origin {primary}"
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
|
||||
else:
|
||||
run_command(cmd, cwd=repo_dir, preview=False)
|
||||
try:
|
||||
add_remote("origin", primary, cwd=repo_dir, preview=preview)
|
||||
except GitAddRemoteError as exc:
|
||||
print(f"[WARN] Failed to add origin remote: {exc}")
|
||||
return # without origin we cannot reliably proceed
|
||||
|
||||
_set_origin_fetch_and_push(repo_dir, primary, preview)
|
||||
|
||||
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
|
||||
|
||||
|
||||
def is_remote_reachable(url: str, cwd: Optional[str] = None) -> bool:
|
||||
# 2) Ensure origin fetch+push URLs are correct (ALWAYS, even if origin already existed)
|
||||
try:
|
||||
run_git(["ls-remote", "--exit-code", url], cwd=cwd or os.getcwd())
|
||||
return True
|
||||
except GitError:
|
||||
return False
|
||||
_set_origin_fetch_and_push(repo_dir, primary, preview)
|
||||
except GitSetRemoteUrlError as exc:
|
||||
# Do not abort: still try to add additional push URLs
|
||||
print(f"[WARN] Failed to set origin URLs: {exc}")
|
||||
|
||||
# 3) Ensure additional push URLs for mirrors
|
||||
try:
|
||||
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
|
||||
except GitAddRemotePushUrlError as exc:
|
||||
print(f"[WARN] Failed to add additional push URLs: {exc}")
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# src/pkgmgr/actions/mirror/remote_check.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from pkgmgr.core.git import GitError, run_git
|
||||
|
||||
|
||||
def probe_mirror(url: str, repo_dir: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Probe a remote mirror URL using `git ls-remote`.
|
||||
|
||||
Returns:
|
||||
(True, "") on success,
|
||||
(False, error_message) on failure.
|
||||
"""
|
||||
try:
|
||||
run_git(["ls-remote", url], cwd=repo_dir)
|
||||
return True, ""
|
||||
except GitError as exc:
|
||||
return False, str(exc)
|
||||
@@ -4,7 +4,7 @@ from typing import List
|
||||
|
||||
from .context import build_context
|
||||
from .git_remote import ensure_origin_remote, determine_primary_remote_url
|
||||
from .remote_check import probe_mirror
|
||||
from pkgmgr.core.git.queries import probe_remote_reachable
|
||||
from .remote_provision import ensure_remote_repository
|
||||
from .types import Repository
|
||||
|
||||
@@ -52,19 +52,14 @@ def _setup_remote_mirrors_for_repo(
|
||||
primary = determine_primary_remote_url(repo, ctx)
|
||||
if not primary:
|
||||
return
|
||||
|
||||
ok, msg = probe_mirror(primary, ctx.repo_dir)
|
||||
ok = probe_remote_reachable(primary, cwd=ctx.repo_dir)
|
||||
print("[OK]" if ok else "[WARN]", primary)
|
||||
if msg:
|
||||
print(msg)
|
||||
print()
|
||||
return
|
||||
|
||||
for name, url in ctx.resolved_mirrors.items():
|
||||
ok, msg = probe_mirror(url, ctx.repo_dir)
|
||||
ok = probe_remote_reachable(url, cwd=ctx.repo_dir)
|
||||
print(f"[OK] {name}: {url}" if ok else f"[WARN] {name}: {url}")
|
||||
if msg:
|
||||
print(msg)
|
||||
|
||||
print()
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pkgmgr.core.git import run_git
|
||||
from pkgmgr.core.git.queries import get_tags_at_ref
|
||||
from pkgmgr.core.version.semver import SemVer, is_semver_tag
|
||||
|
||||
|
||||
def head_semver_tags(cwd: str = ".") -> list[str]:
|
||||
out = run_git(["tag", "--points-at", "HEAD"], cwd=cwd)
|
||||
if not out:
|
||||
return []
|
||||
|
||||
tags = [t.strip() for t in out.splitlines() if t.strip()]
|
||||
tags = get_tags_at_ref("HEAD", cwd=cwd)
|
||||
tags = [t for t in tags if is_semver_tag(t) and t.startswith("v")]
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
return sorted(tags, key=SemVer.parse)
|
||||
|
||||
@@ -7,7 +7,7 @@ Version discovery and bumping helpers for the release workflow.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pkgmgr.core.git import get_tags
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import (
|
||||
SemVer,
|
||||
find_latest_version,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# src/pkgmgr/actions/release/workflow.py
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -6,7 +5,8 @@ import sys
|
||||
from typing import Optional
|
||||
|
||||
from pkgmgr.actions.branch import close_branch
|
||||
from pkgmgr.core.git import get_current_branch, GitError
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.core.git.queries import get_current_branch
|
||||
from pkgmgr.core.repository.paths import resolve_repo_paths
|
||||
|
||||
from .files import (
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.git import get_tags
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import extract_semver_from_tags
|
||||
from pkgmgr.actions.changelog import generate_changelog
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from pkgmgr.cli.context import CLIContext
|
||||
from pkgmgr.core.repository.dir import get_repo_dir
|
||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
||||
from pkgmgr.core.git import get_tags
|
||||
from pkgmgr.core.git.queries import get_tags
|
||||
from pkgmgr.core.version.semver import SemVer, find_latest_version
|
||||
from pkgmgr.core.version.installed import (
|
||||
get_installed_python_version,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Lightweight helper functions around Git commands.
|
||||
@@ -9,84 +8,10 @@ logic (release, version, changelog) does not have to deal with the
|
||||
details of subprocess handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from .errors import GitError
|
||||
from .run import run
|
||||
|
||||
import subprocess
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
"""Raised when a Git command fails in an unexpected way."""
|
||||
|
||||
|
||||
def run_git(args: List[str], cwd: str = ".") -> str:
|
||||
"""
|
||||
Run a Git command and return its stdout as a stripped string.
|
||||
|
||||
Raises GitError if the command fails.
|
||||
"""
|
||||
cmd = ["git"] + args
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise GitError(
|
||||
f"Git command failed in {cwd!r}: {' '.join(cmd)}\n"
|
||||
f"Exit code: {exc.returncode}\n"
|
||||
f"STDOUT:\n{exc.stdout}\n"
|
||||
f"STDERR:\n{exc.stderr}"
|
||||
) from exc
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_tags(cwd: str = ".") -> List[str]:
|
||||
"""
|
||||
Return a list of all tags in the repository in `cwd`.
|
||||
|
||||
If there are no tags, an empty list is returned.
|
||||
"""
|
||||
try:
|
||||
output = run_git(["tag"], cwd=cwd)
|
||||
except GitError as exc:
|
||||
# If the repo has no tags or is not a git repo, surface a clear error.
|
||||
# You can decide later if you want to treat this differently.
|
||||
if "not a git repository" in str(exc):
|
||||
raise
|
||||
# No tags: stdout may just be empty; treat this as empty list.
|
||||
return []
|
||||
|
||||
if not output:
|
||||
return []
|
||||
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def get_head_commit(cwd: str = ".") -> Optional[str]:
|
||||
"""
|
||||
Return the current HEAD commit hash, or None if it cannot be determined.
|
||||
"""
|
||||
try:
|
||||
output = run_git(["rev-parse", "HEAD"], cwd=cwd)
|
||||
except GitError:
|
||||
return None
|
||||
return output or None
|
||||
|
||||
|
||||
def get_current_branch(cwd: str = ".") -> Optional[str]:
|
||||
"""
|
||||
Return the current branch name, or None if it cannot be determined.
|
||||
|
||||
Note: In detached HEAD state this will return 'HEAD'.
|
||||
"""
|
||||
try:
|
||||
output = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
||||
except GitError:
|
||||
return None
|
||||
return output or None
|
||||
__all__ = [
|
||||
"GitError",
|
||||
"run"
|
||||
]
|
||||
|
||||
42
src/pkgmgr/core/git/commands/__init__.py
Normal file
42
src/pkgmgr/core/git/commands/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .checkout import GitCheckoutError, checkout
|
||||
from .delete_local_branch import GitDeleteLocalBranchError, delete_local_branch
|
||||
from .delete_remote_branch import GitDeleteRemoteBranchError, delete_remote_branch
|
||||
from .fetch import GitFetchError, fetch
|
||||
from .merge_no_ff import GitMergeError, merge_no_ff
|
||||
from .pull import GitPullError, pull
|
||||
from .push import GitPushError, push
|
||||
from .create_branch import GitCreateBranchError, create_branch
|
||||
from .push_upstream import GitPushUpstreamError, push_upstream
|
||||
|
||||
from .add_remote import GitAddRemoteError, add_remote
|
||||
from .set_remote_url import GitSetRemoteUrlError, set_remote_url
|
||||
from .add_remote_push_url import GitAddRemotePushUrlError, add_remote_push_url
|
||||
|
||||
__all__ = [
|
||||
"fetch",
|
||||
"checkout",
|
||||
"pull",
|
||||
"merge_no_ff",
|
||||
"push",
|
||||
"delete_local_branch",
|
||||
"delete_remote_branch",
|
||||
"create_branch",
|
||||
"push_upstream",
|
||||
"add_remote",
|
||||
"set_remote_url",
|
||||
"add_remote_push_url",
|
||||
"GitFetchError",
|
||||
"GitCheckoutError",
|
||||
"GitPullError",
|
||||
"GitMergeError",
|
||||
"GitPushError",
|
||||
"GitDeleteLocalBranchError",
|
||||
"GitDeleteRemoteBranchError",
|
||||
"GitCreateBranchError",
|
||||
"GitPushUpstreamError",
|
||||
"GitAddRemoteError",
|
||||
"GitSetRemoteUrlError",
|
||||
"GitAddRemotePushUrlError",
|
||||
]
|
||||
34
src/pkgmgr/core/git/commands/add_remote.py
Normal file
34
src/pkgmgr/core/git/commands/add_remote.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitAddRemoteError(GitCommandError):
|
||||
"""Raised when adding a remote fails."""
|
||||
|
||||
|
||||
def add_remote(
|
||||
name: str,
|
||||
url: str,
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Add a new remote.
|
||||
|
||||
Equivalent to:
|
||||
git remote add <name> <url>
|
||||
"""
|
||||
try:
|
||||
run(
|
||||
["remote", "add", name, url],
|
||||
cwd=cwd,
|
||||
preview=preview,
|
||||
)
|
||||
except GitError as exc:
|
||||
raise GitAddRemoteError(
|
||||
f"Failed to add remote {name!r} with URL {url!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
34
src/pkgmgr/core/git/commands/add_remote_push_url.py
Normal file
34
src/pkgmgr/core/git/commands/add_remote_push_url.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitAddRemotePushUrlError(GitCommandError):
|
||||
"""Raised when adding an additional push URL to a remote fails."""
|
||||
|
||||
|
||||
def add_remote_push_url(
|
||||
remote: str,
|
||||
url: str,
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Add an additional push URL to a remote.
|
||||
|
||||
Equivalent to:
|
||||
git remote set-url --add --push <remote> <url>
|
||||
"""
|
||||
try:
|
||||
run(
|
||||
["remote", "set-url", "--add", "--push", remote, url],
|
||||
cwd=cwd,
|
||||
preview=preview,
|
||||
)
|
||||
except GitError as exc:
|
||||
raise GitAddRemotePushUrlError(
|
||||
f"Failed to add push url {url!r} to remote {remote!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/commands/checkout.py
Normal file
18
src/pkgmgr/core/git/commands/checkout.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitCheckoutError(GitCommandError):
|
||||
"""Raised when checking out a branch fails."""
|
||||
|
||||
|
||||
def checkout(branch: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["checkout", branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitCheckoutError(
|
||||
f"Failed to checkout branch {branch!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
23
src/pkgmgr/core/git/commands/create_branch.py
Normal file
23
src/pkgmgr/core/git/commands/create_branch.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitCreateBranchError(GitCommandError):
|
||||
"""Raised when creating a new branch fails."""
|
||||
|
||||
|
||||
def create_branch(branch: str, base: str, cwd: str = ".") -> None:
|
||||
"""
|
||||
Create a new branch from a base branch.
|
||||
|
||||
Equivalent to: git checkout -b <branch> <base>
|
||||
"""
|
||||
try:
|
||||
run(["checkout", "-b", branch, base], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitCreateBranchError(
|
||||
f"Failed to create branch {branch!r} from base {base!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
19
src/pkgmgr/core/git/commands/delete_local_branch.py
Normal file
19
src/pkgmgr/core/git/commands/delete_local_branch.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitDeleteLocalBranchError(GitCommandError):
|
||||
"""Raised when deleting a local branch fails."""
|
||||
|
||||
|
||||
def delete_local_branch(branch: str, cwd: str = ".", force: bool = False) -> None:
|
||||
flag = "-D" if force else "-d"
|
||||
try:
|
||||
run(["branch", flag, branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitDeleteLocalBranchError(
|
||||
f"Failed to delete local branch {branch!r} (flag {flag}).",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/commands/delete_remote_branch.py
Normal file
18
src/pkgmgr/core/git/commands/delete_remote_branch.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitDeleteRemoteBranchError(GitCommandError):
|
||||
"""Raised when deleting a remote branch fails."""
|
||||
|
||||
|
||||
def delete_remote_branch(remote: str, branch: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["push", remote, "--delete", branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitDeleteRemoteBranchError(
|
||||
f"Failed to delete remote branch {branch!r} on {remote!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/commands/fetch.py
Normal file
18
src/pkgmgr/core/git/commands/fetch.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitFetchError(GitCommandError):
|
||||
"""Raised when fetching from a remote fails."""
|
||||
|
||||
|
||||
def fetch(remote: str = "origin", cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["fetch", remote], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitFetchError(
|
||||
f"Failed to fetch from remote {remote!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/commands/merge_no_ff.py
Normal file
18
src/pkgmgr/core/git/commands/merge_no_ff.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitMergeError(GitCommandError):
|
||||
"""Raised when merging a branch fails."""
|
||||
|
||||
|
||||
def merge_no_ff(branch: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["merge", "--no-ff", branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitMergeError(
|
||||
f"Failed to merge branch {branch!r} with --no-ff.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/commands/pull.py
Normal file
18
src/pkgmgr/core/git/commands/pull.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitPullError(GitCommandError):
|
||||
"""Raised when pulling from a remote branch fails."""
|
||||
|
||||
|
||||
def pull(remote: str, branch: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["pull", remote, branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitPullError(
|
||||
f"Failed to pull {remote!r}/{branch!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/commands/push.py
Normal file
18
src/pkgmgr/core/git/commands/push.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitPushError(GitCommandError):
|
||||
"""Raised when pushing to a remote fails."""
|
||||
|
||||
|
||||
def push(remote: str, ref: str, cwd: str = ".") -> None:
|
||||
try:
|
||||
run(["push", remote, ref], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitPushError(
|
||||
f"Failed to push ref {ref!r} to remote {remote!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
23
src/pkgmgr/core/git/commands/push_upstream.py
Normal file
23
src/pkgmgr/core/git/commands/push_upstream.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitPushUpstreamError(GitCommandError):
|
||||
"""Raised when pushing a branch with upstream tracking fails."""
|
||||
|
||||
|
||||
def push_upstream(remote: str, branch: str, cwd: str = ".") -> None:
|
||||
"""
|
||||
Push a branch and set upstream tracking.
|
||||
|
||||
Equivalent to: git push -u <remote> <branch>
|
||||
"""
|
||||
try:
|
||||
run(["push", "-u", remote, branch], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitPushUpstreamError(
|
||||
f"Failed to push branch {branch!r} to {remote!r} with upstream tracking.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
43
src/pkgmgr/core/git/commands/set_remote_url.py
Normal file
43
src/pkgmgr/core/git/commands/set_remote_url.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError, GitCommandError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitSetRemoteUrlError(GitCommandError):
|
||||
"""Raised when setting a remote URL fails."""
|
||||
|
||||
|
||||
def set_remote_url(
|
||||
remote: str,
|
||||
url: str,
|
||||
*,
|
||||
cwd: str = ".",
|
||||
push: bool = False,
|
||||
preview: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Set the fetch or push URL of a remote.
|
||||
|
||||
Equivalent to:
|
||||
git remote set-url <remote> <url>
|
||||
or:
|
||||
git remote set-url --push <remote> <url>
|
||||
"""
|
||||
args = ["remote", "set-url"]
|
||||
if push:
|
||||
args.append("--push")
|
||||
args += [remote, url]
|
||||
|
||||
try:
|
||||
run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
preview=preview,
|
||||
)
|
||||
except GitError as exc:
|
||||
mode = "push" if push else "fetch"
|
||||
raise GitSetRemoteUrlError(
|
||||
f"Failed to set {mode} url for remote {remote!r} to {url!r}.",
|
||||
cwd=cwd,
|
||||
) from exc
|
||||
16
src/pkgmgr/core/git/errors.py
Normal file
16
src/pkgmgr/core/git/errors.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
"""Base error raised for Git related failures."""
|
||||
|
||||
|
||||
class GitCommandError(GitError):
|
||||
"""
|
||||
Base class for state-changing git command failures.
|
||||
|
||||
Use subclasses to provide stable error types for callers.
|
||||
"""
|
||||
def __init__(self, message: str, *, cwd: str = ".") -> None:
|
||||
super().__init__(message)
|
||||
self.cwd = cwd
|
||||
26
src/pkgmgr/core/git/queries/__init__.py
Normal file
26
src/pkgmgr/core/git/queries/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .get_current_branch import get_current_branch
|
||||
from .get_head_commit import get_head_commit
|
||||
from .get_tags import get_tags
|
||||
from .resolve_base_branch import GitBaseBranchNotFoundError, resolve_base_branch
|
||||
from .list_remotes import list_remotes
|
||||
from .get_remote_push_urls import get_remote_push_urls
|
||||
from .probe_remote_reachable import probe_remote_reachable
|
||||
from .get_changelog import get_changelog, GitChangelogQueryError
|
||||
from .get_tags_at_ref import get_tags_at_ref, GitTagsAtRefQueryError
|
||||
|
||||
__all__ = [
|
||||
"get_current_branch",
|
||||
"get_head_commit",
|
||||
"get_tags",
|
||||
"resolve_base_branch",
|
||||
"GitBaseBranchNotFoundError",
|
||||
"list_remotes",
|
||||
"get_remote_push_urls",
|
||||
"probe_remote_reachable",
|
||||
"get_changelog",
|
||||
"GitChangelogQueryError",
|
||||
"get_tags_at_ref",
|
||||
"GitTagsAtRefQueryError",
|
||||
]
|
||||
44
src/pkgmgr/core/git/queries/get_changelog.py
Normal file
44
src/pkgmgr/core/git/queries/get_changelog.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitChangelogQueryError(GitError):
|
||||
"""Raised when querying the git changelog fails."""
|
||||
|
||||
|
||||
def get_changelog(
|
||||
*,
|
||||
cwd: str,
|
||||
from_ref: Optional[str] = None,
|
||||
to_ref: Optional[str] = None,
|
||||
include_merges: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Return a plain-text changelog between two Git refs.
|
||||
|
||||
Uses:
|
||||
git log --pretty=format:%h %d %s [--no-merges] <range>
|
||||
|
||||
Raises:
|
||||
GitChangelogQueryError on failure.
|
||||
"""
|
||||
if to_ref is None:
|
||||
to_ref = "HEAD"
|
||||
|
||||
rev_range = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
||||
|
||||
cmd = ["log", "--pretty=format:%h %d %s"]
|
||||
if not include_merges:
|
||||
cmd.append("--no-merges")
|
||||
cmd.append(rev_range)
|
||||
|
||||
try:
|
||||
return run(cmd, cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitChangelogQueryError(
|
||||
f"Failed to query changelog for range {rev_range!r}.",
|
||||
) from exc
|
||||
18
src/pkgmgr/core/git/queries/get_current_branch.py
Normal file
18
src/pkgmgr/core/git/queries/get_current_branch.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
def get_current_branch(cwd: str = ".") -> Optional[str]:
|
||||
"""
|
||||
Return the current branch name, or None if it cannot be determined.
|
||||
|
||||
Note: In detached HEAD state this will return 'HEAD'.
|
||||
"""
|
||||
try:
|
||||
output = run(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
||||
except GitError:
|
||||
return None
|
||||
return output or None
|
||||
17
src/pkgmgr/core/git/queries/get_head_commit.py
Normal file
17
src/pkgmgr/core/git/queries/get_head_commit.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
def get_head_commit(cwd: str = ".") -> Optional[str]:
|
||||
"""
|
||||
Return the current HEAD commit hash, or None if it cannot be determined.
|
||||
"""
|
||||
try:
|
||||
output = run(["rev-parse", "HEAD"], cwd=cwd)
|
||||
except GitError:
|
||||
return None
|
||||
return output or None
|
||||
21
src/pkgmgr/core/git/queries/get_remote_push_urls.py
Normal file
21
src/pkgmgr/core/git/queries/get_remote_push_urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Set
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
def get_remote_push_urls(remote: str, cwd: str = ".") -> Set[str]:
|
||||
"""
|
||||
Return all push URLs configured for a remote.
|
||||
|
||||
Equivalent to:
|
||||
git remote get-url --push --all <remote>
|
||||
|
||||
Raises GitError if the command fails.
|
||||
"""
|
||||
output = run(["remote", "get-url", "--push", "--all", remote], cwd=cwd)
|
||||
if not output:
|
||||
return set()
|
||||
return {line.strip() for line in output.splitlines() if line.strip()}
|
||||
27
src/pkgmgr/core/git/queries/get_tags.py
Normal file
27
src/pkgmgr/core/git/queries/get_tags.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
def get_tags(cwd: str = ".") -> List[str]:
|
||||
"""
|
||||
Return a list of all tags in the repository in `cwd`.
|
||||
|
||||
If there are no tags, an empty list is returned.
|
||||
"""
|
||||
try:
|
||||
output = run(["tag"], cwd=cwd)
|
||||
except GitError as exc:
|
||||
# If the repo is not a git repo, surface a clear error.
|
||||
if "not a git repository" in str(exc):
|
||||
raise
|
||||
# Otherwise, treat as "no tags" (e.g., empty stdout).
|
||||
return []
|
||||
|
||||
if not output:
|
||||
return []
|
||||
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
30
src/pkgmgr/core/git/queries/get_tags_at_ref.py
Normal file
30
src/pkgmgr/core/git/queries/get_tags_at_ref.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitTagsAtRefQueryError(GitError):
|
||||
"""Raised when querying tags for a ref fails."""
|
||||
|
||||
|
||||
def get_tags_at_ref(ref: str, *, cwd: str = ".") -> List[str]:
|
||||
"""
|
||||
Return all git tags pointing at a given ref.
|
||||
|
||||
Equivalent to:
|
||||
git tag --points-at <ref>
|
||||
"""
|
||||
try:
|
||||
output = run(["tag", "--points-at", ref], cwd=cwd)
|
||||
except GitError as exc:
|
||||
raise GitTagsAtRefQueryError(
|
||||
f"Failed to query tags at ref {ref!r}.",
|
||||
) from exc
|
||||
|
||||
if not output:
|
||||
return []
|
||||
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
18
src/pkgmgr/core/git/queries/list_remotes.py
Normal file
18
src/pkgmgr/core/git/queries/list_remotes.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
def list_remotes(cwd: str = ".") -> List[str]:
|
||||
"""
|
||||
Return a list of configured git remotes (e.g. ['origin', 'upstream']).
|
||||
|
||||
Raises GitError if the command fails.
|
||||
"""
|
||||
output = run(["remote"], cwd=cwd)
|
||||
if not output:
|
||||
return []
|
||||
return [line.strip() for line in output.splitlines() if line.strip()]
|
||||
21
src/pkgmgr/core/git/queries/probe_remote_reachable.py
Normal file
21
src/pkgmgr/core/git/queries/probe_remote_reachable.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
def probe_remote_reachable(url: str, cwd: str = ".") -> bool:
|
||||
"""
|
||||
Check whether a remote URL is reachable.
|
||||
|
||||
Equivalent to:
|
||||
git ls-remote --exit-code <url>
|
||||
|
||||
Returns:
|
||||
True if reachable, False otherwise.
|
||||
"""
|
||||
try:
|
||||
run(["ls-remote", "--exit-code", url], cwd=cwd)
|
||||
return True
|
||||
except GitError:
|
||||
return False
|
||||
66
src/pkgmgr/core/git/queries/resolve_base_branch.py
Normal file
66
src/pkgmgr/core/git/queries/resolve_base_branch.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# src/pkgmgr/core/git/queries/resolve_base_branch.py
|
||||
from __future__ import annotations
|
||||
|
||||
from ..errors import GitError
|
||||
from ..run import run
|
||||
|
||||
|
||||
class GitBaseBranchNotFoundError(GitError):
|
||||
"""Raised when neither preferred nor fallback base branch exists."""
|
||||
|
||||
|
||||
def _is_branch_missing_error(exc: GitError) -> bool:
|
||||
"""
|
||||
Heuristic: Detect errors that indicate the branch/ref does not exist.
|
||||
|
||||
We intentionally *do not* swallow other errors like:
|
||||
- not a git repository
|
||||
- permission issues
|
||||
- corrupted repository
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
|
||||
# Common git messages when verifying a non-existing ref/branch.
|
||||
patterns = [
|
||||
"needed a single revision",
|
||||
"unknown revision or path not in the working tree",
|
||||
"not a valid object name",
|
||||
"ambiguous argument",
|
||||
"bad revision",
|
||||
"fatal: invalid object name",
|
||||
"fatal: ambiguous argument",
|
||||
]
|
||||
|
||||
return any(p in msg for p in patterns)
|
||||
|
||||
|
||||
def resolve_base_branch(
|
||||
preferred: str = "main",
|
||||
fallback: str = "master",
|
||||
cwd: str = ".",
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the base branch to use.
|
||||
|
||||
Try `preferred` first (default: main),
|
||||
fall back to `fallback` (default: master).
|
||||
|
||||
Raises GitBaseBranchNotFoundError if neither exists.
|
||||
Raises GitError for other git failures (e.g., not a git repository).
|
||||
"""
|
||||
last_missing_error: GitError | None = None
|
||||
|
||||
for candidate in (preferred, fallback):
|
||||
try:
|
||||
run(["rev-parse", "--verify", candidate], cwd=cwd)
|
||||
return candidate
|
||||
except GitError as exc:
|
||||
if _is_branch_missing_error(exc):
|
||||
last_missing_error = exc
|
||||
continue
|
||||
raise # anything else is a real problem -> bubble up
|
||||
|
||||
# Both candidates missing -> raise specific error
|
||||
raise GitBaseBranchNotFoundError(
|
||||
f"Neither {preferred!r} nor {fallback!r} exist in this repository."
|
||||
) from last_missing_error
|
||||
46
src/pkgmgr/core/git/run.py
Normal file
46
src/pkgmgr/core/git/run.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
from .errors import GitError
|
||||
|
||||
|
||||
def run(
|
||||
args: List[str],
|
||||
*,
|
||||
cwd: str = ".",
|
||||
preview: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Run a Git command and return its stdout as a stripped string.
|
||||
|
||||
If preview=True, the command is printed but NOT executed.
|
||||
|
||||
Raises GitError if execution fails.
|
||||
"""
|
||||
cmd = ["git"] + args
|
||||
cmd_str = " ".join(cmd)
|
||||
|
||||
if preview:
|
||||
print(f"[PREVIEW] Would run in {cwd!r}: {cmd_str}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise GitError(
|
||||
f"Git command failed in {cwd!r}: {cmd_str}\n"
|
||||
f"Exit code: {exc.returncode}\n"
|
||||
f"STDOUT:\n{exc.stdout}\n"
|
||||
f"STDERR:\n{exc.stderr}"
|
||||
) from exc
|
||||
|
||||
return result.stdout.strip()
|
||||
@@ -27,18 +27,10 @@ from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
|
||||
class TestIntegrationMirrorCommands(unittest.TestCase):
|
||||
"""
|
||||
Integration tests for `pkgmgr mirror` commands.
|
||||
"""
|
||||
"""Integration tests for `pkgmgr mirror` commands."""
|
||||
|
||||
def _run_pkgmgr(self, args: List[str], extra_env: Optional[Dict[str, str]] = None) -> str:
|
||||
"""
|
||||
Execute pkgmgr with the given arguments and return captured output.
|
||||
|
||||
- Treat SystemExit(0) or SystemExit(None) as success.
|
||||
- Any other exit code is considered a test failure.
|
||||
- Mirror commands are patched to avoid network/destructive operations.
|
||||
"""
|
||||
"""Execute pkgmgr with the given arguments and return captured output."""
|
||||
original_argv = list(sys.argv)
|
||||
original_env = dict(os.environ)
|
||||
buffer = io.StringIO()
|
||||
@@ -64,8 +56,7 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
|
||||
try:
|
||||
importlib.import_module(module_name)
|
||||
except ModuleNotFoundError:
|
||||
# If the module truly doesn't exist, create=True may still allow patching
|
||||
# in some cases, but dotted resolution can still fail. Best-effort.
|
||||
# Best-effort: allow patch(create=True) even if a symbol moved.
|
||||
pass
|
||||
return patch(target, create=True, **kwargs)
|
||||
|
||||
@@ -95,10 +86,9 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.build_context", return_value=dummy_ctx))
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.remote_provision.build_context", return_value=dummy_ctx))
|
||||
|
||||
# Deterministic remote probing (covers setup + likely check implementations)
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.remote_check.probe_mirror", return_value=(True, "")))
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.probe_mirror", return_value=(True, "")))
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.git_remote.is_remote_reachable", return_value=True))
|
||||
# Deterministic remote probing (new refactor: probe_remote_reachable)
|
||||
stack.enter_context(_p("pkgmgr.core.git.queries.probe_remote_reachable", return_value=True))
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable", return_value=True))
|
||||
|
||||
# setup_cmd imports ensure_origin_remote directly:
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote", return_value=None))
|
||||
@@ -113,9 +103,6 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
# Extra safety: if anything calls remote_check.run_git directly, make it inert
|
||||
stack.enter_context(_p("pkgmgr.actions.mirror.remote_check.run_git", return_value="dummy"))
|
||||
|
||||
with redirect_stdout(buffer), redirect_stderr(buffer):
|
||||
try:
|
||||
runpy.run_module("pkgmgr", run_name="__main__")
|
||||
@@ -134,10 +121,6 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
|
||||
os.environ.clear()
|
||||
os.environ.update(original_env)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Tests
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def test_mirror_help(self) -> None:
|
||||
output = self._run_pkgmgr(["mirror", "--help"])
|
||||
self.assertIn("usage:", output.lower())
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.branch.utils import _resolve_base_branch
|
||||
from pkgmgr.core.git import GitError
|
||||
|
||||
|
||||
class TestResolveBaseBranch(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.branch.utils.run_git")
|
||||
def test_resolves_preferred(self, run_git):
|
||||
run_git.return_value = None
|
||||
result = _resolve_base_branch("main", "master", cwd=".")
|
||||
self.assertEqual(result, "main")
|
||||
run_git.assert_called_with(["rev-parse", "--verify", "main"], cwd=".")
|
||||
|
||||
@patch("pkgmgr.actions.branch.utils.run_git")
|
||||
def test_resolves_fallback(self, run_git):
|
||||
run_git.side_effect = [
|
||||
GitError("main missing"),
|
||||
None,
|
||||
]
|
||||
result = _resolve_base_branch("main", "master", cwd=".")
|
||||
self.assertEqual(result, "master")
|
||||
|
||||
@patch("pkgmgr.actions.branch.utils.run_git")
|
||||
def test_raises_when_no_branch_exists(self, run_git):
|
||||
run_git.side_effect = GitError("missing")
|
||||
with self.assertRaises(RuntimeError):
|
||||
_resolve_base_branch("main", "master", cwd=".")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,54 +2,129 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.branch.close_branch import close_branch
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.core.git.errors import GitError
|
||||
from pkgmgr.core.git.commands import GitDeleteRemoteBranchError
|
||||
|
||||
|
||||
class TestCloseBranch(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.branch.close_branch.input", return_value="y")
|
||||
@patch("builtins.input", return_value="y")
|
||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.run_git")
|
||||
def test_close_branch_happy_path(self, run_git, resolve, current, input_mock):
|
||||
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||
@patch("pkgmgr.actions.branch.close_branch.checkout")
|
||||
@patch("pkgmgr.actions.branch.close_branch.pull")
|
||||
@patch("pkgmgr.actions.branch.close_branch.merge_no_ff")
|
||||
@patch("pkgmgr.actions.branch.close_branch.push")
|
||||
@patch("pkgmgr.actions.branch.close_branch.delete_local_branch")
|
||||
@patch("pkgmgr.actions.branch.close_branch.delete_remote_branch")
|
||||
def test_close_branch_happy_path(
|
||||
self,
|
||||
delete_remote_branch,
|
||||
delete_local_branch,
|
||||
push,
|
||||
merge_no_ff,
|
||||
pull,
|
||||
checkout,
|
||||
fetch,
|
||||
_resolve,
|
||||
_current,
|
||||
_input_mock,
|
||||
) -> None:
|
||||
close_branch(None, cwd=".")
|
||||
expected = [
|
||||
(["fetch", "origin"],),
|
||||
(["checkout", "main"],),
|
||||
(["pull", "origin", "main"],),
|
||||
(["merge", "--no-ff", "feature-x"],),
|
||||
(["push", "origin", "main"],),
|
||||
(["branch", "-d", "feature-x"],),
|
||||
(["push", "origin", "--delete", "feature-x"],),
|
||||
]
|
||||
actual = [call.args for call in run_git.call_args_list]
|
||||
self.assertEqual(actual, expected)
|
||||
fetch.assert_called_once_with("origin", cwd=".")
|
||||
checkout.assert_called_once_with("main", cwd=".")
|
||||
pull.assert_called_once_with("origin", "main", cwd=".")
|
||||
merge_no_ff.assert_called_once_with("feature-x", cwd=".")
|
||||
push.assert_called_once_with("origin", "main", cwd=".")
|
||||
delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False)
|
||||
delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||
|
||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
|
||||
def test_refuses_to_close_base_branch(self, resolve, current):
|
||||
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
|
||||
def test_refuses_to_close_base_branch(self, _resolve, _current) -> None:
|
||||
with self.assertRaises(RuntimeError):
|
||||
close_branch(None)
|
||||
|
||||
@patch("pkgmgr.actions.branch.close_branch.input", return_value="n")
|
||||
@patch("builtins.input", return_value="n")
|
||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.run_git")
|
||||
def test_close_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
|
||||
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||
def test_close_branch_aborts_on_no(self, fetch, _resolve, _current, _input_mock) -> None:
|
||||
close_branch(None, cwd=".")
|
||||
run_git.assert_not_called()
|
||||
fetch.assert_not_called()
|
||||
|
||||
@patch("builtins.input")
|
||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.run_git")
|
||||
def test_close_branch_force_skips_prompt(self, run_git, resolve, current):
|
||||
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||
@patch("pkgmgr.actions.branch.close_branch.checkout")
|
||||
@patch("pkgmgr.actions.branch.close_branch.pull")
|
||||
@patch("pkgmgr.actions.branch.close_branch.merge_no_ff")
|
||||
@patch("pkgmgr.actions.branch.close_branch.push")
|
||||
@patch("pkgmgr.actions.branch.close_branch.delete_local_branch")
|
||||
@patch("pkgmgr.actions.branch.close_branch.delete_remote_branch")
|
||||
def test_close_branch_force_skips_prompt(
|
||||
self,
|
||||
delete_remote_branch,
|
||||
delete_local_branch,
|
||||
push,
|
||||
merge_no_ff,
|
||||
pull,
|
||||
checkout,
|
||||
fetch,
|
||||
_resolve,
|
||||
_current,
|
||||
input_mock,
|
||||
) -> None:
|
||||
close_branch(None, cwd=".", force=True)
|
||||
self.assertGreater(len(run_git.call_args_list), 0)
|
||||
|
||||
# no interactive prompt when forced
|
||||
input_mock.assert_not_called()
|
||||
|
||||
# workflow still runs (but is mocked)
|
||||
fetch.assert_called_once_with("origin", cwd=".")
|
||||
checkout.assert_called_once_with("main", cwd=".")
|
||||
pull.assert_called_once_with("origin", "main", cwd=".")
|
||||
merge_no_ff.assert_called_once_with("feature-x", cwd=".")
|
||||
push.assert_called_once_with("origin", "main", cwd=".")
|
||||
delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False)
|
||||
delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||
|
||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", side_effect=GitError("fail"))
|
||||
def test_close_branch_errors_if_cannot_detect_branch(self, current):
|
||||
def test_close_branch_errors_if_cannot_detect_branch(self, _current) -> None:
|
||||
with self.assertRaises(RuntimeError):
|
||||
close_branch(None)
|
||||
|
||||
@patch("builtins.input", return_value="y")
|
||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||
@patch("pkgmgr.actions.branch.close_branch.checkout")
|
||||
@patch("pkgmgr.actions.branch.close_branch.pull")
|
||||
@patch("pkgmgr.actions.branch.close_branch.merge_no_ff")
|
||||
@patch("pkgmgr.actions.branch.close_branch.push")
|
||||
@patch("pkgmgr.actions.branch.close_branch.delete_local_branch")
|
||||
@patch(
|
||||
"pkgmgr.actions.branch.close_branch.delete_remote_branch",
|
||||
side_effect=GitDeleteRemoteBranchError("boom", cwd="."),
|
||||
)
|
||||
def test_close_branch_remote_delete_failure_is_wrapped(
|
||||
self,
|
||||
_delete_remote_branch,
|
||||
_delete_local_branch,
|
||||
_push,
|
||||
_merge_no_ff,
|
||||
_pull,
|
||||
_checkout,
|
||||
_fetch,
|
||||
_resolve,
|
||||
_current,
|
||||
_input_mock,
|
||||
) -> None:
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
close_branch(None, cwd=".")
|
||||
self.assertIn("remote deletion failed", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,49 +2,79 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.branch.drop_branch import drop_branch
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.core.git.errors import GitError
|
||||
from pkgmgr.core.git.commands import GitDeleteRemoteBranchError
|
||||
|
||||
|
||||
class TestDropBranch(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.branch.drop_branch.input", return_value="y")
|
||||
@patch("builtins.input", return_value="y")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.run_git")
|
||||
def test_drop_branch_happy_path(self, run_git, resolve, current, input_mock):
|
||||
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.delete_remote_branch")
|
||||
def test_drop_branch_happy_path(self, delete_remote, delete_local, _resolve, _current, _input_mock) -> None:
|
||||
drop_branch(None, cwd=".")
|
||||
expected = [
|
||||
(["branch", "-d", "feature-x"],),
|
||||
(["push", "origin", "--delete", "feature-x"],),
|
||||
]
|
||||
actual = [call.args for call in run_git.call_args_list]
|
||||
self.assertEqual(actual, expected)
|
||||
delete_local.assert_called_once_with("feature-x", cwd=".", force=False)
|
||||
delete_remote.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||
|
||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
|
||||
def test_refuses_to_drop_base_branch(self, resolve, current):
|
||||
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
|
||||
def test_refuses_to_drop_base_branch(self, _resolve, _current) -> None:
|
||||
with self.assertRaises(RuntimeError):
|
||||
drop_branch(None)
|
||||
|
||||
@patch("pkgmgr.actions.branch.drop_branch.input", return_value="n")
|
||||
@patch("builtins.input", return_value="n")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.run_git")
|
||||
def test_drop_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
|
||||
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||
def test_drop_branch_aborts_on_no(self, delete_local, _resolve, _current, _input_mock) -> None:
|
||||
drop_branch(None, cwd=".")
|
||||
run_git.assert_not_called()
|
||||
delete_local.assert_not_called()
|
||||
|
||||
@patch("builtins.input")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.run_git")
|
||||
def test_drop_branch_force_skips_prompt(self, run_git, resolve, current):
|
||||
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.delete_remote_branch")
|
||||
def test_drop_branch_force_skips_prompt(
|
||||
self,
|
||||
delete_remote,
|
||||
delete_local,
|
||||
_resolve,
|
||||
_current,
|
||||
input_mock,
|
||||
) -> None:
|
||||
drop_branch(None, cwd=".", force=True)
|
||||
self.assertGreater(len(run_git.call_args_list), 0)
|
||||
|
||||
input_mock.assert_not_called()
|
||||
delete_local.assert_called_once_with("feature-x", cwd=".", force=False)
|
||||
delete_remote.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||
|
||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", side_effect=GitError("fail"))
|
||||
def test_drop_branch_errors_if_no_branch_detected(self, current):
|
||||
def test_drop_branch_errors_if_no_branch_detected(self, _current) -> None:
|
||||
with self.assertRaises(RuntimeError):
|
||||
drop_branch(None)
|
||||
|
||||
@patch("builtins.input", return_value="y")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||
@patch(
|
||||
"pkgmgr.actions.branch.drop_branch.delete_remote_branch",
|
||||
side_effect=GitDeleteRemoteBranchError("boom", cwd="."),
|
||||
)
|
||||
def test_drop_branch_remote_delete_failure_is_wrapped(
|
||||
self,
|
||||
_delete_remote,
|
||||
_delete_local,
|
||||
_resolve,
|
||||
_current,
|
||||
_input_mock,
|
||||
) -> None:
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
drop_branch(None, cwd=".")
|
||||
self.assertIn("remote deletion failed", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,29 +5,55 @@ from pkgmgr.actions.branch.open_branch import open_branch
|
||||
|
||||
|
||||
class TestOpenBranch(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.open_branch.run_git")
|
||||
def test_open_branch_executes_git_commands(self, run_git, resolve):
|
||||
@patch("pkgmgr.actions.branch.open_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.open_branch.fetch")
|
||||
@patch("pkgmgr.actions.branch.open_branch.checkout")
|
||||
@patch("pkgmgr.actions.branch.open_branch.pull")
|
||||
@patch("pkgmgr.actions.branch.open_branch.create_branch")
|
||||
@patch("pkgmgr.actions.branch.open_branch.push_upstream")
|
||||
def test_open_branch_executes_git_commands(
|
||||
self,
|
||||
push_upstream,
|
||||
create_branch,
|
||||
pull,
|
||||
checkout,
|
||||
fetch,
|
||||
_resolve,
|
||||
) -> None:
|
||||
open_branch("feature-x", base_branch="main", cwd=".")
|
||||
expected_calls = [
|
||||
(["fetch", "origin"],),
|
||||
(["checkout", "main"],),
|
||||
(["pull", "origin", "main"],),
|
||||
(["checkout", "-b", "feature-x"],),
|
||||
(["push", "-u", "origin", "feature-x"],),
|
||||
]
|
||||
actual = [call.args for call in run_git.call_args_list]
|
||||
self.assertEqual(actual, expected_calls)
|
||||
|
||||
fetch.assert_called_once_with("origin", cwd=".")
|
||||
checkout.assert_called_once_with("main", cwd=".")
|
||||
pull.assert_called_once_with("origin", "main", cwd=".")
|
||||
create_branch.assert_called_once_with("feature-x", "main", cwd=".")
|
||||
push_upstream.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||
|
||||
@patch("builtins.input", return_value="auto-branch")
|
||||
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.open_branch.run_git")
|
||||
def test_open_branch_prompts_for_name(self, run_git, resolve, input_mock):
|
||||
@patch("pkgmgr.actions.branch.open_branch.resolve_base_branch", return_value="main")
|
||||
@patch("pkgmgr.actions.branch.open_branch.fetch")
|
||||
@patch("pkgmgr.actions.branch.open_branch.checkout")
|
||||
@patch("pkgmgr.actions.branch.open_branch.pull")
|
||||
@patch("pkgmgr.actions.branch.open_branch.create_branch")
|
||||
@patch("pkgmgr.actions.branch.open_branch.push_upstream")
|
||||
def test_open_branch_prompts_for_name(
|
||||
self,
|
||||
push_upstream,
|
||||
create_branch,
|
||||
pull,
|
||||
checkout,
|
||||
fetch,
|
||||
_resolve,
|
||||
_input_mock,
|
||||
) -> None:
|
||||
open_branch(None)
|
||||
calls = [call.args for call in run_git.call_args_list]
|
||||
self.assertEqual(calls[3][0][0], "checkout") # verify git executed normally
|
||||
|
||||
def test_open_branch_rejects_empty_name(self):
|
||||
fetch.assert_called_once_with("origin", cwd=".")
|
||||
checkout.assert_called_once_with("main", cwd=".")
|
||||
pull.assert_called_once_with("origin", "main", cwd=".")
|
||||
create_branch.assert_called_once_with("auto-branch", "main", cwd=".")
|
||||
push_upstream.assert_called_once_with("origin", "auto-branch", cwd=".")
|
||||
|
||||
def test_open_branch_rejects_empty_name(self) -> None:
|
||||
with patch("builtins.input", return_value=""):
|
||||
with self.assertRaises(RuntimeError):
|
||||
open_branch(None)
|
||||
|
||||
@@ -21,46 +21,34 @@ class TestMirrorGitRemote(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_build_default_ssh_url(self) -> None:
|
||||
repo = {
|
||||
"provider": "github.com",
|
||||
"account": "alice",
|
||||
"repository": "repo",
|
||||
}
|
||||
self.assertEqual(
|
||||
build_default_ssh_url(repo),
|
||||
"git@github.com:alice/repo.git",
|
||||
)
|
||||
|
||||
def test_determine_primary_prefers_origin(self) -> None:
|
||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||
ctx = self._ctx(config={"origin": "git@github.com:alice/repo.git"})
|
||||
self.assertEqual(
|
||||
determine_primary_remote_url(repo, ctx),
|
||||
"git@github.com:alice/repo.git",
|
||||
)
|
||||
self.assertEqual(build_default_ssh_url(repo), "git@github.com:alice/repo.git")
|
||||
|
||||
def test_determine_primary_uses_file_order(self) -> None:
|
||||
def test_determine_primary_prefers_origin_in_resolved(self) -> None:
|
||||
# resolved_mirrors = config + file (file wins), so put origin in file.
|
||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||
ctx = self._ctx(
|
||||
file={
|
||||
"first": "git@a/first.git",
|
||||
"second": "git@a/second.git",
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
determine_primary_remote_url(repo, ctx),
|
||||
"git@a/first.git",
|
||||
)
|
||||
ctx = self._ctx(file={"origin": "git@github.com:alice/repo.git"})
|
||||
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@github.com:alice/repo.git")
|
||||
|
||||
def test_determine_primary_falls_back_to_file_order(self) -> None:
|
||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||
ctx = self._ctx(file={"first": "git@a/first.git", "second": "git@a/second.git"})
|
||||
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@a/first.git")
|
||||
|
||||
def test_determine_primary_falls_back_to_config_order(self) -> None:
|
||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||
ctx = self._ctx(config={"cfg1": "git@c/one.git", "cfg2": "git@c/two.git"})
|
||||
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@c/one.git")
|
||||
|
||||
def test_determine_primary_fallback_default(self) -> None:
|
||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||
ctx = self._ctx()
|
||||
self.assertEqual(
|
||||
determine_primary_remote_url(repo, ctx),
|
||||
"git@github.com:alice/repo.git",
|
||||
)
|
||||
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@github.com:alice/repo.git")
|
||||
|
||||
@patch("pkgmgr.actions.mirror.git_remote._safe_git_output")
|
||||
def test_has_origin_remote(self, m_out) -> None:
|
||||
m_out.return_value = "origin\nupstream\n"
|
||||
@patch("pkgmgr.actions.mirror.git_remote.list_remotes", return_value=["origin", "backup"])
|
||||
def test_has_origin_remote_true(self, _m_list) -> None:
|
||||
self.assertTrue(has_origin_remote("/tmp/repo"))
|
||||
|
||||
@patch("pkgmgr.actions.mirror.git_remote.list_remotes", return_value=["backup"])
|
||||
def test_has_origin_remote_false(self, _m_list) -> None:
|
||||
self.assertFalse(has_origin_remote("/tmp/repo"))
|
||||
|
||||
@@ -10,6 +10,8 @@ from pkgmgr.actions.mirror.types import RepoMirrorContext
|
||||
class TestGitRemotePrimaryPush(unittest.TestCase):
|
||||
def test_origin_created_and_extra_push_added(self) -> None:
|
||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||
|
||||
# Use file_mirrors so ctx.resolved_mirrors contains both, no setattr (frozen dataclass!)
|
||||
ctx = RepoMirrorContext(
|
||||
identifier="repo",
|
||||
repo_dir="/tmp/repo",
|
||||
@@ -20,31 +22,44 @@ class TestGitRemotePrimaryPush(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
executed: list[str] = []
|
||||
with patch("os.path.isdir", return_value=True):
|
||||
with patch("pkgmgr.actions.mirror.git_remote.has_origin_remote", return_value=False), patch(
|
||||
"pkgmgr.actions.mirror.git_remote.add_remote"
|
||||
) as m_add_remote, patch(
|
||||
"pkgmgr.actions.mirror.git_remote.set_remote_url"
|
||||
) as m_set_remote_url, patch(
|
||||
"pkgmgr.actions.mirror.git_remote.get_remote_push_urls", return_value=set()
|
||||
), patch(
|
||||
"pkgmgr.actions.mirror.git_remote.add_remote_push_url"
|
||||
) as m_add_push:
|
||||
ensure_origin_remote(repo, ctx, preview=False)
|
||||
|
||||
def fake_run(cmd: str, cwd: str, preview: bool) -> None:
|
||||
executed.append(cmd)
|
||||
|
||||
def fake_git(args, cwd):
|
||||
if args == ["remote"]:
|
||||
return ""
|
||||
if args == ["remote", "get-url", "--push", "--all", "origin"]:
|
||||
return "git@github.com:alice/repo.git\n"
|
||||
return ""
|
||||
|
||||
with patch("os.path.isdir", return_value=True), patch(
|
||||
"pkgmgr.actions.mirror.git_remote.run_command", side_effect=fake_run
|
||||
), patch(
|
||||
"pkgmgr.actions.mirror.git_remote._safe_git_output", side_effect=fake_git
|
||||
):
|
||||
ensure_origin_remote(repo, ctx, preview=False)
|
||||
|
||||
self.assertEqual(
|
||||
executed,
|
||||
[
|
||||
"git remote add origin git@github.com:alice/repo.git",
|
||||
"git remote set-url origin git@github.com:alice/repo.git",
|
||||
"git remote set-url --push origin git@github.com:alice/repo.git",
|
||||
"git remote set-url --add --push origin git@github.com:alice/repo-backup.git",
|
||||
],
|
||||
# determine_primary_remote_url falls back to file order (primary first)
|
||||
m_add_remote.assert_called_once_with(
|
||||
"origin",
|
||||
"git@github.com:alice/repo.git",
|
||||
cwd="/tmp/repo",
|
||||
preview=False,
|
||||
)
|
||||
|
||||
m_set_remote_url.assert_any_call(
|
||||
"origin",
|
||||
"git@github.com:alice/repo.git",
|
||||
cwd="/tmp/repo",
|
||||
push=False,
|
||||
preview=False,
|
||||
)
|
||||
m_set_remote_url.assert_any_call(
|
||||
"origin",
|
||||
"git@github.com:alice/repo.git",
|
||||
cwd="/tmp/repo",
|
||||
push=True,
|
||||
preview=False,
|
||||
)
|
||||
|
||||
m_add_push.assert_called_once_with(
|
||||
"origin",
|
||||
"git@github.com:alice/repo-backup.git",
|
||||
cwd="/tmp/repo",
|
||||
preview=False,
|
||||
)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.mirror.remote_check import probe_mirror
|
||||
from pkgmgr.core.git import GitError
|
||||
|
||||
|
||||
class TestRemoteCheck(unittest.TestCase):
|
||||
"""
|
||||
Unit tests for non-destructive remote probing (git ls-remote).
|
||||
"""
|
||||
|
||||
@patch("pkgmgr.actions.mirror.remote_check.run_git")
|
||||
def test_probe_mirror_success_returns_true_and_empty_message(self, mock_run_git) -> None:
|
||||
mock_run_git.return_value = "dummy-output"
|
||||
|
||||
ok, message = probe_mirror(
|
||||
"ssh://git@code.example.org:2201/alice/repo.git",
|
||||
"/tmp/some-repo",
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(message, "")
|
||||
mock_run_git.assert_called_once_with(
|
||||
["ls-remote", "ssh://git@code.example.org:2201/alice/repo.git"],
|
||||
cwd="/tmp/some-repo",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.actions.mirror.remote_check.run_git")
|
||||
def test_probe_mirror_failure_returns_false_and_error_message(self, mock_run_git) -> None:
|
||||
mock_run_git.side_effect = GitError("Git command failed (simulated)")
|
||||
|
||||
ok, message = probe_mirror(
|
||||
"ssh://git@code.example.org:2201/alice/repo.git",
|
||||
"/tmp/some-repo",
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Git command failed", message)
|
||||
mock_run_git.assert_called_once_with(
|
||||
["ls-remote", "ssh://git@code.example.org:2201/alice/repo.git"],
|
||||
cwd="/tmp/some-repo",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,16 +8,10 @@ from pkgmgr.actions.mirror.types import RepoMirrorContext
|
||||
|
||||
|
||||
class TestMirrorSetupCmd(unittest.TestCase):
|
||||
def _ctx(
|
||||
self,
|
||||
*,
|
||||
repo_dir: str = "/tmp/repo",
|
||||
resolved: dict[str, str] | None = None,
|
||||
) -> RepoMirrorContext:
|
||||
# RepoMirrorContext derives resolved via property (config + file)
|
||||
# We feed mirrors via file_mirrors to keep insertion order realistic.
|
||||
def _ctx(self, *, repo_dir: str = "/tmp/repo", resolved: dict[str, str] | None = None) -> RepoMirrorContext:
|
||||
# resolved_mirrors is a @property combining config+file. Put it into file_mirrors.
|
||||
return RepoMirrorContext(
|
||||
identifier="repo-id",
|
||||
identifier="repo",
|
||||
repo_dir=repo_dir,
|
||||
config_mirrors={},
|
||||
file_mirrors=resolved or {},
|
||||
@@ -26,7 +20,8 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote")
|
||||
def test_setup_mirrors_local_calls_ensure_origin_remote(self, m_ensure, m_ctx) -> None:
|
||||
m_ctx.return_value = self._ctx(repo_dir="/tmp/repo", resolved={"primary": "git@x/y.git"})
|
||||
ctx = self._ctx(repo_dir="/tmp/repo", resolved={"primary": "git@x/y.git"})
|
||||
m_ctx.return_value = ctx
|
||||
|
||||
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
|
||||
setup_mirrors(
|
||||
@@ -39,24 +34,21 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
||||
ensure_remote=False,
|
||||
)
|
||||
|
||||
self.assertEqual(m_ensure.call_count, 1)
|
||||
# ensure_origin_remote(repo, ctx, preview) is called positionally in your code
|
||||
m_ensure.assert_called_once()
|
||||
args, kwargs = m_ensure.call_args
|
||||
|
||||
# ensure_origin_remote(repo, ctx, preview) may be positional or kw.
|
||||
# Accept both to avoid coupling tests to call style.
|
||||
if "preview" in kwargs:
|
||||
self.assertTrue(kwargs["preview"])
|
||||
else:
|
||||
# args: (repo, ctx, preview)
|
||||
self.assertTrue(args[2])
|
||||
self.assertEqual(args[0], repos[0])
|
||||
self.assertIs(args[1], ctx)
|
||||
self.assertEqual(kwargs.get("preview", args[2] if len(args) >= 3 else None), True)
|
||||
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.probe_mirror")
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.determine_primary_remote_url")
|
||||
def test_setup_mirrors_remote_no_mirrors_probes_primary(self, m_primary, m_probe, m_ctx) -> None:
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable")
|
||||
def test_setup_mirrors_remote_no_mirrors_probes_primary(self, m_probe, m_primary, m_ctx) -> None:
|
||||
m_ctx.return_value = self._ctx(repo_dir="/tmp/repo", resolved={})
|
||||
m_primary.return_value = "git@github.com:alice/repo.git"
|
||||
m_probe.return_value = (True, "")
|
||||
m_probe.return_value = True
|
||||
|
||||
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
|
||||
setup_mirrors(
|
||||
@@ -70,10 +62,10 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
||||
)
|
||||
|
||||
m_primary.assert_called()
|
||||
m_probe.assert_called_with("git@github.com:alice/repo.git", "/tmp/repo")
|
||||
m_probe.assert_called_once_with("git@github.com:alice/repo.git", cwd="/tmp/repo")
|
||||
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.probe_mirror")
|
||||
@patch("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable")
|
||||
def test_setup_mirrors_remote_with_mirrors_probes_each(self, m_probe, m_ctx) -> None:
|
||||
m_ctx.return_value = self._ctx(
|
||||
repo_dir="/tmp/repo",
|
||||
@@ -82,7 +74,7 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
||||
"backup": "ssh://git@git.veen.world:2201/alice/repo.git",
|
||||
},
|
||||
)
|
||||
m_probe.return_value = (True, "")
|
||||
m_probe.return_value = True
|
||||
|
||||
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
|
||||
setup_mirrors(
|
||||
@@ -96,6 +88,8 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(m_probe.call_count, 2)
|
||||
m_probe.assert_any_call("git@github.com:alice/repo.git", cwd="/tmp/repo")
|
||||
m_probe.assert_any_call("ssh://git@git.veen.world:2201/alice/repo.git", cwd="/tmp/repo")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -6,15 +5,10 @@ from pkgmgr.actions.publish.git_tags import head_semver_tags
|
||||
|
||||
|
||||
class TestHeadSemverTags(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.publish.git_tags.run_git")
|
||||
def test_no_tags(self, mock_run_git):
|
||||
mock_run_git.return_value = ""
|
||||
@patch("pkgmgr.actions.publish.git_tags.get_tags_at_ref", return_value=[])
|
||||
def test_no_tags(self, _mock_get_tags_at_ref) -> None:
|
||||
self.assertEqual(head_semver_tags(), [])
|
||||
|
||||
@patch("pkgmgr.actions.publish.git_tags.run_git")
|
||||
def test_filters_and_sorts_semver(self, mock_run_git):
|
||||
mock_run_git.return_value = "v1.0.0\nv2.0.0\nfoo\n"
|
||||
self.assertEqual(
|
||||
head_semver_tags(),
|
||||
["v1.0.0", "v2.0.0"],
|
||||
)
|
||||
@patch("pkgmgr.actions.publish.git_tags.get_tags_at_ref", return_value=["v2.0.0", "nope", "v1.0.0", "v1.2.0"])
|
||||
def test_filters_and_sorts_semver(self, _mock_get_tags_at_ref) -> None:
|
||||
self.assertEqual(head_semver_tags(), ["v1.0.0", "v1.2.0", "v2.0.0"])
|
||||
|
||||
@@ -4,44 +4,28 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.changelog import generate_changelog
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.core.git.queries import GitChangelogQueryError
|
||||
from pkgmgr.cli.commands.changelog import _find_previous_and_current_tag
|
||||
|
||||
|
||||
class TestGenerateChangelog(unittest.TestCase):
|
||||
@patch("pkgmgr.actions.changelog.run_git")
|
||||
def test_generate_changelog_default_range_no_merges(self, mock_run_git) -> None:
|
||||
"""
|
||||
Default behaviour:
|
||||
- to_ref = HEAD
|
||||
- from_ref = None
|
||||
- include_merges = False -> adds --no-merges
|
||||
"""
|
||||
mock_run_git.return_value = "abc123 (HEAD -> main) Initial commit"
|
||||
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||
def test_generate_changelog_default_range_no_merges(self, mock_get_changelog) -> None:
|
||||
mock_get_changelog.return_value = "abc123 (HEAD -> main) Initial commit"
|
||||
|
||||
output = generate_changelog(cwd="/repo")
|
||||
|
||||
self.assertEqual(
|
||||
output,
|
||||
"abc123 (HEAD -> main) Initial commit",
|
||||
self.assertEqual(output, "abc123 (HEAD -> main) Initial commit")
|
||||
mock_get_changelog.assert_called_once_with(
|
||||
cwd="/repo",
|
||||
from_ref=None,
|
||||
to_ref="HEAD",
|
||||
include_merges=False,
|
||||
)
|
||||
mock_run_git.assert_called_once()
|
||||
args, kwargs = mock_run_git.call_args
|
||||
|
||||
# Command must start with git log and include our pretty format.
|
||||
self.assertEqual(args[0][0], "log")
|
||||
self.assertIn("--pretty=format:%h %d %s", args[0])
|
||||
self.assertIn("--no-merges", args[0])
|
||||
self.assertIn("HEAD", args[0])
|
||||
self.assertEqual(kwargs.get("cwd"), "/repo")
|
||||
|
||||
@patch("pkgmgr.actions.changelog.run_git")
|
||||
def test_generate_changelog_with_range_and_merges(self, mock_run_git) -> None:
|
||||
"""
|
||||
Explicit range and include_merges=True:
|
||||
- from_ref/to_ref are combined into from..to
|
||||
- no --no-merges flag
|
||||
"""
|
||||
mock_run_git.return_value = "def456 (tag: v1.1.0) Some change"
|
||||
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||
def test_generate_changelog_with_range_and_merges(self, mock_get_changelog) -> None:
|
||||
mock_get_changelog.return_value = "def456 (tag: v1.1.0) Some change"
|
||||
|
||||
output = generate_changelog(
|
||||
cwd="/repo",
|
||||
@@ -51,24 +35,16 @@ class TestGenerateChangelog(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(output, "def456 (tag: v1.1.0) Some change")
|
||||
mock_run_git.assert_called_once()
|
||||
args, kwargs = mock_run_git.call_args
|
||||
mock_get_changelog.assert_called_once_with(
|
||||
cwd="/repo",
|
||||
from_ref="v1.0.0",
|
||||
to_ref="v1.1.0",
|
||||
include_merges=True,
|
||||
)
|
||||
|
||||
cmd = args[0]
|
||||
self.assertEqual(cmd[0], "log")
|
||||
self.assertIn("--pretty=format:%h %d %s", cmd)
|
||||
# include_merges=True -> no --no-merges flag
|
||||
self.assertNotIn("--no-merges", cmd)
|
||||
# Range must be exactly v1.0.0..v1.1.0
|
||||
self.assertIn("v1.0.0..v1.1.0", cmd)
|
||||
self.assertEqual(kwargs.get("cwd"), "/repo")
|
||||
|
||||
@patch("pkgmgr.actions.changelog.run_git")
|
||||
def test_generate_changelog_giterror_returns_error_message(self, mock_run_git) -> None:
|
||||
"""
|
||||
If Git fails, we do NOT raise; instead we return a human readable error string.
|
||||
"""
|
||||
mock_run_git.side_effect = GitError("simulated git failure")
|
||||
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||
def test_generate_changelog_giterror_returns_error_message(self, mock_get_changelog) -> None:
|
||||
mock_get_changelog.side_effect = GitChangelogQueryError("simulated git failure")
|
||||
|
||||
result = generate_changelog(cwd="/repo", from_ref="v0.1.0", to_ref="v0.2.0")
|
||||
|
||||
@@ -76,12 +52,9 @@ class TestGenerateChangelog(unittest.TestCase):
|
||||
self.assertIn("simulated git failure", result)
|
||||
self.assertIn("v0.1.0..v0.2.0", result)
|
||||
|
||||
@patch("pkgmgr.actions.changelog.run_git")
|
||||
def test_generate_changelog_empty_output_returns_info(self, mock_run_git) -> None:
|
||||
"""
|
||||
Empty git log output -> informational message instead of empty string.
|
||||
"""
|
||||
mock_run_git.return_value = " \n "
|
||||
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||
def test_generate_changelog_empty_output_returns_info(self, mock_get_changelog) -> None:
|
||||
mock_get_changelog.return_value = " \n "
|
||||
|
||||
result = generate_changelog(cwd="/repo", from_ref=None, to_ref="HEAD")
|
||||
|
||||
@@ -90,49 +63,38 @@ class TestGenerateChangelog(unittest.TestCase):
|
||||
|
||||
class TestFindPreviousAndCurrentTag(unittest.TestCase):
|
||||
def test_no_semver_tags_returns_none_none(self) -> None:
|
||||
tags = ["foo", "bar", "v1.2", "v1.2.3.4"] # all invalid for SemVer
|
||||
tags = ["foo", "bar", "v1.2", "v1.2.3.4"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertIsNone(cur_tag)
|
||||
|
||||
def test_latest_tags_when_no_target_given(self) -> None:
|
||||
"""
|
||||
When no target tag is given, the function should return:
|
||||
(second_latest_semver_tag, latest_semver_tag)
|
||||
based on semantic version ordering, not lexicographic order.
|
||||
"""
|
||||
tags = ["v1.0.0", "v1.2.0", "v1.1.0", "not-a-tag"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||
|
||||
self.assertEqual(prev_tag, "v1.1.0")
|
||||
self.assertEqual(cur_tag, "v1.2.0")
|
||||
|
||||
def test_single_semver_tag_returns_none_and_that_tag(self) -> None:
|
||||
tags = ["v0.1.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertEqual(cur_tag, "v0.1.0")
|
||||
|
||||
def test_with_target_tag_in_the_middle(self) -> None:
|
||||
tags = ["v1.0.0", "v1.1.0", "v1.2.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.1.0")
|
||||
|
||||
self.assertEqual(prev_tag, "v1.0.0")
|
||||
self.assertEqual(cur_tag, "v1.1.0")
|
||||
|
||||
def test_with_target_tag_first_has_no_previous(self) -> None:
|
||||
tags = ["v1.0.0", "v1.1.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.0.0")
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertEqual(cur_tag, "v1.0.0")
|
||||
|
||||
def test_unknown_target_tag_returns_none_none(self) -> None:
|
||||
tags = ["v1.0.0", "v1.1.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v2.0.0")
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertIsNone(cur_tag)
|
||||
|
||||
|
||||
0
tests/unit/pkgmgr/core/git/queries/__init__.py
Normal file
0
tests/unit/pkgmgr/core/git/queries/__init__.py
Normal file
50
tests/unit/pkgmgr/core/git/queries/test_remote_check.py
Normal file
50
tests/unit/pkgmgr/core/git/queries/test_remote_check.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.core.git.queries.probe_remote_reachable import probe_remote_reachable
|
||||
|
||||
|
||||
class TestProbeRemoteReachable(unittest.TestCase):
|
||||
"""
|
||||
Unit tests for non-destructive remote probing (git ls-remote).
|
||||
"""
|
||||
|
||||
@patch("pkgmgr.core.git.queries.probe_remote_reachable.run")
|
||||
def test_probe_remote_reachable_success_returns_true(self, mock_run) -> None:
|
||||
mock_run.return_value = "dummy-output"
|
||||
|
||||
ok = probe_remote_reachable(
|
||||
"ssh://git@code.example.org:2201/alice/repo.git",
|
||||
cwd="/tmp/some-repo",
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
mock_run.assert_called_once_with(
|
||||
["ls-remote", "--exit-code", "ssh://git@code.example.org:2201/alice/repo.git"],
|
||||
cwd="/tmp/some-repo",
|
||||
)
|
||||
|
||||
@patch("pkgmgr.core.git.queries.probe_remote_reachable.run")
|
||||
def test_probe_remote_reachable_failure_returns_false(self, mock_run) -> None:
|
||||
mock_run.side_effect = GitError("Git command failed (simulated)")
|
||||
|
||||
ok = probe_remote_reachable(
|
||||
"ssh://git@code.example.org:2201/alice/repo.git",
|
||||
cwd="/tmp/some-repo",
|
||||
)
|
||||
|
||||
self.assertFalse(ok)
|
||||
mock_run.assert_called_once_with(
|
||||
["ls-remote", "--exit-code", "ssh://git@code.example.org:2201/alice/repo.git"],
|
||||
cwd="/tmp/some-repo",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.git import GitError
|
||||
from pkgmgr.core.git.queries.resolve_base_branch import (
|
||||
GitBaseBranchNotFoundError,
|
||||
resolve_base_branch,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveBaseBranch(unittest.TestCase):
|
||||
@patch("pkgmgr.core.git.queries.resolve_base_branch.run")
|
||||
def test_resolves_preferred(self, mock_run):
|
||||
mock_run.return_value = "dummy"
|
||||
result = resolve_base_branch("main", "master", cwd=".")
|
||||
self.assertEqual(result, "main")
|
||||
mock_run.assert_called_with(["rev-parse", "--verify", "main"], cwd=".")
|
||||
|
||||
@patch("pkgmgr.core.git.queries.resolve_base_branch.run")
|
||||
def test_resolves_fallback(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
GitError("fatal: Needed a single revision"), # treat as "missing"
|
||||
"dummy",
|
||||
]
|
||||
result = resolve_base_branch("main", "master", cwd=".")
|
||||
self.assertEqual(result, "master")
|
||||
self.assertEqual(mock_run.call_args_list[0].kwargs["cwd"], ".")
|
||||
self.assertEqual(mock_run.call_args_list[1].kwargs["cwd"], ".")
|
||||
|
||||
@patch("pkgmgr.core.git.queries.resolve_base_branch.run")
|
||||
def test_raises_when_no_branch_exists(self, mock_run):
|
||||
mock_run.side_effect = [
|
||||
GitError("fatal: Needed a single revision"),
|
||||
GitError("fatal: Needed a single revision"),
|
||||
]
|
||||
with self.assertRaises(GitBaseBranchNotFoundError):
|
||||
resolve_base_branch("main", "master", cwd=".")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,40 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.core.git import (
|
||||
GitError,
|
||||
run_git,
|
||||
get_tags,
|
||||
get_head_commit,
|
||||
get_current_branch,
|
||||
)
|
||||
from pkgmgr.core.git.errors import GitError
|
||||
from pkgmgr.core.git.run import run
|
||||
from pkgmgr.core.git.queries import get_tags, get_head_commit, get_current_branch
|
||||
|
||||
|
||||
class TestGitUtils(unittest.TestCase):
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
def test_run_git_success(self, mock_run):
|
||||
mock_run.return_value = SimpleNamespace(
|
||||
stdout="ok\n",
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
class TestGitRun(unittest.TestCase):
|
||||
@patch("pkgmgr.core.git.run.subprocess.run")
|
||||
def test_run_success(self, mock_run):
|
||||
mock_run.return_value.stdout = "ok\n"
|
||||
mock_run.return_value.stderr = ""
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
output = run_git(["status"], cwd="/tmp/repo")
|
||||
output = run(["status"], cwd="/tmp/repo")
|
||||
|
||||
self.assertEqual(output, "ok")
|
||||
mock_run.assert_called_once()
|
||||
# basic sanity: command prefix should be 'git'
|
||||
args, kwargs = mock_run.call_args
|
||||
self.assertEqual(args[0][0], "git")
|
||||
self.assertEqual(kwargs.get("cwd"), "/tmp/repo")
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
def test_run_git_failure_raises_giterror(self, mock_run):
|
||||
@patch("pkgmgr.core.git.run.subprocess.run")
|
||||
def test_run_failure_raises_giterror(self, mock_run):
|
||||
import subprocess
|
||||
|
||||
mock_run.side_effect = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=["git", "status"],
|
||||
@@ -43,7 +36,7 @@ class TestGitUtils(unittest.TestCase):
|
||||
)
|
||||
|
||||
with self.assertRaises(GitError) as ctx:
|
||||
run_git(["status"], cwd="/tmp/repo")
|
||||
run(["status"], cwd="/tmp/repo")
|
||||
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("Git command failed", msg)
|
||||
@@ -51,71 +44,41 @@ class TestGitUtils(unittest.TestCase):
|
||||
self.assertIn("bad", msg)
|
||||
self.assertIn("error", msg)
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
def test_get_tags_empty(self, mock_run):
|
||||
mock_run.return_value = SimpleNamespace(
|
||||
stdout="",
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
class TestGitQueries(unittest.TestCase):
|
||||
@patch("pkgmgr.core.git.queries.get_tags.run")
|
||||
def test_get_tags_empty(self, mock_run):
|
||||
mock_run.return_value = ""
|
||||
tags = get_tags(cwd="/tmp/repo")
|
||||
self.assertEqual(tags, [])
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
@patch("pkgmgr.core.git.queries.get_tags.run")
|
||||
def test_get_tags_non_empty(self, mock_run):
|
||||
mock_run.return_value = SimpleNamespace(
|
||||
stdout="v1.0.0\nv1.1.0\n",
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
mock_run.return_value = "v1.0.0\nv1.1.0\n"
|
||||
tags = get_tags(cwd="/tmp/repo")
|
||||
self.assertEqual(tags, ["v1.0.0", "v1.1.0"])
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
@patch("pkgmgr.core.git.queries.get_head_commit.run")
|
||||
def test_get_head_commit_success(self, mock_run):
|
||||
mock_run.return_value = SimpleNamespace(
|
||||
stdout="abc123\n",
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
mock_run.return_value = "abc123"
|
||||
commit = get_head_commit(cwd="/tmp/repo")
|
||||
self.assertEqual(commit, "abc123")
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
@patch("pkgmgr.core.git.queries.get_head_commit.run")
|
||||
def test_get_head_commit_failure_returns_none(self, mock_run):
|
||||
mock_run.side_effect = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=["git", "rev-parse", "HEAD"],
|
||||
output="",
|
||||
stderr="error\n",
|
||||
)
|
||||
|
||||
mock_run.side_effect = GitError("fail")
|
||||
commit = get_head_commit(cwd="/tmp/repo")
|
||||
self.assertIsNone(commit)
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
@patch("pkgmgr.core.git.queries.get_current_branch.run")
|
||||
def test_get_current_branch_success(self, mock_run):
|
||||
mock_run.return_value = SimpleNamespace(
|
||||
stdout="main\n",
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
mock_run.return_value = "main"
|
||||
branch = get_current_branch(cwd="/tmp/repo")
|
||||
self.assertEqual(branch, "main")
|
||||
|
||||
@patch("pkgmgr.core.git.subprocess.run")
|
||||
@patch("pkgmgr.core.git.queries.get_current_branch.run")
|
||||
def test_get_current_branch_failure_returns_none(self, mock_run):
|
||||
mock_run.side_effect = subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
output="",
|
||||
stderr="error\n",
|
||||
)
|
||||
|
||||
mock_run.side_effect = GitError("fail")
|
||||
branch = get_current_branch(cwd="/tmp/repo")
|
||||
self.assertIsNone(branch)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user