Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
0119af330f gpt-5.2: fix tests and imports after git queries split
Some checks failed
Mark stable commit / test-unit (push) Has been cancelled
Mark stable commit / test-integration (push) Has been cancelled
Mark stable commit / test-env-virtual (push) Has been cancelled
Mark stable commit / test-env-nix (push) Has been cancelled
Mark stable commit / test-e2e (push) Has been cancelled
Mark stable commit / test-virgin-user (push) Has been cancelled
Mark stable commit / test-virgin-root (push) Has been cancelled
Mark stable commit / lint-shell (push) Has been cancelled
Mark stable commit / lint-python (push) Has been cancelled
Mark stable commit / mark-stable (push) Has been cancelled
https://chatgpt.com/share/694135eb-10a8-800f-8b12-968612f605c7

Gemini
https://ai.studio/apps/drive/1QO9MaEklm2zZMDZ6XPP0LStuAooXs1NO
2025-12-16 11:35:10 +01:00
Kevin Veen-Birkenbach
e117115b7f gpt-5.2 ChatGPT: adapt tests to new core.git commands/queries split
- Update mirror integration tests to use probe_remote_reachable
- Refactor branch action tests to mock git command helpers instead of run_git
- Align changelog tests with get_changelog query API
- Update git core tests to cover run() and query helpers
- Remove legacy run_git assumptions from tests

https://chatgpt.com/share/69412008-9e8c-800f-9ac9-90f390d55380

**Validated by Google's model.**

**Summary:**
The test modifications have been correctly implemented to cover the Git refactoring changes:

1.  **Granular Mocking:** The tests have shifted from mocking the monolithic `run_git` or `subprocess` to mocking the new, specific wrapper functions (e.g., `pkgmgr.core.git.commands.fetch`, `pkgmgr.core.git.queries.probe_remote_reachable`). This accurately reflects the architectural change in the source code where business logic now relies on these granular imports.
2.  **Structural Alignment:** The test directory structure was updated (e.g., moving tests to `tests/unit/pkgmgr/core/git/queries/`) to match the new source code organization, ensuring logical consistency.
3.  **Exception Handling:** The tests were updated to verify specific exception types (like `GitDeleteRemoteBranchError`) rather than generic errors, ensuring the improved error granularity is correctly handled by the CLI.
4.  **Integration Safety:** The integration tests in `test_mirror_commands.py` were correctly updated to patch the new query paths, ensuring that network operations remain disabled during testing.

The test changes are consistent with the refactor and provide complete coverage for the new code structure.
https://aistudio.google.com/app/prompts?state=%7B%22ids%22:%5B%2214Br1JG1hxuntmoRzuvme3GKUvQ0heqRn%22%5D,%22action%22:%22open%22,%22userId%22:%22109171005420801378245%22,%22resourceKeys%22:%7B%7D%7D&usp=sharing
2025-12-16 10:01:30 +01:00
Kevin Veen-Birkenbach
755b78fcb7 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
2025-12-16 09:41:35 +01:00
56 changed files with 1266 additions and 752 deletions

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
# expose subpackages for patch() / resolve_name() friendliness
from . import release as release # noqa: F401
__all__ = ["release"]

View File

@@ -1,7 +1,21 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch
from .utils import _resolve_base_branch
from pkgmgr.core.git.errors import GitError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.git.commands import (
GitDeleteRemoteBranchError,
checkout,
delete_local_branch,
delete_remote_branch,
fetch,
merge_no_ff,
pull,
push,
)
from pkgmgr.core.git.queries import resolve_base_branch
def close_branch(
@@ -14,7 +28,6 @@ def close_branch(
"""
Merge a feature branch into the base branch and delete it afterwards.
"""
# Determine branch name
if not name:
try:
@@ -25,7 +38,7 @@ def close_branch(
if not name:
raise RuntimeError("Branch name must not be empty.")
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
target_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
raise RuntimeError(
@@ -42,58 +55,20 @@ def close_branch(
print("Aborted closing branch.")
return
# Fetch
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before closing branch {name!r}: {exc}"
) from exc
# Execute workflow (commands raise specific GitError subclasses)
fetch("origin", cwd=cwd)
checkout(target_base, cwd=cwd)
pull("origin", target_base, cwd=cwd)
merge_no_ff(name, cwd=cwd)
push("origin", target_base, cwd=cwd)
# Checkout base
try:
run_git(["checkout", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {target_base!r}: {exc}"
) from exc
# Delete local branch (safe delete by default)
delete_local_branch(name, cwd=cwd, force=False)
# Pull latest
# Delete remote branch (special-case error message)
try:
run_git(["pull", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
) from exc
# Merge
try:
run_git(["merge", "--no-ff", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to merge branch {name!r} into {target_base!r}: {exc}"
) from exc
# Push result
try:
run_git(["push", "origin", target_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push base branch {target_base!r} after merge: {exc}"
) from exc
# Delete local
try:
run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to delete local branch {name!r}: {exc}"
) from exc
# Delete remote
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
delete_remote_branch("origin", name, cwd=cwd)
except GitDeleteRemoteBranchError as exc:
raise RuntimeError(
f"Branch {name!r} deleted locally, but remote deletion failed: {exc}"
) from exc

View File

@@ -1,7 +1,16 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch
from .utils import _resolve_base_branch
from pkgmgr.core.git.errors import GitError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.git.commands import (
GitDeleteRemoteBranchError,
delete_local_branch,
delete_remote_branch,
)
from pkgmgr.core.git.queries import resolve_base_branch
def drop_branch(
@@ -14,7 +23,6 @@ def drop_branch(
"""
Delete a branch locally and remotely without merging.
"""
if not name:
try:
name = get_current_branch(cwd=cwd)
@@ -24,7 +32,7 @@ def drop_branch(
if not name:
raise RuntimeError("Branch name must not be empty.")
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
target_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base:
raise RuntimeError(
@@ -40,16 +48,12 @@ def drop_branch(
print("Aborted dropping branch.")
return
# Local delete
delete_local_branch(name, cwd=cwd, force=False)
# Remote delete (special-case message)
try:
run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(f"Failed to delete local branch {name!r}: {exc}") from exc
# Remote delete
try:
run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc:
delete_remote_branch("origin", name, cwd=cwd)
except GitDeleteRemoteBranchError as exc:
raise RuntimeError(
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
) from exc

View File

@@ -1,7 +1,15 @@
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError
from .utils import _resolve_base_branch
from pkgmgr.core.git.commands import (
checkout,
create_branch,
fetch,
pull,
push_upstream,
)
from pkgmgr.core.git.queries import resolve_base_branch
def open_branch(
@@ -13,7 +21,6 @@ def open_branch(
"""
Create and push a new feature branch on top of a base branch.
"""
# Request name interactively if not provided
if not name:
name = input("Enter new branch name: ").strip()
@@ -21,44 +28,13 @@ def open_branch(
if not name:
raise RuntimeError("Branch name must not be empty.")
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
resolved_base = resolve_base_branch(base_branch, fallback_base, cwd=cwd)
# 1) Fetch from origin
try:
run_git(["fetch", "origin"], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to fetch from origin before creating branch {name!r}: {exc}"
) from exc
# Workflow (commands raise specific GitError subclasses)
fetch("origin", cwd=cwd)
checkout(resolved_base, cwd=cwd)
pull("origin", resolved_base, cwd=cwd)
# 2) Checkout base branch
try:
run_git(["checkout", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to checkout base branch {resolved_base!r}: {exc}"
) from exc
# 3) Pull latest changes
try:
run_git(["pull", "origin", resolved_base], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
) from exc
# 4) Create new branch
try:
run_git(["checkout", "-b", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
) from exc
# 5) Push new branch
try:
run_git(["push", "-u", "origin", name], cwd=cwd)
except GitError as exc:
raise RuntimeError(
f"Failed to push new branch {name!r} to origin: {exc}"
) from exc
# Create new branch from resolved base and push it with upstream tracking
create_branch(name, resolved_base, cwd=cwd)
push_upstream("origin", name, cwd=cwd)

View File

@@ -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."
)

View File

@@ -3,17 +3,16 @@
"""
Helpers to generate changelog information from Git history.
This module provides a small abstraction around `git log` so that
CLI commands can request a changelog between two refs (tags, branches,
commits) without dealing with raw subprocess calls.
"""
from __future__ import annotations
from typing import Optional
from pkgmgr.core.git import run_git, GitError
from pkgmgr.core.git.queries import (
get_changelog,
GitChangelogQueryError,
)
def generate_changelog(
@@ -25,48 +24,20 @@ def generate_changelog(
"""
Generate a plain-text changelog between two Git refs.
Parameters
----------
cwd:
Repository directory in which to run Git commands.
from_ref:
Optional starting reference (exclusive). If provided together
with `to_ref`, the range `from_ref..to_ref` is used.
If only `from_ref` is given, the range `from_ref..HEAD` is used.
to_ref:
Optional end reference (inclusive). If omitted, `HEAD` is used.
include_merges:
If False (default), merge commits are filtered out.
Returns
-------
str
The output of `git log` formatted as a simple text changelog.
If no commits are found or Git fails, an explanatory message
is returned instead of raising.
Returns a human-readable message instead of raising.
"""
# Determine the revision range
if to_ref is None:
to_ref = "HEAD"
if from_ref:
rev_range = f"{from_ref}..{to_ref}"
else:
rev_range = to_ref
# Use a custom pretty format that includes tags/refs (%d)
cmd = [
"log",
"--pretty=format:%h %d %s",
]
if not include_merges:
cmd.append("--no-merges")
cmd.append(rev_range)
rev_range = f"{from_ref}..{to_ref}" if from_ref else to_ref
try:
output = run_git(cmd, cwd=cwd)
except GitError as exc:
# Do not raise to the CLI, return a human-readable error instead.
output = get_changelog(
cwd=cwd,
from_ref=from_ref,
to_ref=to_ref,
include_merges=include_merges,
)
except GitChangelogQueryError as exc:
return (
f"[ERROR] Failed to generate changelog in {cwd!r} "
f"for range {rev_range!r}:\n{exc}"

View File

@@ -1,10 +1,21 @@
from __future__ import annotations
import os
from typing import List, Optional, Set
from typing import Optional, Set
from pkgmgr.core.command.run import run_command
from pkgmgr.core.git import GitError, run_git
from pkgmgr.core.git.errors import GitError
from pkgmgr.core.git.commands import (
GitAddRemoteError,
GitAddRemotePushUrlError,
GitSetRemoteUrlError,
add_remote,
add_remote_push_url,
set_remote_url,
)
from pkgmgr.core.git.queries import (
get_remote_push_urls,
list_remotes,
)
from .types import MirrorMap, RepoMirrorContext, Repository
@@ -48,29 +59,20 @@ def determine_primary_remote_url(
return build_default_ssh_url(repo)
def _safe_git_output(args: List[str], cwd: str) -> Optional[str]:
try:
return run_git(args, cwd=cwd)
except GitError:
return None
def has_origin_remote(repo_dir: str) -> bool:
out = _safe_git_output(["remote"], cwd=repo_dir)
return bool(out and "origin" in out.split())
try:
return "origin" in list_remotes(cwd=repo_dir)
except GitError:
return False
def _set_origin_fetch_and_push(repo_dir: str, url: str, preview: bool) -> None:
fetch = f"git remote set-url origin {url}"
push = f"git remote set-url --push origin {url}"
if preview:
print(f"[PREVIEW] Would run in {repo_dir!r}: {fetch}")
print(f"[PREVIEW] Would run in {repo_dir!r}: {push}")
return
run_command(fetch, cwd=repo_dir, preview=False)
run_command(push, cwd=repo_dir, preview=False)
"""
Ensure origin has fetch URL and push URL set to the primary URL.
Preview is handled by the underlying git runner.
"""
set_remote_url("origin", url, cwd=repo_dir, push=False, preview=preview)
set_remote_url("origin", url, cwd=repo_dir, push=True, preview=preview)
def _ensure_additional_push_urls(
@@ -79,22 +81,21 @@ def _ensure_additional_push_urls(
primary: str,
preview: bool,
) -> None:
"""
Ensure all mirror URLs (except primary) are configured as additional push URLs for origin.
Preview is handled by the underlying git runner.
"""
desired: Set[str] = {u for u in mirrors.values() if u and u != primary}
if not desired:
return
out = _safe_git_output(
["remote", "get-url", "--push", "--all", "origin"],
cwd=repo_dir,
)
existing = set(out.splitlines()) if out else set()
try:
existing = get_remote_push_urls("origin", cwd=repo_dir)
except GitError:
existing = set()
for url in sorted(desired - existing):
cmd = f"git remote set-url --add --push origin {url}"
if preview:
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
else:
run_command(cmd, cwd=repo_dir, preview=False)
add_remote_push_url("origin", url, cwd=repo_dir, preview=preview)
def ensure_origin_remote(
@@ -113,21 +114,23 @@ def ensure_origin_remote(
print("[WARN] No primary mirror URL could be determined.")
return
# 1) Ensure origin exists
if not has_origin_remote(repo_dir):
cmd = f"git remote add origin {primary}"
if preview:
print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}")
else:
run_command(cmd, cwd=repo_dir, preview=False)
try:
add_remote("origin", primary, cwd=repo_dir, preview=preview)
except GitAddRemoteError as exc:
print(f"[WARN] Failed to add origin remote: {exc}")
return # without origin we cannot reliably proceed
_set_origin_fetch_and_push(repo_dir, primary, preview)
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
def is_remote_reachable(url: str, cwd: Optional[str] = None) -> bool:
# 2) Ensure origin fetch+push URLs are correct (ALWAYS, even if origin already existed)
try:
run_git(["ls-remote", "--exit-code", url], cwd=cwd or os.getcwd())
return True
except GitError:
return False
_set_origin_fetch_and_push(repo_dir, primary, preview)
except GitSetRemoteUrlError as exc:
# Do not abort: still try to add additional push URLs
print(f"[WARN] Failed to set origin URLs: {exc}")
# 3) Ensure additional push URLs for mirrors
try:
_ensure_additional_push_urls(repo_dir, ctx.resolved_mirrors, primary, preview)
except GitAddRemotePushUrlError as exc:
print(f"[WARN] Failed to add additional push URLs: {exc}")

View File

@@ -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)

View File

@@ -4,7 +4,7 @@ from typing import List
from .context import build_context
from .git_remote import ensure_origin_remote, determine_primary_remote_url
from .remote_check import probe_mirror
from pkgmgr.core.git.queries import probe_remote_reachable
from .remote_provision import ensure_remote_repository
from .types import Repository
@@ -52,19 +52,14 @@ def _setup_remote_mirrors_for_repo(
primary = determine_primary_remote_url(repo, ctx)
if not primary:
return
ok, msg = probe_mirror(primary, ctx.repo_dir)
ok = probe_remote_reachable(primary, cwd=ctx.repo_dir)
print("[OK]" if ok else "[WARN]", primary)
if msg:
print(msg)
print()
return
for name, url in ctx.resolved_mirrors.items():
ok, msg = probe_mirror(url, ctx.repo_dir)
ok = probe_remote_reachable(url, cwd=ctx.repo_dir)
print(f"[OK] {name}: {url}" if ok else f"[WARN] {name}: {url}")
if msg:
print(msg)
print()

View File

@@ -1,17 +1,10 @@
from __future__ import annotations
from pkgmgr.core.git import run_git
from pkgmgr.core.git.queries import get_tags_at_ref
from pkgmgr.core.version.semver import SemVer, is_semver_tag
def head_semver_tags(cwd: str = ".") -> list[str]:
out = run_git(["tag", "--points-at", "HEAD"], cwd=cwd)
if not out:
return []
tags = [t.strip() for t in out.splitlines() if t.strip()]
tags = get_tags_at_ref("HEAD", cwd=cwd)
tags = [t for t in tags if is_semver_tag(t) and t.startswith("v")]
if not tags:
return []
return sorted(tags, key=SemVer.parse)

View File

@@ -7,7 +7,7 @@ Version discovery and bumping helpers for the release workflow.
from __future__ import annotations
from pkgmgr.core.git import get_tags
from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import (
SemVer,
find_latest_version,

View File

@@ -1,4 +1,3 @@
# src/pkgmgr/actions/release/workflow.py
from __future__ import annotations
import os
@@ -6,7 +5,8 @@ import sys
from typing import Optional
from pkgmgr.actions.branch import close_branch
from pkgmgr.core.git import get_current_branch, GitError
from pkgmgr.core.git import GitError
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.repository.paths import resolve_repo_paths
from .files import (

View File

@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.git import get_tags
from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import extract_semver_from_tags
from pkgmgr.actions.changelog import generate_changelog

View File

@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
from pkgmgr.cli.context import CLIContext
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.git import get_tags
from pkgmgr.core.git.queries import get_tags
from pkgmgr.core.version.semver import SemVer, find_latest_version
from pkgmgr.core.version.installed import (
get_installed_python_version,

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
"""
Lightweight helper functions around Git commands.
@@ -9,84 +8,10 @@ logic (release, version, changelog) does not have to deal with the
details of subprocess handling.
"""
from __future__ import annotations
from .errors import GitError
from .run import run
import subprocess
from typing import List, Optional
class GitError(RuntimeError):
"""Raised when a Git command fails in an unexpected way."""
def run_git(args: List[str], cwd: str = ".") -> str:
"""
Run a Git command and return its stdout as a stripped string.
Raises GitError if the command fails.
"""
cmd = ["git"] + args
try:
result = subprocess.run(
cmd,
cwd=cwd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as exc:
raise GitError(
f"Git command failed in {cwd!r}: {' '.join(cmd)}\n"
f"Exit code: {exc.returncode}\n"
f"STDOUT:\n{exc.stdout}\n"
f"STDERR:\n{exc.stderr}"
) from exc
return result.stdout.strip()
def get_tags(cwd: str = ".") -> List[str]:
"""
Return a list of all tags in the repository in `cwd`.
If there are no tags, an empty list is returned.
"""
try:
output = run_git(["tag"], cwd=cwd)
except GitError as exc:
# If the repo has no tags or is not a git repo, surface a clear error.
# You can decide later if you want to treat this differently.
if "not a git repository" in str(exc):
raise
# No tags: stdout may just be empty; treat this as empty list.
return []
if not output:
return []
return [line.strip() for line in output.splitlines() if line.strip()]
def get_head_commit(cwd: str = ".") -> Optional[str]:
"""
Return the current HEAD commit hash, or None if it cannot be determined.
"""
try:
output = run_git(["rev-parse", "HEAD"], cwd=cwd)
except GitError:
return None
return output or None
def get_current_branch(cwd: str = ".") -> Optional[str]:
"""
Return the current branch name, or None if it cannot be determined.
Note: In detached HEAD state this will return 'HEAD'.
"""
try:
output = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
except GitError:
return None
return output or None
__all__ = [
"GitError",
"run"
]

View 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",
]

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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",
]

View 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

View 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

View 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

View 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()}

View 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()]

View 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()]

View 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()]

View 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

View 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

View 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()

View File

@@ -27,18 +27,10 @@ from unittest.mock import MagicMock, PropertyMock, patch
class TestIntegrationMirrorCommands(unittest.TestCase):
"""
Integration tests for `pkgmgr mirror` commands.
"""
"""Integration tests for `pkgmgr mirror` commands."""
def _run_pkgmgr(self, args: List[str], extra_env: Optional[Dict[str, str]] = None) -> str:
"""
Execute pkgmgr with the given arguments and return captured output.
- Treat SystemExit(0) or SystemExit(None) as success.
- Any other exit code is considered a test failure.
- Mirror commands are patched to avoid network/destructive operations.
"""
"""Execute pkgmgr with the given arguments and return captured output."""
original_argv = list(sys.argv)
original_env = dict(os.environ)
buffer = io.StringIO()
@@ -64,8 +56,7 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
try:
importlib.import_module(module_name)
except ModuleNotFoundError:
# If the module truly doesn't exist, create=True may still allow patching
# in some cases, but dotted resolution can still fail. Best-effort.
# Best-effort: allow patch(create=True) even if a symbol moved.
pass
return patch(target, create=True, **kwargs)
@@ -95,10 +86,9 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.build_context", return_value=dummy_ctx))
stack.enter_context(_p("pkgmgr.actions.mirror.remote_provision.build_context", return_value=dummy_ctx))
# Deterministic remote probing (covers setup + likely check implementations)
stack.enter_context(_p("pkgmgr.actions.mirror.remote_check.probe_mirror", return_value=(True, "")))
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.probe_mirror", return_value=(True, "")))
stack.enter_context(_p("pkgmgr.actions.mirror.git_remote.is_remote_reachable", return_value=True))
# Deterministic remote probing (new refactor: probe_remote_reachable)
stack.enter_context(_p("pkgmgr.core.git.queries.probe_remote_reachable", return_value=True))
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable", return_value=True))
# setup_cmd imports ensure_origin_remote directly:
stack.enter_context(_p("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote", return_value=None))
@@ -113,9 +103,6 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
)
)
# Extra safety: if anything calls remote_check.run_git directly, make it inert
stack.enter_context(_p("pkgmgr.actions.mirror.remote_check.run_git", return_value="dummy"))
with redirect_stdout(buffer), redirect_stderr(buffer):
try:
runpy.run_module("pkgmgr", run_name="__main__")
@@ -134,10 +121,6 @@ class TestIntegrationMirrorCommands(unittest.TestCase):
os.environ.clear()
os.environ.update(original_env)
# ------------------------------------------------------------
# Tests
# ------------------------------------------------------------
def test_mirror_help(self) -> None:
output = self._run_pkgmgr(["mirror", "--help"])
self.assertIn("usage:", output.lower())

View File

@@ -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()

View File

@@ -2,54 +2,129 @@ import unittest
from unittest.mock import patch
from pkgmgr.actions.branch.close_branch import close_branch
from pkgmgr.core.git import GitError
from pkgmgr.core.git.errors import GitError
from pkgmgr.core.git.commands import GitDeleteRemoteBranchError
class TestCloseBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.close_branch.input", return_value="y")
@patch("builtins.input", return_value="y")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.run_git")
def test_close_branch_happy_path(self, run_git, resolve, current, input_mock):
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.fetch")
@patch("pkgmgr.actions.branch.close_branch.checkout")
@patch("pkgmgr.actions.branch.close_branch.pull")
@patch("pkgmgr.actions.branch.close_branch.merge_no_ff")
@patch("pkgmgr.actions.branch.close_branch.push")
@patch("pkgmgr.actions.branch.close_branch.delete_local_branch")
@patch("pkgmgr.actions.branch.close_branch.delete_remote_branch")
def test_close_branch_happy_path(
self,
delete_remote_branch,
delete_local_branch,
push,
merge_no_ff,
pull,
checkout,
fetch,
_resolve,
_current,
_input_mock,
) -> None:
close_branch(None, cwd=".")
expected = [
(["fetch", "origin"],),
(["checkout", "main"],),
(["pull", "origin", "main"],),
(["merge", "--no-ff", "feature-x"],),
(["push", "origin", "main"],),
(["branch", "-d", "feature-x"],),
(["push", "origin", "--delete", "feature-x"],),
]
actual = [call.args for call in run_git.call_args_list]
self.assertEqual(actual, expected)
fetch.assert_called_once_with("origin", cwd=".")
checkout.assert_called_once_with("main", cwd=".")
pull.assert_called_once_with("origin", "main", cwd=".")
merge_no_ff.assert_called_once_with("feature-x", cwd=".")
push.assert_called_once_with("origin", "main", cwd=".")
delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False)
delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
def test_refuses_to_close_base_branch(self, resolve, current):
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
def test_refuses_to_close_base_branch(self, _resolve, _current) -> None:
with self.assertRaises(RuntimeError):
close_branch(None)
@patch("pkgmgr.actions.branch.close_branch.input", return_value="n")
@patch("builtins.input", return_value="n")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.run_git")
def test_close_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.fetch")
def test_close_branch_aborts_on_no(self, fetch, _resolve, _current, _input_mock) -> None:
close_branch(None, cwd=".")
run_git.assert_not_called()
fetch.assert_not_called()
@patch("builtins.input")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.run_git")
def test_close_branch_force_skips_prompt(self, run_git, resolve, current):
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.fetch")
@patch("pkgmgr.actions.branch.close_branch.checkout")
@patch("pkgmgr.actions.branch.close_branch.pull")
@patch("pkgmgr.actions.branch.close_branch.merge_no_ff")
@patch("pkgmgr.actions.branch.close_branch.push")
@patch("pkgmgr.actions.branch.close_branch.delete_local_branch")
@patch("pkgmgr.actions.branch.close_branch.delete_remote_branch")
def test_close_branch_force_skips_prompt(
self,
delete_remote_branch,
delete_local_branch,
push,
merge_no_ff,
pull,
checkout,
fetch,
_resolve,
_current,
input_mock,
) -> None:
close_branch(None, cwd=".", force=True)
self.assertGreater(len(run_git.call_args_list), 0)
# no interactive prompt when forced
input_mock.assert_not_called()
# workflow still runs (but is mocked)
fetch.assert_called_once_with("origin", cwd=".")
checkout.assert_called_once_with("main", cwd=".")
pull.assert_called_once_with("origin", "main", cwd=".")
merge_no_ff.assert_called_once_with("feature-x", cwd=".")
push.assert_called_once_with("origin", "main", cwd=".")
delete_local_branch.assert_called_once_with("feature-x", cwd=".", force=False)
delete_remote_branch.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", side_effect=GitError("fail"))
def test_close_branch_errors_if_cannot_detect_branch(self, current):
def test_close_branch_errors_if_cannot_detect_branch(self, _current) -> None:
with self.assertRaises(RuntimeError):
close_branch(None)
@patch("builtins.input", return_value="y")
@patch("pkgmgr.actions.branch.close_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.close_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.close_branch.fetch")
@patch("pkgmgr.actions.branch.close_branch.checkout")
@patch("pkgmgr.actions.branch.close_branch.pull")
@patch("pkgmgr.actions.branch.close_branch.merge_no_ff")
@patch("pkgmgr.actions.branch.close_branch.push")
@patch("pkgmgr.actions.branch.close_branch.delete_local_branch")
@patch(
"pkgmgr.actions.branch.close_branch.delete_remote_branch",
side_effect=GitDeleteRemoteBranchError("boom", cwd="."),
)
def test_close_branch_remote_delete_failure_is_wrapped(
self,
_delete_remote_branch,
_delete_local_branch,
_push,
_merge_no_ff,
_pull,
_checkout,
_fetch,
_resolve,
_current,
_input_mock,
) -> None:
with self.assertRaises(RuntimeError) as ctx:
close_branch(None, cwd=".")
self.assertIn("remote deletion failed", str(ctx.exception))
if __name__ == "__main__":
unittest.main()

View File

@@ -2,49 +2,79 @@ import unittest
from unittest.mock import patch
from pkgmgr.actions.branch.drop_branch import drop_branch
from pkgmgr.core.git import GitError
from pkgmgr.core.git.errors import GitError
from pkgmgr.core.git.commands import GitDeleteRemoteBranchError
class TestDropBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.drop_branch.input", return_value="y")
@patch("builtins.input", return_value="y")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.run_git")
def test_drop_branch_happy_path(self, run_git, resolve, current, input_mock):
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
@patch("pkgmgr.actions.branch.drop_branch.delete_remote_branch")
def test_drop_branch_happy_path(self, delete_remote, delete_local, _resolve, _current, _input_mock) -> None:
drop_branch(None, cwd=".")
expected = [
(["branch", "-d", "feature-x"],),
(["push", "origin", "--delete", "feature-x"],),
]
actual = [call.args for call in run_git.call_args_list]
self.assertEqual(actual, expected)
delete_local.assert_called_once_with("feature-x", cwd=".", force=False)
delete_remote.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
def test_refuses_to_drop_base_branch(self, resolve, current):
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
def test_refuses_to_drop_base_branch(self, _resolve, _current) -> None:
with self.assertRaises(RuntimeError):
drop_branch(None)
@patch("pkgmgr.actions.branch.drop_branch.input", return_value="n")
@patch("builtins.input", return_value="n")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.run_git")
def test_drop_branch_aborts_on_no(self, run_git, resolve, current, input_mock):
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
def test_drop_branch_aborts_on_no(self, delete_local, _resolve, _current, _input_mock) -> None:
drop_branch(None, cwd=".")
run_git.assert_not_called()
delete_local.assert_not_called()
@patch("builtins.input")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.run_git")
def test_drop_branch_force_skips_prompt(self, run_git, resolve, current):
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
@patch("pkgmgr.actions.branch.drop_branch.delete_remote_branch")
def test_drop_branch_force_skips_prompt(
self,
delete_remote,
delete_local,
_resolve,
_current,
input_mock,
) -> None:
drop_branch(None, cwd=".", force=True)
self.assertGreater(len(run_git.call_args_list), 0)
input_mock.assert_not_called()
delete_local.assert_called_once_with("feature-x", cwd=".", force=False)
delete_remote.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", side_effect=GitError("fail"))
def test_drop_branch_errors_if_no_branch_detected(self, current):
def test_drop_branch_errors_if_no_branch_detected(self, _current) -> None:
with self.assertRaises(RuntimeError):
drop_branch(None)
@patch("builtins.input", return_value="y")
@patch("pkgmgr.actions.branch.drop_branch.get_current_branch", return_value="feature-x")
@patch("pkgmgr.actions.branch.drop_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.drop_branch.delete_local_branch")
@patch(
"pkgmgr.actions.branch.drop_branch.delete_remote_branch",
side_effect=GitDeleteRemoteBranchError("boom", cwd="."),
)
def test_drop_branch_remote_delete_failure_is_wrapped(
self,
_delete_remote,
_delete_local,
_resolve,
_current,
_input_mock,
) -> None:
with self.assertRaises(RuntimeError) as ctx:
drop_branch(None, cwd=".")
self.assertIn("remote deletion failed", str(ctx.exception))
if __name__ == "__main__":
unittest.main()

View File

@@ -5,29 +5,55 @@ from pkgmgr.actions.branch.open_branch import open_branch
class TestOpenBranch(unittest.TestCase):
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.open_branch.run_git")
def test_open_branch_executes_git_commands(self, run_git, resolve):
@patch("pkgmgr.actions.branch.open_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.open_branch.fetch")
@patch("pkgmgr.actions.branch.open_branch.checkout")
@patch("pkgmgr.actions.branch.open_branch.pull")
@patch("pkgmgr.actions.branch.open_branch.create_branch")
@patch("pkgmgr.actions.branch.open_branch.push_upstream")
def test_open_branch_executes_git_commands(
self,
push_upstream,
create_branch,
pull,
checkout,
fetch,
_resolve,
) -> None:
open_branch("feature-x", base_branch="main", cwd=".")
expected_calls = [
(["fetch", "origin"],),
(["checkout", "main"],),
(["pull", "origin", "main"],),
(["checkout", "-b", "feature-x"],),
(["push", "-u", "origin", "feature-x"],),
]
actual = [call.args for call in run_git.call_args_list]
self.assertEqual(actual, expected_calls)
fetch.assert_called_once_with("origin", cwd=".")
checkout.assert_called_once_with("main", cwd=".")
pull.assert_called_once_with("origin", "main", cwd=".")
create_branch.assert_called_once_with("feature-x", "main", cwd=".")
push_upstream.assert_called_once_with("origin", "feature-x", cwd=".")
@patch("builtins.input", return_value="auto-branch")
@patch("pkgmgr.actions.branch.open_branch._resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.open_branch.run_git")
def test_open_branch_prompts_for_name(self, run_git, resolve, input_mock):
@patch("pkgmgr.actions.branch.open_branch.resolve_base_branch", return_value="main")
@patch("pkgmgr.actions.branch.open_branch.fetch")
@patch("pkgmgr.actions.branch.open_branch.checkout")
@patch("pkgmgr.actions.branch.open_branch.pull")
@patch("pkgmgr.actions.branch.open_branch.create_branch")
@patch("pkgmgr.actions.branch.open_branch.push_upstream")
def test_open_branch_prompts_for_name(
self,
push_upstream,
create_branch,
pull,
checkout,
fetch,
_resolve,
_input_mock,
) -> None:
open_branch(None)
calls = [call.args for call in run_git.call_args_list]
self.assertEqual(calls[3][0][0], "checkout") # verify git executed normally
def test_open_branch_rejects_empty_name(self):
fetch.assert_called_once_with("origin", cwd=".")
checkout.assert_called_once_with("main", cwd=".")
pull.assert_called_once_with("origin", "main", cwd=".")
create_branch.assert_called_once_with("auto-branch", "main", cwd=".")
push_upstream.assert_called_once_with("origin", "auto-branch", cwd=".")
def test_open_branch_rejects_empty_name(self) -> None:
with patch("builtins.input", return_value=""):
with self.assertRaises(RuntimeError):
open_branch(None)

View File

@@ -21,46 +21,34 @@ class TestMirrorGitRemote(unittest.TestCase):
)
def test_build_default_ssh_url(self) -> None:
repo = {
"provider": "github.com",
"account": "alice",
"repository": "repo",
}
self.assertEqual(
build_default_ssh_url(repo),
"git@github.com:alice/repo.git",
)
def test_determine_primary_prefers_origin(self) -> None:
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
ctx = self._ctx(config={"origin": "git@github.com:alice/repo.git"})
self.assertEqual(
determine_primary_remote_url(repo, ctx),
"git@github.com:alice/repo.git",
)
self.assertEqual(build_default_ssh_url(repo), "git@github.com:alice/repo.git")
def test_determine_primary_uses_file_order(self) -> None:
def test_determine_primary_prefers_origin_in_resolved(self) -> None:
# resolved_mirrors = config + file (file wins), so put origin in file.
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
ctx = self._ctx(
file={
"first": "git@a/first.git",
"second": "git@a/second.git",
}
)
self.assertEqual(
determine_primary_remote_url(repo, ctx),
"git@a/first.git",
)
ctx = self._ctx(file={"origin": "git@github.com:alice/repo.git"})
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@github.com:alice/repo.git")
def test_determine_primary_falls_back_to_file_order(self) -> None:
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
ctx = self._ctx(file={"first": "git@a/first.git", "second": "git@a/second.git"})
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@a/first.git")
def test_determine_primary_falls_back_to_config_order(self) -> None:
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
ctx = self._ctx(config={"cfg1": "git@c/one.git", "cfg2": "git@c/two.git"})
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@c/one.git")
def test_determine_primary_fallback_default(self) -> None:
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
ctx = self._ctx()
self.assertEqual(
determine_primary_remote_url(repo, ctx),
"git@github.com:alice/repo.git",
)
self.assertEqual(determine_primary_remote_url(repo, ctx), "git@github.com:alice/repo.git")
@patch("pkgmgr.actions.mirror.git_remote._safe_git_output")
def test_has_origin_remote(self, m_out) -> None:
m_out.return_value = "origin\nupstream\n"
@patch("pkgmgr.actions.mirror.git_remote.list_remotes", return_value=["origin", "backup"])
def test_has_origin_remote_true(self, _m_list) -> None:
self.assertTrue(has_origin_remote("/tmp/repo"))
@patch("pkgmgr.actions.mirror.git_remote.list_remotes", return_value=["backup"])
def test_has_origin_remote_false(self, _m_list) -> None:
self.assertFalse(has_origin_remote("/tmp/repo"))

View File

@@ -10,6 +10,8 @@ from pkgmgr.actions.mirror.types import RepoMirrorContext
class TestGitRemotePrimaryPush(unittest.TestCase):
def test_origin_created_and_extra_push_added(self) -> None:
repo = {"provider": "github.com", "account": "alice", "repository": "repo"}
# Use file_mirrors so ctx.resolved_mirrors contains both, no setattr (frozen dataclass!)
ctx = RepoMirrorContext(
identifier="repo",
repo_dir="/tmp/repo",
@@ -20,31 +22,44 @@ class TestGitRemotePrimaryPush(unittest.TestCase):
},
)
executed: list[str] = []
with patch("os.path.isdir", return_value=True):
with patch("pkgmgr.actions.mirror.git_remote.has_origin_remote", return_value=False), patch(
"pkgmgr.actions.mirror.git_remote.add_remote"
) as m_add_remote, patch(
"pkgmgr.actions.mirror.git_remote.set_remote_url"
) as m_set_remote_url, patch(
"pkgmgr.actions.mirror.git_remote.get_remote_push_urls", return_value=set()
), patch(
"pkgmgr.actions.mirror.git_remote.add_remote_push_url"
) as m_add_push:
ensure_origin_remote(repo, ctx, preview=False)
def fake_run(cmd: str, cwd: str, preview: bool) -> None:
executed.append(cmd)
def fake_git(args, cwd):
if args == ["remote"]:
return ""
if args == ["remote", "get-url", "--push", "--all", "origin"]:
return "git@github.com:alice/repo.git\n"
return ""
with patch("os.path.isdir", return_value=True), patch(
"pkgmgr.actions.mirror.git_remote.run_command", side_effect=fake_run
), patch(
"pkgmgr.actions.mirror.git_remote._safe_git_output", side_effect=fake_git
):
ensure_origin_remote(repo, ctx, preview=False)
self.assertEqual(
executed,
[
"git remote add origin git@github.com:alice/repo.git",
"git remote set-url origin git@github.com:alice/repo.git",
"git remote set-url --push origin git@github.com:alice/repo.git",
"git remote set-url --add --push origin git@github.com:alice/repo-backup.git",
],
# determine_primary_remote_url falls back to file order (primary first)
m_add_remote.assert_called_once_with(
"origin",
"git@github.com:alice/repo.git",
cwd="/tmp/repo",
preview=False,
)
m_set_remote_url.assert_any_call(
"origin",
"git@github.com:alice/repo.git",
cwd="/tmp/repo",
push=False,
preview=False,
)
m_set_remote_url.assert_any_call(
"origin",
"git@github.com:alice/repo.git",
cwd="/tmp/repo",
push=True,
preview=False,
)
m_add_push.assert_called_once_with(
"origin",
"git@github.com:alice/repo-backup.git",
cwd="/tmp/repo",
preview=False,
)

View File

@@ -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()

View File

@@ -8,16 +8,10 @@ from pkgmgr.actions.mirror.types import RepoMirrorContext
class TestMirrorSetupCmd(unittest.TestCase):
def _ctx(
self,
*,
repo_dir: str = "/tmp/repo",
resolved: dict[str, str] | None = None,
) -> RepoMirrorContext:
# RepoMirrorContext derives resolved via property (config + file)
# We feed mirrors via file_mirrors to keep insertion order realistic.
def _ctx(self, *, repo_dir: str = "/tmp/repo", resolved: dict[str, str] | None = None) -> RepoMirrorContext:
# resolved_mirrors is a @property combining config+file. Put it into file_mirrors.
return RepoMirrorContext(
identifier="repo-id",
identifier="repo",
repo_dir=repo_dir,
config_mirrors={},
file_mirrors=resolved or {},
@@ -26,7 +20,8 @@ class TestMirrorSetupCmd(unittest.TestCase):
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
@patch("pkgmgr.actions.mirror.setup_cmd.ensure_origin_remote")
def test_setup_mirrors_local_calls_ensure_origin_remote(self, m_ensure, m_ctx) -> None:
m_ctx.return_value = self._ctx(repo_dir="/tmp/repo", resolved={"primary": "git@x/y.git"})
ctx = self._ctx(repo_dir="/tmp/repo", resolved={"primary": "git@x/y.git"})
m_ctx.return_value = ctx
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
setup_mirrors(
@@ -39,24 +34,21 @@ class TestMirrorSetupCmd(unittest.TestCase):
ensure_remote=False,
)
self.assertEqual(m_ensure.call_count, 1)
# ensure_origin_remote(repo, ctx, preview) is called positionally in your code
m_ensure.assert_called_once()
args, kwargs = m_ensure.call_args
# ensure_origin_remote(repo, ctx, preview) may be positional or kw.
# Accept both to avoid coupling tests to call style.
if "preview" in kwargs:
self.assertTrue(kwargs["preview"])
else:
# args: (repo, ctx, preview)
self.assertTrue(args[2])
self.assertEqual(args[0], repos[0])
self.assertIs(args[1], ctx)
self.assertEqual(kwargs.get("preview", args[2] if len(args) >= 3 else None), True)
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
@patch("pkgmgr.actions.mirror.setup_cmd.probe_mirror")
@patch("pkgmgr.actions.mirror.setup_cmd.determine_primary_remote_url")
def test_setup_mirrors_remote_no_mirrors_probes_primary(self, m_primary, m_probe, m_ctx) -> None:
@patch("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable")
def test_setup_mirrors_remote_no_mirrors_probes_primary(self, m_probe, m_primary, m_ctx) -> None:
m_ctx.return_value = self._ctx(repo_dir="/tmp/repo", resolved={})
m_primary.return_value = "git@github.com:alice/repo.git"
m_probe.return_value = (True, "")
m_probe.return_value = True
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
setup_mirrors(
@@ -70,10 +62,10 @@ class TestMirrorSetupCmd(unittest.TestCase):
)
m_primary.assert_called()
m_probe.assert_called_with("git@github.com:alice/repo.git", "/tmp/repo")
m_probe.assert_called_once_with("git@github.com:alice/repo.git", cwd="/tmp/repo")
@patch("pkgmgr.actions.mirror.setup_cmd.build_context")
@patch("pkgmgr.actions.mirror.setup_cmd.probe_mirror")
@patch("pkgmgr.actions.mirror.setup_cmd.probe_remote_reachable")
def test_setup_mirrors_remote_with_mirrors_probes_each(self, m_probe, m_ctx) -> None:
m_ctx.return_value = self._ctx(
repo_dir="/tmp/repo",
@@ -82,7 +74,7 @@ class TestMirrorSetupCmd(unittest.TestCase):
"backup": "ssh://git@git.veen.world:2201/alice/repo.git",
},
)
m_probe.return_value = (True, "")
m_probe.return_value = True
repos = [{"provider": "github.com", "account": "alice", "repository": "repo"}]
setup_mirrors(
@@ -96,6 +88,8 @@ class TestMirrorSetupCmd(unittest.TestCase):
)
self.assertEqual(m_probe.call_count, 2)
m_probe.assert_any_call("git@github.com:alice/repo.git", cwd="/tmp/repo")
m_probe.assert_any_call("ssh://git@git.veen.world:2201/alice/repo.git", cwd="/tmp/repo")
if __name__ == "__main__":

View File

@@ -1,4 +1,3 @@
import unittest
from unittest.mock import patch
@@ -6,15 +5,10 @@ from pkgmgr.actions.publish.git_tags import head_semver_tags
class TestHeadSemverTags(unittest.TestCase):
@patch("pkgmgr.actions.publish.git_tags.run_git")
def test_no_tags(self, mock_run_git):
mock_run_git.return_value = ""
@patch("pkgmgr.actions.publish.git_tags.get_tags_at_ref", return_value=[])
def test_no_tags(self, _mock_get_tags_at_ref) -> None:
self.assertEqual(head_semver_tags(), [])
@patch("pkgmgr.actions.publish.git_tags.run_git")
def test_filters_and_sorts_semver(self, mock_run_git):
mock_run_git.return_value = "v1.0.0\nv2.0.0\nfoo\n"
self.assertEqual(
head_semver_tags(),
["v1.0.0", "v2.0.0"],
)
@patch("pkgmgr.actions.publish.git_tags.get_tags_at_ref", return_value=["v2.0.0", "nope", "v1.0.0", "v1.2.0"])
def test_filters_and_sorts_semver(self, _mock_get_tags_at_ref) -> None:
self.assertEqual(head_semver_tags(), ["v1.0.0", "v1.2.0", "v2.0.0"])

View File

@@ -4,44 +4,28 @@ import unittest
from unittest.mock import patch
from pkgmgr.actions.changelog import generate_changelog
from pkgmgr.core.git import GitError
from pkgmgr.core.git.queries import GitChangelogQueryError
from pkgmgr.cli.commands.changelog import _find_previous_and_current_tag
class TestGenerateChangelog(unittest.TestCase):
@patch("pkgmgr.actions.changelog.run_git")
def test_generate_changelog_default_range_no_merges(self, mock_run_git) -> None:
"""
Default behaviour:
- to_ref = HEAD
- from_ref = None
- include_merges = False -> adds --no-merges
"""
mock_run_git.return_value = "abc123 (HEAD -> main) Initial commit"
@patch("pkgmgr.actions.changelog.get_changelog")
def test_generate_changelog_default_range_no_merges(self, mock_get_changelog) -> None:
mock_get_changelog.return_value = "abc123 (HEAD -> main) Initial commit"
output = generate_changelog(cwd="/repo")
self.assertEqual(
output,
"abc123 (HEAD -> main) Initial commit",
self.assertEqual(output, "abc123 (HEAD -> main) Initial commit")
mock_get_changelog.assert_called_once_with(
cwd="/repo",
from_ref=None,
to_ref="HEAD",
include_merges=False,
)
mock_run_git.assert_called_once()
args, kwargs = mock_run_git.call_args
# Command must start with git log and include our pretty format.
self.assertEqual(args[0][0], "log")
self.assertIn("--pretty=format:%h %d %s", args[0])
self.assertIn("--no-merges", args[0])
self.assertIn("HEAD", args[0])
self.assertEqual(kwargs.get("cwd"), "/repo")
@patch("pkgmgr.actions.changelog.run_git")
def test_generate_changelog_with_range_and_merges(self, mock_run_git) -> None:
"""
Explicit range and include_merges=True:
- from_ref/to_ref are combined into from..to
- no --no-merges flag
"""
mock_run_git.return_value = "def456 (tag: v1.1.0) Some change"
@patch("pkgmgr.actions.changelog.get_changelog")
def test_generate_changelog_with_range_and_merges(self, mock_get_changelog) -> None:
mock_get_changelog.return_value = "def456 (tag: v1.1.0) Some change"
output = generate_changelog(
cwd="/repo",
@@ -51,24 +35,16 @@ class TestGenerateChangelog(unittest.TestCase):
)
self.assertEqual(output, "def456 (tag: v1.1.0) Some change")
mock_run_git.assert_called_once()
args, kwargs = mock_run_git.call_args
mock_get_changelog.assert_called_once_with(
cwd="/repo",
from_ref="v1.0.0",
to_ref="v1.1.0",
include_merges=True,
)
cmd = args[0]
self.assertEqual(cmd[0], "log")
self.assertIn("--pretty=format:%h %d %s", cmd)
# include_merges=True -> no --no-merges flag
self.assertNotIn("--no-merges", cmd)
# Range must be exactly v1.0.0..v1.1.0
self.assertIn("v1.0.0..v1.1.0", cmd)
self.assertEqual(kwargs.get("cwd"), "/repo")
@patch("pkgmgr.actions.changelog.run_git")
def test_generate_changelog_giterror_returns_error_message(self, mock_run_git) -> None:
"""
If Git fails, we do NOT raise; instead we return a human readable error string.
"""
mock_run_git.side_effect = GitError("simulated git failure")
@patch("pkgmgr.actions.changelog.get_changelog")
def test_generate_changelog_giterror_returns_error_message(self, mock_get_changelog) -> None:
mock_get_changelog.side_effect = GitChangelogQueryError("simulated git failure")
result = generate_changelog(cwd="/repo", from_ref="v0.1.0", to_ref="v0.2.0")
@@ -76,12 +52,9 @@ class TestGenerateChangelog(unittest.TestCase):
self.assertIn("simulated git failure", result)
self.assertIn("v0.1.0..v0.2.0", result)
@patch("pkgmgr.actions.changelog.run_git")
def test_generate_changelog_empty_output_returns_info(self, mock_run_git) -> None:
"""
Empty git log output -> informational message instead of empty string.
"""
mock_run_git.return_value = " \n "
@patch("pkgmgr.actions.changelog.get_changelog")
def test_generate_changelog_empty_output_returns_info(self, mock_get_changelog) -> None:
mock_get_changelog.return_value = " \n "
result = generate_changelog(cwd="/repo", from_ref=None, to_ref="HEAD")
@@ -90,49 +63,38 @@ class TestGenerateChangelog(unittest.TestCase):
class TestFindPreviousAndCurrentTag(unittest.TestCase):
def test_no_semver_tags_returns_none_none(self) -> None:
tags = ["foo", "bar", "v1.2", "v1.2.3.4"] # all invalid for SemVer
tags = ["foo", "bar", "v1.2", "v1.2.3.4"]
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
self.assertIsNone(prev_tag)
self.assertIsNone(cur_tag)
def test_latest_tags_when_no_target_given(self) -> None:
"""
When no target tag is given, the function should return:
(second_latest_semver_tag, latest_semver_tag)
based on semantic version ordering, not lexicographic order.
"""
tags = ["v1.0.0", "v1.2.0", "v1.1.0", "not-a-tag"]
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
self.assertEqual(prev_tag, "v1.1.0")
self.assertEqual(cur_tag, "v1.2.0")
def test_single_semver_tag_returns_none_and_that_tag(self) -> None:
tags = ["v0.1.0"]
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
self.assertIsNone(prev_tag)
self.assertEqual(cur_tag, "v0.1.0")
def test_with_target_tag_in_the_middle(self) -> None:
tags = ["v1.0.0", "v1.1.0", "v1.2.0"]
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.1.0")
self.assertEqual(prev_tag, "v1.0.0")
self.assertEqual(cur_tag, "v1.1.0")
def test_with_target_tag_first_has_no_previous(self) -> None:
tags = ["v1.0.0", "v1.1.0"]
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.0.0")
self.assertIsNone(prev_tag)
self.assertEqual(cur_tag, "v1.0.0")
def test_unknown_target_tag_returns_none_none(self) -> None:
tags = ["v1.0.0", "v1.1.0"]
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v2.0.0")
self.assertIsNone(prev_tag)
self.assertIsNone(cur_tag)

View 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()

View File

@@ -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()

View File

@@ -1,40 +1,33 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import subprocess
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from pkgmgr.core.git import (
GitError,
run_git,
get_tags,
get_head_commit,
get_current_branch,
)
from pkgmgr.core.git.errors import GitError
from pkgmgr.core.git.run import run
from pkgmgr.core.git.queries import get_tags, get_head_commit, get_current_branch
class TestGitUtils(unittest.TestCase):
@patch("pkgmgr.core.git.subprocess.run")
def test_run_git_success(self, mock_run):
mock_run.return_value = SimpleNamespace(
stdout="ok\n",
stderr="",
returncode=0,
)
class TestGitRun(unittest.TestCase):
@patch("pkgmgr.core.git.run.subprocess.run")
def test_run_success(self, mock_run):
mock_run.return_value.stdout = "ok\n"
mock_run.return_value.stderr = ""
mock_run.return_value.returncode = 0
output = run_git(["status"], cwd="/tmp/repo")
output = run(["status"], cwd="/tmp/repo")
self.assertEqual(output, "ok")
mock_run.assert_called_once()
# basic sanity: command prefix should be 'git'
args, kwargs = mock_run.call_args
self.assertEqual(args[0][0], "git")
self.assertEqual(kwargs.get("cwd"), "/tmp/repo")
@patch("pkgmgr.core.git.subprocess.run")
def test_run_git_failure_raises_giterror(self, mock_run):
@patch("pkgmgr.core.git.run.subprocess.run")
def test_run_failure_raises_giterror(self, mock_run):
import subprocess
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1,
cmd=["git", "status"],
@@ -43,7 +36,7 @@ class TestGitUtils(unittest.TestCase):
)
with self.assertRaises(GitError) as ctx:
run_git(["status"], cwd="/tmp/repo")
run(["status"], cwd="/tmp/repo")
msg = str(ctx.exception)
self.assertIn("Git command failed", msg)
@@ -51,71 +44,41 @@ class TestGitUtils(unittest.TestCase):
self.assertIn("bad", msg)
self.assertIn("error", msg)
@patch("pkgmgr.core.git.subprocess.run")
def test_get_tags_empty(self, mock_run):
mock_run.return_value = SimpleNamespace(
stdout="",
stderr="",
returncode=0,
)
class TestGitQueries(unittest.TestCase):
@patch("pkgmgr.core.git.queries.get_tags.run")
def test_get_tags_empty(self, mock_run):
mock_run.return_value = ""
tags = get_tags(cwd="/tmp/repo")
self.assertEqual(tags, [])
@patch("pkgmgr.core.git.subprocess.run")
@patch("pkgmgr.core.git.queries.get_tags.run")
def test_get_tags_non_empty(self, mock_run):
mock_run.return_value = SimpleNamespace(
stdout="v1.0.0\nv1.1.0\n",
stderr="",
returncode=0,
)
mock_run.return_value = "v1.0.0\nv1.1.0\n"
tags = get_tags(cwd="/tmp/repo")
self.assertEqual(tags, ["v1.0.0", "v1.1.0"])
@patch("pkgmgr.core.git.subprocess.run")
@patch("pkgmgr.core.git.queries.get_head_commit.run")
def test_get_head_commit_success(self, mock_run):
mock_run.return_value = SimpleNamespace(
stdout="abc123\n",
stderr="",
returncode=0,
)
mock_run.return_value = "abc123"
commit = get_head_commit(cwd="/tmp/repo")
self.assertEqual(commit, "abc123")
@patch("pkgmgr.core.git.subprocess.run")
@patch("pkgmgr.core.git.queries.get_head_commit.run")
def test_get_head_commit_failure_returns_none(self, mock_run):
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1,
cmd=["git", "rev-parse", "HEAD"],
output="",
stderr="error\n",
)
mock_run.side_effect = GitError("fail")
commit = get_head_commit(cwd="/tmp/repo")
self.assertIsNone(commit)
@patch("pkgmgr.core.git.subprocess.run")
@patch("pkgmgr.core.git.queries.get_current_branch.run")
def test_get_current_branch_success(self, mock_run):
mock_run.return_value = SimpleNamespace(
stdout="main\n",
stderr="",
returncode=0,
)
mock_run.return_value = "main"
branch = get_current_branch(cwd="/tmp/repo")
self.assertEqual(branch, "main")
@patch("pkgmgr.core.git.subprocess.run")
@patch("pkgmgr.core.git.queries.get_current_branch.run")
def test_get_current_branch_failure_returns_none(self, mock_run):
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1,
cmd=["git", "rev-parse", "--abbrev-ref", "HEAD"],
output="",
stderr="error\n",
)
mock_run.side_effect = GitError("fail")
branch = get_current_branch(cwd="/tmp/repo")
self.assertIsNone(branch)