Compare commits
3 Commits
v1.8.0
...
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
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(
|
def close_branch(
|
||||||
@@ -14,7 +28,6 @@ def close_branch(
|
|||||||
"""
|
"""
|
||||||
Merge a feature branch into the base branch and delete it afterwards.
|
Merge a feature branch into the base branch and delete it afterwards.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Determine branch name
|
# Determine branch name
|
||||||
if not name:
|
if not name:
|
||||||
try:
|
try:
|
||||||
@@ -25,7 +38,7 @@ def close_branch(
|
|||||||
if not name:
|
if not name:
|
||||||
raise RuntimeError("Branch name must not be empty.")
|
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:
|
if name == target_base:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -42,58 +55,20 @@ def close_branch(
|
|||||||
print("Aborted closing branch.")
|
print("Aborted closing branch.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fetch
|
# Execute workflow (commands raise specific GitError subclasses)
|
||||||
try:
|
fetch("origin", cwd=cwd)
|
||||||
run_git(["fetch", "origin"], cwd=cwd)
|
checkout(target_base, cwd=cwd)
|
||||||
except GitError as exc:
|
pull("origin", target_base, cwd=cwd)
|
||||||
raise RuntimeError(
|
merge_no_ff(name, cwd=cwd)
|
||||||
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
|
push("origin", target_base, cwd=cwd)
|
||||||
) from exc
|
|
||||||
|
|
||||||
# Checkout base
|
# Delete local branch (safe delete by default)
|
||||||
try:
|
delete_local_branch(name, cwd=cwd, force=False)
|
||||||
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
|
|
||||||
|
|
||||||
# Pull latest
|
# Delete remote branch (special-case error message)
|
||||||
try:
|
try:
|
||||||
run_git(["pull", "origin", target_base], cwd=cwd)
|
delete_remote_branch("origin", name, cwd=cwd)
|
||||||
except GitError as exc:
|
except GitDeleteRemoteBranchError 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:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Branch {name!r} deleted locally, but remote deletion failed: {exc}"
|
f"Branch {name!r} deleted locally, but remote deletion failed: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
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(
|
def drop_branch(
|
||||||
@@ -14,7 +23,6 @@ def drop_branch(
|
|||||||
"""
|
"""
|
||||||
Delete a branch locally and remotely without merging.
|
Delete a branch locally and remotely without merging.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
try:
|
try:
|
||||||
name = get_current_branch(cwd=cwd)
|
name = get_current_branch(cwd=cwd)
|
||||||
@@ -24,7 +32,7 @@ def drop_branch(
|
|||||||
if not name:
|
if not name:
|
||||||
raise RuntimeError("Branch name must not be empty.")
|
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:
|
if name == target_base:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -40,16 +48,12 @@ def drop_branch(
|
|||||||
print("Aborted dropping branch.")
|
print("Aborted dropping branch.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Local delete
|
delete_local_branch(name, cwd=cwd, force=False)
|
||||||
|
|
||||||
|
# Remote delete (special-case message)
|
||||||
try:
|
try:
|
||||||
run_git(["branch", "-d", name], cwd=cwd)
|
delete_remote_branch("origin", name, cwd=cwd)
|
||||||
except GitError as exc:
|
except GitDeleteRemoteBranchError 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:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
|
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
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(
|
def open_branch(
|
||||||
@@ -13,7 +21,6 @@ def open_branch(
|
|||||||
"""
|
"""
|
||||||
Create and push a new feature branch on top of a base branch.
|
Create and push a new feature branch on top of a base branch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Request name interactively if not provided
|
# Request name interactively if not provided
|
||||||
if not name:
|
if not name:
|
||||||
name = input("Enter new branch name: ").strip()
|
name = input("Enter new branch name: ").strip()
|
||||||
@@ -21,44 +28,13 @@ def open_branch(
|
|||||||
if not name:
|
if not name:
|
||||||
raise RuntimeError("Branch name must not be empty.")
|
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
|
# Workflow (commands raise specific GitError subclasses)
|
||||||
try:
|
fetch("origin", cwd=cwd)
|
||||||
run_git(["fetch", "origin"], cwd=cwd)
|
checkout(resolved_base, cwd=cwd)
|
||||||
except GitError as exc:
|
pull("origin", resolved_base, cwd=cwd)
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to fetch from origin before creating branch {name!r}: {exc}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
# 2) Checkout base branch
|
# Create new branch from resolved base and push it with upstream tracking
|
||||||
try:
|
create_branch(name, resolved_base, cwd=cwd)
|
||||||
run_git(["checkout", resolved_base], cwd=cwd)
|
push_upstream("origin", name, 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
|
|
||||||
|
|||||||
@@ -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.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pkgmgr.core.git import run_git, GitError
|
from pkgmgr.core.git.queries import (
|
||||||
|
get_changelog,
|
||||||
|
GitChangelogQueryError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_changelog(
|
def generate_changelog(
|
||||||
@@ -25,48 +24,20 @@ def generate_changelog(
|
|||||||
"""
|
"""
|
||||||
Generate a plain-text changelog between two Git refs.
|
Generate a plain-text changelog between two Git refs.
|
||||||
|
|
||||||
Parameters
|
Returns a human-readable message instead of raising.
|
||||||
----------
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
# Determine the revision range
|
|
||||||
if to_ref is None:
|
if to_ref is None:
|
||||||
to_ref = "HEAD"
|
to_ref = "HEAD"
|
||||||
|
|
||||||
if from_ref:
|
rev_range = f"{from_ref}..{to_ref}" if from_ref else to_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)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
output = run_git(cmd, cwd=cwd)
|
output = get_changelog(
|
||||||
except GitError as exc:
|
cwd=cwd,
|
||||||
# Do not raise to the CLI, return a human-readable error instead.
|
from_ref=from_ref,
|
||||||
|
to_ref=to_ref,
|
||||||
|
include_merges=include_merges,
|
||||||
|
)
|
||||||
|
except GitChangelogQueryError as exc:
|
||||||
return (
|
return (
|
||||||
f"[ERROR] Failed to generate changelog in {cwd!r} "
|
f"[ERROR] Failed to generate changelog in {cwd!r} "
|
||||||
f"for range {rev_range!r}:\n{exc}"
|
f"for range {rev_range!r}:\n{exc}"
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
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.errors import GitError
|
||||||
from pkgmgr.core.git import GitError, run_git
|
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
|
from .types import MirrorMap, RepoMirrorContext, Repository
|
||||||
|
|
||||||
@@ -48,29 +59,20 @@ def determine_primary_remote_url(
|
|||||||
return build_default_ssh_url(repo)
|
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:
|
def has_origin_remote(repo_dir: str) -> bool:
|
||||||
out = _safe_git_output(["remote"], cwd=repo_dir)
|
try:
|
||||||
return bool(out and "origin" in out.split())
|
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:
|
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}"
|
Ensure origin has fetch URL and push URL set to the primary URL.
|
||||||
|
Preview is handled by the underlying git runner.
|
||||||
if preview:
|
"""
|
||||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {fetch}")
|
set_remote_url("origin", url, cwd=repo_dir, push=False, preview=preview)
|
||||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {push}")
|
set_remote_url("origin", url, cwd=repo_dir, push=True, preview=preview)
|
||||||
return
|
|
||||||
|
|
||||||
run_command(fetch, cwd=repo_dir, preview=False)
|
|
||||||
run_command(push, cwd=repo_dir, preview=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_additional_push_urls(
|
def _ensure_additional_push_urls(
|
||||||
@@ -79,22 +81,21 @@ def _ensure_additional_push_urls(
|
|||||||
primary: str,
|
primary: str,
|
||||||
preview: bool,
|
preview: bool,
|
||||||
) -> None:
|
) -> 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}
|
desired: Set[str] = {u for u in mirrors.values() if u and u != primary}
|
||||||
if not desired:
|
if not desired:
|
||||||
return
|
return
|
||||||
|
|
||||||
out = _safe_git_output(
|
try:
|
||||||
["remote", "get-url", "--push", "--all", "origin"],
|
existing = get_remote_push_urls("origin", cwd=repo_dir)
|
||||||
cwd=repo_dir,
|
except GitError:
|
||||||
)
|
existing = set()
|
||||||
existing = set(out.splitlines()) if out else set()
|
|
||||||
|
|
||||||
for url in sorted(desired - existing):
|
for url in sorted(desired - existing):
|
||||||
cmd = f"git remote set-url --add --push origin {url}"
|
add_remote_push_url("origin", url, cwd=repo_dir, preview=preview)
|
||||||
if preview:
|
|
||||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
|
|
||||||
else:
|
|
||||||
run_command(cmd, cwd=repo_dir, preview=False)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_origin_remote(
|
def ensure_origin_remote(
|
||||||
@@ -113,21 +114,23 @@ def ensure_origin_remote(
|
|||||||
print("[WARN] No primary mirror URL could be determined.")
|
print("[WARN] No primary mirror URL could be determined.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 1) Ensure origin exists
|
||||||
if not has_origin_remote(repo_dir):
|
if not has_origin_remote(repo_dir):
|
||||||
cmd = f"git remote add origin {primary}"
|
try:
|
||||||
if preview:
|
add_remote("origin", primary, cwd=repo_dir, preview=preview)
|
||||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
|
except GitAddRemoteError as exc:
|
||||||
else:
|
print(f"[WARN] Failed to add origin remote: {exc}")
|
||||||
run_command(cmd, cwd=repo_dir, preview=False)
|
return # without origin we cannot reliably proceed
|
||||||
|
|
||||||
_set_origin_fetch_and_push(repo_dir, primary, preview)
|
# 2) Ensure origin fetch+push URLs are correct (ALWAYS, even if origin already existed)
|
||||||
|
|
||||||
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
|
|
||||||
|
|
||||||
|
|
||||||
def is_remote_reachable(url: str, cwd: Optional[str] = None) -> bool:
|
|
||||||
try:
|
try:
|
||||||
run_git(["ls-remote", "--exit-code", url], cwd=cwd or os.getcwd())
|
_set_origin_fetch_and_push(repo_dir, primary, preview)
|
||||||
return True
|
except GitSetRemoteUrlError as exc:
|
||||||
except GitError:
|
# Do not abort: still try to add additional push URLs
|
||||||
return False
|
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 .context import build_context
|
||||||
from .git_remote import ensure_origin_remote, determine_primary_remote_url
|
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 .remote_provision import ensure_remote_repository
|
||||||
from .types import Repository
|
from .types import Repository
|
||||||
|
|
||||||
@@ -52,19 +52,14 @@ def _setup_remote_mirrors_for_repo(
|
|||||||
primary = determine_primary_remote_url(repo, ctx)
|
primary = determine_primary_remote_url(repo, ctx)
|
||||||
if not primary:
|
if not primary:
|
||||||
return
|
return
|
||||||
|
ok = probe_remote_reachable(primary, cwd=ctx.repo_dir)
|
||||||
ok, msg = probe_mirror(primary, ctx.repo_dir)
|
|
||||||
print("[OK]" if ok else "[WARN]", primary)
|
print("[OK]" if ok else "[WARN]", primary)
|
||||||
if msg:
|
|
||||||
print(msg)
|
|
||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
for name, url in ctx.resolved_mirrors.items():
|
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}")
|
print(f"[OK] {name}: {url}" if ok else f"[WARN] {name}: {url}")
|
||||||
if msg:
|
|
||||||
print(msg)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
from __future__ import annotations
|
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
|
from pkgmgr.core.version.semver import SemVer, is_semver_tag
|
||||||
|
|
||||||
|
|
||||||
def head_semver_tags(cwd: str = ".") -> list[str]:
|
def head_semver_tags(cwd: str = ".") -> list[str]:
|
||||||
out = run_git(["tag", "--points-at", "HEAD"], cwd=cwd)
|
tags = get_tags_at_ref("HEAD", cwd=cwd)
|
||||||
if not out:
|
|
||||||
return []
|
|
||||||
|
|
||||||
tags = [t.strip() for t in out.splitlines() if t.strip()]
|
|
||||||
tags = [t for t in tags if is_semver_tag(t) and t.startswith("v")]
|
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)
|
return sorted(tags, key=SemVer.parse)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Version discovery and bumping helpers for the release workflow.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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 (
|
from pkgmgr.core.version.semver import (
|
||||||
SemVer,
|
SemVer,
|
||||||
find_latest_version,
|
find_latest_version,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# src/pkgmgr/actions/release/workflow.py
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -6,7 +5,8 @@ import sys
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pkgmgr.actions.branch import close_branch
|
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 pkgmgr.core.repository.paths import resolve_repo_paths
|
||||||
|
|
||||||
from .files import (
|
from .files import (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
from pkgmgr.cli.context import CLIContext
|
from pkgmgr.cli.context import CLIContext
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
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.core.version.semver import extract_semver_from_tags
|
||||||
from pkgmgr.actions.changelog import generate_changelog
|
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.cli.context import CLIContext
|
||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
from pkgmgr.core.repository.identifier import get_repo_identifier
|
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.semver import SemVer, find_latest_version
|
||||||
from pkgmgr.core.version.installed import (
|
from pkgmgr.core.version.installed import (
|
||||||
get_installed_python_version,
|
get_installed_python_version,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
from __future__ import annotations
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Lightweight helper functions around Git commands.
|
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.
|
details of subprocess handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from .errors import GitError
|
||||||
|
from .run import run
|
||||||
|
|
||||||
import subprocess
|
__all__ = [
|
||||||
from typing import List, Optional
|
"GitError",
|
||||||
|
"run"
|
||||||
|
]
|
||||||
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
|
|
||||||
|
|||||||
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):
|
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:
|
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."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
original_argv = list(sys.argv)
|
original_argv = list(sys.argv)
|
||||||
original_env = dict(os.environ)
|
original_env = dict(os.environ)
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
@@ -64,8 +56,7 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
|
|||||||
try:
|
try:
|
||||||
importlib.import_module(module_name)
|
importlib.import_module(module_name)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
# If the module truly doesn't exist, create=True may still allow patching
|
# Best-effort: allow patch(create=True) even if a symbol moved.
|
||||||
# in some cases, but dotted resolution can still fail. Best-effort.
|
|
||||||
pass
|
pass
|
||||||
return patch(target, create=True, **kwargs)
|
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.setup_cmd.build_context", return_value=dummy_ctx))
|
||||||
stack.enter_context(_p("pkgmgr.actions.mirror.remote_provision.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)
|
# Deterministic remote probing (new refactor: probe_remote_reachable)
|
||||||
stack.enter_context(_p("pkgmgr.actions.mirror.remote_check.probe_mirror", return_value=(True, "")))
|
stack.enter_context(_p("pkgmgr.core.git.queries.probe_remote_reachable", return_value=True))
|
||||||
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.probe_mirror", return_value=(True, "")))
|
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable", return_value=True))
|
||||||
stack.enter_context(_p("pkgmgr.actions.mirror.git_remote.is_remote_reachable", return_value=True))
|
|
||||||
|
|
||||||
# setup_cmd imports ensure_origin_remote directly:
|
# setup_cmd imports ensure_origin_remote directly:
|
||||||
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote", return_value=None))
|
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):
|
with redirect_stdout(buffer), redirect_stderr(buffer):
|
||||||
try:
|
try:
|
||||||
runpy.run_module("pkgmgr", run_name="__main__")
|
runpy.run_module("pkgmgr", run_name="__main__")
|
||||||
@@ -134,10 +121,6 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
|
|||||||
os.environ.clear()
|
os.environ.clear()
|
||||||
os.environ.update(original_env)
|
os.environ.update(original_env)
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Tests
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_mirror_help(self) -> None:
|
def test_mirror_help(self) -> None:
|
||||||
output = self._run_pkgmgr(["mirror", "--help"])
|
output = self._run_pkgmgr(["mirror", "--help"])
|
||||||
self.assertIn("usage:", output.lower())
|
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 unittest.mock import patch
|
||||||
|
|
||||||
from pkgmgr.actions.branch.close_branch import close_branch
|
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):
|
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.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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.close_branch.run_git")
|
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||||
def test_close_branch_happy_path(self, run_git, resolve, current, input_mock):
|
@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=".")
|
close_branch(None, cwd=".")
|
||||||
expected = [
|
fetch.assert_called_once_with("origin", cwd=".")
|
||||||
(["fetch", "origin"],),
|
checkout.assert_called_once_with("main", cwd=".")
|
||||||
(["checkout", "main"],),
|
pull.assert_called_once_with("origin", "main", cwd=".")
|
||||||
(["pull", "origin", "main"],),
|
merge_no_ff.assert_called_once_with("feature-x", cwd=".")
|
||||||
(["merge", "--no-ff", "feature-x"],),
|
push.assert_called_once_with("origin", "main", cwd=".")
|
||||||
(["push", "origin", "main"],),
|
delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False)
|
||||||
(["branch", "-d", "feature-x"],),
|
delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||||
(["push", "origin", "--delete", "feature-x"],),
|
|
||||||
]
|
|
||||||
actual = [call.args for call in run_git.call_args_list]
|
|
||||||
self.assertEqual(actual, expected)
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="main")
|
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.close_branch._resolve_base_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):
|
def test_refuses_to_close_base_branch(self, _resolve, _current) -> None:
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
close_branch(None)
|
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.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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.close_branch.run_git")
|
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||||
def test_close_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
|
def test_close_branch_aborts_on_no(self, fetch, _resolve, _current, _input_mock) -> None:
|
||||||
close_branch(None, cwd=".")
|
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.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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.close_branch.run_git")
|
@patch("pkgmgr.actions.branch.close_branch.fetch")
|
||||||
def test_close_branch_force_skips_prompt(self, run_git, resolve, current):
|
@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)
|
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"))
|
@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):
|
with self.assertRaises(RuntimeError):
|
||||||
close_branch(None)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,49 +2,79 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pkgmgr.actions.branch.drop_branch import drop_branch
|
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):
|
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.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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.drop_branch.run_git")
|
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||||
def test_drop_branch_happy_path(self, run_git, resolve, current, input_mock):
|
@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=".")
|
drop_branch(None, cwd=".")
|
||||||
expected = [
|
delete_local.assert_called_once_with("feature-x", cwd=".", force=False)
|
||||||
(["branch", "-d", "feature-x"],),
|
delete_remote.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||||
(["push", "origin", "--delete", "feature-x"],),
|
|
||||||
]
|
|
||||||
actual = [call.args for call in run_git.call_args_list]
|
|
||||||
self.assertEqual(actual, expected)
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="main")
|
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_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):
|
def test_refuses_to_drop_base_branch(self, _resolve, _current) -> None:
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
drop_branch(None)
|
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.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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.drop_branch.run_git")
|
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||||
def test_drop_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
|
def test_drop_branch_aborts_on_no(self, delete_local, _resolve, _current, _input_mock) -> None:
|
||||||
drop_branch(None, cwd=".")
|
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.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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.drop_branch.run_git")
|
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
|
||||||
def test_drop_branch_force_skips_prompt(self, run_git, resolve, current):
|
@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)
|
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"))
|
@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):
|
with self.assertRaises(RuntimeError):
|
||||||
drop_branch(None)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -5,29 +5,55 @@ from pkgmgr.actions.branch.open_branch import open_branch
|
|||||||
|
|
||||||
|
|
||||||
class TestOpenBranch(unittest.TestCase):
|
class TestOpenBranch(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
|
@patch("pkgmgr.actions.branch.open_branch.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.open_branch.run_git")
|
@patch("pkgmgr.actions.branch.open_branch.fetch")
|
||||||
def test_open_branch_executes_git_commands(self, run_git, resolve):
|
@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=".")
|
open_branch("feature-x", base_branch="main", cwd=".")
|
||||||
expected_calls = [
|
|
||||||
(["fetch", "origin"],),
|
fetch.assert_called_once_with("origin", cwd=".")
|
||||||
(["checkout", "main"],),
|
checkout.assert_called_once_with("main", cwd=".")
|
||||||
(["pull", "origin", "main"],),
|
pull.assert_called_once_with("origin", "main", cwd=".")
|
||||||
(["checkout", "-b", "feature-x"],),
|
create_branch.assert_called_once_with("feature-x", "main", cwd=".")
|
||||||
(["push", "-u", "origin", "feature-x"],),
|
push_upstream.assert_called_once_with("origin", "feature-x", cwd=".")
|
||||||
]
|
|
||||||
actual = [call.args for call in run_git.call_args_list]
|
|
||||||
self.assertEqual(actual, expected_calls)
|
|
||||||
|
|
||||||
@patch("builtins.input", return_value="auto-branch")
|
@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.resolve_base_branch", return_value="main")
|
||||||
@patch("pkgmgr.actions.branch.open_branch.run_git")
|
@patch("pkgmgr.actions.branch.open_branch.fetch")
|
||||||
def test_open_branch_prompts_for_name(self, run_git, resolve, input_mock):
|
@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)
|
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 patch("builtins.input", return_value=""):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
open_branch(None)
|
open_branch(None)
|
||||||
|
|||||||
@@ -21,46 +21,34 @@ class TestMirrorGitRemote(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_build_default_ssh_url(self) -> None:
|
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"}
|
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||||
ctx = self._ctx(config={"origin": "git@github.com:alice/repo.git"})
|
self.assertEqual(build_default_ssh_url(repo), "git@github.com:alice/repo.git")
|
||||||
self.assertEqual(
|
|
||||||
determine_primary_remote_url(repo, ctx),
|
|
||||||
"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"}
|
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||||
ctx = self._ctx(
|
ctx = self._ctx(file={"origin": "git@github.com:alice/repo.git"})
|
||||||
file={
|
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@github.com:alice/repo.git")
|
||||||
"first": "git@a/first.git",
|
|
||||||
"second": "git@a/second.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(
|
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@a/first.git")
|
||||||
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:
|
def test_determine_primary_fallback_default(self) -> None:
|
||||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||||
ctx = self._ctx()
|
ctx = self._ctx()
|
||||||
self.assertEqual(
|
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@github.com:alice/repo.git")
|
||||||
determine_primary_remote_url(repo, ctx),
|
|
||||||
"git@github.com:alice/repo.git",
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.mirror.git_remote._safe_git_output")
|
@patch("pkgmgr.actions.mirror.git_remote.list_remotes", return_value=["origin", "backup"])
|
||||||
def test_has_origin_remote(self, m_out) -> None:
|
def test_has_origin_remote_true(self, _m_list) -> None:
|
||||||
m_out.return_value = "origin\nupstream\n"
|
|
||||||
self.assertTrue(has_origin_remote("/tmp/repo"))
|
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):
|
class TestGitRemotePrimaryPush(unittest.TestCase):
|
||||||
def test_origin_created_and_extra_push_added(self) -> None:
|
def test_origin_created_and_extra_push_added(self) -> None:
|
||||||
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
|
||||||
|
|
||||||
|
# Use file_mirrors so ctx.resolved_mirrors contains both, no setattr (frozen dataclass!)
|
||||||
ctx = RepoMirrorContext(
|
ctx = RepoMirrorContext(
|
||||||
identifier="repo",
|
identifier="repo",
|
||||||
repo_dir="/tmp/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:
|
# determine_primary_remote_url falls back to file order (primary first)
|
||||||
executed.append(cmd)
|
m_add_remote.assert_called_once_with(
|
||||||
|
"origin",
|
||||||
def fake_git(args, cwd):
|
"git@github.com:alice/repo.git",
|
||||||
if args == ["remote"]:
|
cwd="/tmp/repo",
|
||||||
return ""
|
preview=False,
|
||||||
if args == ["remote", "get-url", "--push", "--all", "origin"]:
|
)
|
||||||
return "git@github.com:alice/repo.git\n"
|
|
||||||
return ""
|
m_set_remote_url.assert_any_call(
|
||||||
|
"origin",
|
||||||
with patch("os.path.isdir", return_value=True), patch(
|
"git@github.com:alice/repo.git",
|
||||||
"pkgmgr.actions.mirror.git_remote.run_command", side_effect=fake_run
|
cwd="/tmp/repo",
|
||||||
), patch(
|
push=False,
|
||||||
"pkgmgr.actions.mirror.git_remote._safe_git_output", side_effect=fake_git
|
preview=False,
|
||||||
):
|
)
|
||||||
ensure_origin_remote(repo, ctx, preview=False)
|
m_set_remote_url.assert_any_call(
|
||||||
|
"origin",
|
||||||
self.assertEqual(
|
"git@github.com:alice/repo.git",
|
||||||
executed,
|
cwd="/tmp/repo",
|
||||||
[
|
push=True,
|
||||||
"git remote add origin git@github.com:alice/repo.git",
|
preview=False,
|
||||||
"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",
|
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):
|
class TestMirrorSetupCmd(unittest.TestCase):
|
||||||
def _ctx(
|
def _ctx(self, *, repo_dir: str = "/tmp/repo", resolved: dict[str, str] | None = None) -> RepoMirrorContext:
|
||||||
self,
|
# resolved_mirrors is a @property combining config+file. Put it into file_mirrors.
|
||||||
*,
|
|
||||||
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.
|
|
||||||
return RepoMirrorContext(
|
return RepoMirrorContext(
|
||||||
identifier="repo-id",
|
identifier="repo",
|
||||||
repo_dir=repo_dir,
|
repo_dir=repo_dir,
|
||||||
config_mirrors={},
|
config_mirrors={},
|
||||||
file_mirrors=resolved or {},
|
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.build_context")
|
||||||
@patch("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote")
|
@patch("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote")
|
||||||
def test_setup_mirrors_local_calls_ensure_origin_remote(self, m_ensure, m_ctx) -> None:
|
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"}]
|
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
|
||||||
setup_mirrors(
|
setup_mirrors(
|
||||||
@@ -39,24 +34,21 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
|||||||
ensure_remote=False,
|
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
|
args, kwargs = m_ensure.call_args
|
||||||
|
|
||||||
# ensure_origin_remote(repo, ctx, preview) may be positional or kw.
|
self.assertEqual(args[0], repos[0])
|
||||||
# Accept both to avoid coupling tests to call style.
|
self.assertIs(args[1], ctx)
|
||||||
if "preview" in kwargs:
|
self.assertEqual(kwargs.get("preview", args[2] if len(args) >= 3 else None), True)
|
||||||
self.assertTrue(kwargs["preview"])
|
|
||||||
else:
|
|
||||||
# args: (repo, ctx, preview)
|
|
||||||
self.assertTrue(args[2])
|
|
||||||
|
|
||||||
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
|
@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")
|
@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_ctx.return_value = self._ctx(repo_dir="/tmp/repo", resolved={})
|
||||||
m_primary.return_value = "git@github.com:alice/repo.git"
|
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"}]
|
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
|
||||||
setup_mirrors(
|
setup_mirrors(
|
||||||
@@ -70,10 +62,10 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
m_primary.assert_called()
|
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.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:
|
def test_setup_mirrors_remote_with_mirrors_probes_each(self, m_probe, m_ctx) -> None:
|
||||||
m_ctx.return_value = self._ctx(
|
m_ctx.return_value = self._ctx(
|
||||||
repo_dir="/tmp/repo",
|
repo_dir="/tmp/repo",
|
||||||
@@ -82,7 +74,7 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
|||||||
"backup": "ssh://git@git.veen.world:2201/alice/repo.git",
|
"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"}]
|
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
|
||||||
setup_mirrors(
|
setup_mirrors(
|
||||||
@@ -96,6 +88,8 @@ class TestMirrorSetupCmd(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(m_probe.call_count, 2)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -6,15 +5,10 @@ from pkgmgr.actions.publish.git_tags import head_semver_tags
|
|||||||
|
|
||||||
|
|
||||||
class TestHeadSemverTags(unittest.TestCase):
|
class TestHeadSemverTags(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.publish.git_tags.run_git")
|
@patch("pkgmgr.actions.publish.git_tags.get_tags_at_ref", return_value=[])
|
||||||
def test_no_tags(self, mock_run_git):
|
def test_no_tags(self, _mock_get_tags_at_ref) -> None:
|
||||||
mock_run_git.return_value = ""
|
|
||||||
self.assertEqual(head_semver_tags(), [])
|
self.assertEqual(head_semver_tags(), [])
|
||||||
|
|
||||||
@patch("pkgmgr.actions.publish.git_tags.run_git")
|
@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_run_git):
|
def test_filters_and_sorts_semver(self, _mock_get_tags_at_ref) -> None:
|
||||||
mock_run_git.return_value = "v1.0.0\nv2.0.0\nfoo\n"
|
self.assertEqual(head_semver_tags(), ["v1.0.0", "v1.2.0", "v2.0.0"])
|
||||||
self.assertEqual(
|
|
||||||
head_semver_tags(),
|
|
||||||
["v1.0.0", "v2.0.0"],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,44 +4,28 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pkgmgr.actions.changelog import generate_changelog
|
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
|
from pkgmgr.cli.commands.changelog import _find_previous_and_current_tag
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateChangelog(unittest.TestCase):
|
class TestGenerateChangelog(unittest.TestCase):
|
||||||
@patch("pkgmgr.actions.changelog.run_git")
|
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||||
def test_generate_changelog_default_range_no_merges(self, mock_run_git) -> None:
|
def test_generate_changelog_default_range_no_merges(self, mock_get_changelog) -> None:
|
||||||
"""
|
mock_get_changelog.return_value = "abc123 (HEAD -> main) Initial commit"
|
||||||
Default behaviour:
|
|
||||||
- to_ref = HEAD
|
|
||||||
- from_ref = None
|
|
||||||
- include_merges = False -> adds --no-merges
|
|
||||||
"""
|
|
||||||
mock_run_git.return_value = "abc123 (HEAD -> main) Initial commit"
|
|
||||||
|
|
||||||
output = generate_changelog(cwd="/repo")
|
output = generate_changelog(cwd="/repo")
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(output, "abc123 (HEAD -> main) Initial commit")
|
||||||
output,
|
mock_get_changelog.assert_called_once_with(
|
||||||
"abc123 (HEAD -> main) Initial commit",
|
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.
|
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||||
self.assertEqual(args[0][0], "log")
|
def test_generate_changelog_with_range_and_merges(self, mock_get_changelog) -> None:
|
||||||
self.assertIn("--pretty=format:%h %d %s", args[0])
|
mock_get_changelog.return_value = "def456 (tag: v1.1.0) Some change"
|
||||||
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"
|
|
||||||
|
|
||||||
output = generate_changelog(
|
output = generate_changelog(
|
||||||
cwd="/repo",
|
cwd="/repo",
|
||||||
@@ -51,24 +35,16 @@ class TestGenerateChangelog(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(output, "def456 (tag: v1.1.0) Some change")
|
self.assertEqual(output, "def456 (tag: v1.1.0) Some change")
|
||||||
mock_run_git.assert_called_once()
|
mock_get_changelog.assert_called_once_with(
|
||||||
args, kwargs = mock_run_git.call_args
|
cwd="/repo",
|
||||||
|
from_ref="v1.0.0",
|
||||||
|
to_ref="v1.1.0",
|
||||||
|
include_merges=True,
|
||||||
|
)
|
||||||
|
|
||||||
cmd = args[0]
|
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||||
self.assertEqual(cmd[0], "log")
|
def test_generate_changelog_giterror_returns_error_message(self, mock_get_changelog) -> None:
|
||||||
self.assertIn("--pretty=format:%h %d %s", cmd)
|
mock_get_changelog.side_effect = GitChangelogQueryError("simulated git failure")
|
||||||
# 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")
|
|
||||||
|
|
||||||
result = generate_changelog(cwd="/repo", from_ref="v0.1.0", to_ref="v0.2.0")
|
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("simulated git failure", result)
|
||||||
self.assertIn("v0.1.0..v0.2.0", result)
|
self.assertIn("v0.1.0..v0.2.0", result)
|
||||||
|
|
||||||
@patch("pkgmgr.actions.changelog.run_git")
|
@patch("pkgmgr.actions.changelog.get_changelog")
|
||||||
def test_generate_changelog_empty_output_returns_info(self, mock_run_git) -> None:
|
def test_generate_changelog_empty_output_returns_info(self, mock_get_changelog) -> None:
|
||||||
"""
|
mock_get_changelog.return_value = " \n "
|
||||||
Empty git log output -> informational message instead of empty string.
|
|
||||||
"""
|
|
||||||
mock_run_git.return_value = " \n "
|
|
||||||
|
|
||||||
result = generate_changelog(cwd="/repo", from_ref=None, to_ref="HEAD")
|
result = generate_changelog(cwd="/repo", from_ref=None, to_ref="HEAD")
|
||||||
|
|
||||||
@@ -90,49 +63,38 @@ class TestGenerateChangelog(unittest.TestCase):
|
|||||||
|
|
||||||
class TestFindPreviousAndCurrentTag(unittest.TestCase):
|
class TestFindPreviousAndCurrentTag(unittest.TestCase):
|
||||||
def test_no_semver_tags_returns_none_none(self) -> None:
|
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)
|
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||||
|
|
||||||
self.assertIsNone(prev_tag)
|
self.assertIsNone(prev_tag)
|
||||||
self.assertIsNone(cur_tag)
|
self.assertIsNone(cur_tag)
|
||||||
|
|
||||||
def test_latest_tags_when_no_target_given(self) -> None:
|
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"]
|
tags = ["v1.0.0", "v1.2.0", "v1.1.0", "not-a-tag"]
|
||||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||||
|
|
||||||
self.assertEqual(prev_tag, "v1.1.0")
|
self.assertEqual(prev_tag, "v1.1.0")
|
||||||
self.assertEqual(cur_tag, "v1.2.0")
|
self.assertEqual(cur_tag, "v1.2.0")
|
||||||
|
|
||||||
def test_single_semver_tag_returns_none_and_that_tag(self) -> None:
|
def test_single_semver_tag_returns_none_and_that_tag(self) -> None:
|
||||||
tags = ["v0.1.0"]
|
tags = ["v0.1.0"]
|
||||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||||
|
|
||||||
self.assertIsNone(prev_tag)
|
self.assertIsNone(prev_tag)
|
||||||
self.assertEqual(cur_tag, "v0.1.0")
|
self.assertEqual(cur_tag, "v0.1.0")
|
||||||
|
|
||||||
def test_with_target_tag_in_the_middle(self) -> None:
|
def test_with_target_tag_in_the_middle(self) -> None:
|
||||||
tags = ["v1.0.0", "v1.1.0", "v1.2.0"]
|
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")
|
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.1.0")
|
||||||
|
|
||||||
self.assertEqual(prev_tag, "v1.0.0")
|
self.assertEqual(prev_tag, "v1.0.0")
|
||||||
self.assertEqual(cur_tag, "v1.1.0")
|
self.assertEqual(cur_tag, "v1.1.0")
|
||||||
|
|
||||||
def test_with_target_tag_first_has_no_previous(self) -> None:
|
def test_with_target_tag_first_has_no_previous(self) -> None:
|
||||||
tags = ["v1.0.0", "v1.1.0"]
|
tags = ["v1.0.0", "v1.1.0"]
|
||||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.0.0")
|
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.0.0")
|
||||||
|
|
||||||
self.assertIsNone(prev_tag)
|
self.assertIsNone(prev_tag)
|
||||||
self.assertEqual(cur_tag, "v1.0.0")
|
self.assertEqual(cur_tag, "v1.0.0")
|
||||||
|
|
||||||
def test_unknown_target_tag_returns_none_none(self) -> None:
|
def test_unknown_target_tag_returns_none_none(self) -> None:
|
||||||
tags = ["v1.0.0", "v1.1.0"]
|
tags = ["v1.0.0", "v1.1.0"]
|
||||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v2.0.0")
|
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v2.0.0")
|
||||||
|
|
||||||
self.assertIsNone(prev_tag)
|
self.assertIsNone(prev_tag)
|
||||||
self.assertIsNone(cur_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
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pkgmgr.core.git import (
|
from pkgmgr.core.git.errors import GitError
|
||||||
GitError,
|
from pkgmgr.core.git.run import run
|
||||||
run_git,
|
from pkgmgr.core.git.queries import get_tags, get_head_commit, get_current_branch
|
||||||
get_tags,
|
|
||||||
get_head_commit,
|
|
||||||
get_current_branch,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitUtils(unittest.TestCase):
|
class TestGitRun(unittest.TestCase):
|
||||||
@patch("pkgmgr.core.git.subprocess.run")
|
@patch("pkgmgr.core.git.run.subprocess.run")
|
||||||
def test_run_git_success(self, mock_run):
|
def test_run_success(self, mock_run):
|
||||||
mock_run.return_value = SimpleNamespace(
|
mock_run.return_value.stdout = "ok\n"
|
||||||
stdout="ok\n",
|
mock_run.return_value.stderr = ""
|
||||||
stderr="",
|
mock_run.return_value.returncode = 0
|
||||||
returncode=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
output = run_git(["status"], cwd="/tmp/repo")
|
output = run(["status"], cwd="/tmp/repo")
|
||||||
|
|
||||||
self.assertEqual(output, "ok")
|
self.assertEqual(output, "ok")
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
# basic sanity: command prefix should be 'git'
|
|
||||||
args, kwargs = mock_run.call_args
|
args, kwargs = mock_run.call_args
|
||||||
self.assertEqual(args[0][0], "git")
|
self.assertEqual(args[0][0], "git")
|
||||||
self.assertEqual(kwargs.get("cwd"), "/tmp/repo")
|
self.assertEqual(kwargs.get("cwd"), "/tmp/repo")
|
||||||
|
|
||||||
@patch("pkgmgr.core.git.subprocess.run")
|
@patch("pkgmgr.core.git.run.subprocess.run")
|
||||||
def test_run_git_failure_raises_giterror(self, mock_run):
|
def test_run_failure_raises_giterror(self, mock_run):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
mock_run.side_effect = subprocess.CalledProcessError(
|
mock_run.side_effect = subprocess.CalledProcessError(
|
||||||
returncode=1,
|
returncode=1,
|
||||||
cmd=["git", "status"],
|
cmd=["git", "status"],
|
||||||
@@ -43,7 +36,7 @@ class TestGitUtils(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with self.assertRaises(GitError) as ctx:
|
with self.assertRaises(GitError) as ctx:
|
||||||
run_git(["status"], cwd="/tmp/repo")
|
run(["status"], cwd="/tmp/repo")
|
||||||
|
|
||||||
msg = str(ctx.exception)
|
msg = str(ctx.exception)
|
||||||
self.assertIn("Git command failed", msg)
|
self.assertIn("Git command failed", msg)
|
||||||
@@ -51,71 +44,41 @@ class TestGitUtils(unittest.TestCase):
|
|||||||
self.assertIn("bad", msg)
|
self.assertIn("bad", msg)
|
||||||
self.assertIn("error", 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")
|
tags = get_tags(cwd="/tmp/repo")
|
||||||
self.assertEqual(tags, [])
|
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):
|
def test_get_tags_non_empty(self, mock_run):
|
||||||
mock_run.return_value = SimpleNamespace(
|
mock_run.return_value = "v1.0.0\nv1.1.0\n"
|
||||||
stdout="v1.0.0\nv1.1.0\n",
|
|
||||||
stderr="",
|
|
||||||
returncode=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
tags = get_tags(cwd="/tmp/repo")
|
tags = get_tags(cwd="/tmp/repo")
|
||||||
self.assertEqual(tags, ["v1.0.0", "v1.1.0"])
|
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):
|
def test_get_head_commit_success(self, mock_run):
|
||||||
mock_run.return_value = SimpleNamespace(
|
mock_run.return_value = "abc123"
|
||||||
stdout="abc123\n",
|
|
||||||
stderr="",
|
|
||||||
returncode=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
commit = get_head_commit(cwd="/tmp/repo")
|
commit = get_head_commit(cwd="/tmp/repo")
|
||||||
self.assertEqual(commit, "abc123")
|
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):
|
def test_get_head_commit_failure_returns_none(self, mock_run):
|
||||||
mock_run.side_effect = subprocess.CalledProcessError(
|
mock_run.side_effect = GitError("fail")
|
||||||
returncode=1,
|
|
||||||
cmd=["git", "rev-parse", "HEAD"],
|
|
||||||
output="",
|
|
||||||
stderr="error\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
commit = get_head_commit(cwd="/tmp/repo")
|
commit = get_head_commit(cwd="/tmp/repo")
|
||||||
self.assertIsNone(commit)
|
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):
|
def test_get_current_branch_success(self, mock_run):
|
||||||
mock_run.return_value = SimpleNamespace(
|
mock_run.return_value = "main"
|
||||||
stdout="main\n",
|
|
||||||
stderr="",
|
|
||||||
returncode=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
branch = get_current_branch(cwd="/tmp/repo")
|
branch = get_current_branch(cwd="/tmp/repo")
|
||||||
self.assertEqual(branch, "main")
|
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):
|
def test_get_current_branch_failure_returns_none(self, mock_run):
|
||||||
mock_run.side_effect = subprocess.CalledProcessError(
|
mock_run.side_effect = GitError("fail")
|
||||||
returncode=1,
|
|
||||||
cmd=["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
||||||
output="",
|
|
||||||
stderr="error\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
branch = get_current_branch(cwd="/tmp/repo")
|
branch = get_current_branch(cwd="/tmp/repo")
|
||||||
self.assertIsNone(branch)
|
self.assertIsNone(branch)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user