refactor(git): split git helpers into run/commands/queries and update branch, mirror and changelog actions
https://chatgpt.com/share/69411b4a-fcf8-800f-843d-61c913f388eb
This commit is contained in:
@@ -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)
|
||||||
try:
|
|
||||||
run_git(["branch", "-d", name], cwd=cwd)
|
|
||||||
except GitError as exc:
|
|
||||||
raise RuntimeError(f"Failed to delete local branch {name!r}: {exc}") from exc
|
|
||||||
|
|
||||||
# Remote delete
|
# Remote delete (special-case message)
|
||||||
try:
|
try:
|
||||||
run_git(["push", "origin", "--delete", name], cwd=cwd)
|
delete_remote_branch("origin", name, cwd=cwd)
|
||||||
except GitError as exc:
|
except GitDeleteRemoteBranchError 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}"
|
|
||||||
if preview:
|
|
||||||
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
|
|
||||||
else:
|
|
||||||
run_command(cmd, cwd=repo_dir, preview=False)
|
|
||||||
|
|
||||||
_set_origin_fetch_and_push(repo_dir, primary, preview)
|
|
||||||
|
|
||||||
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
|
|
||||||
|
|
||||||
|
|
||||||
def is_remote_reachable(url: str, cwd: Optional[str] = None) -> bool:
|
|
||||||
try:
|
try:
|
||||||
run_git(["ls-remote", "--exit-code", url], cwd=cwd or os.getcwd())
|
add_remote("origin", primary, cwd=repo_dir, preview=preview)
|
||||||
return True
|
except GitAddRemoteError as exc:
|
||||||
except GitError:
|
print(f"[WARN] Failed to add origin remote: {exc}")
|
||||||
return False
|
return # without origin we cannot reliably proceed
|
||||||
|
|
||||||
|
# 2) Ensure origin fetch+push URLs are correct (ALWAYS, even if origin already existed)
|
||||||
|
try:
|
||||||
|
_set_origin_fetch_and_push(repo_dir, primary, preview)
|
||||||
|
except GitSetRemoteUrlError as exc:
|
||||||
|
# Do not abort: still try to add additional push URLs
|
||||||
|
print(f"[WARN] Failed to set origin URLs: {exc}")
|
||||||
|
|
||||||
|
# 3) Ensure additional push URLs for mirrors
|
||||||
|
try:
|
||||||
|
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
|
||||||
|
except GitAddRemotePushUrlError as exc:
|
||||||
|
print(f"[WARN] Failed to add additional push URLs: {exc}")
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# src/pkgmgr/actions/mirror/remote_check.py
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from pkgmgr.core.git import GitError, run_git
|
|
||||||
|
|
||||||
|
|
||||||
def probe_mirror(url: str, repo_dir: str) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Probe a remote mirror URL using `git ls-remote`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(True, "") on success,
|
|
||||||
(False, error_message) on failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
run_git(["ls-remote", url], cwd=repo_dir)
|
|
||||||
return True, ""
|
|
||||||
except GitError as exc:
|
|
||||||
return False, str(exc)
|
|
||||||
@@ -4,7 +4,7 @@ from typing import List
|
|||||||
|
|
||||||
from .context import build_context
|
from .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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
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()
|
||||||
Reference in New Issue
Block a user