diff --git a/.github/workflows/test-container.yml b/.github/workflows/test-container.yml index a60c327..7aef50a 100644 --- a/.github/workflows/test-container.yml +++ b/.github/workflows/test-container.yml @@ -16,6 +16,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Show commit SHA + run: git rev-parse HEAD + - name: Show Docker version run: docker version diff --git a/Dockerfile b/Dockerfile index 125f012..76f90d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ # ------------------------------------------------------------ # Base image selector — overridden by Makefile # ------------------------------------------------------------ -ARG BASE_IMAGE=archlinux:latest +ARG BASE_IMAGE FROM ${BASE_IMAGE} +RUN echo "BASE_IMAGE=${BASE_IMAGE}" && \ + cat /etc/os-release || true + # ------------------------------------------------------------ # Nix environment defaults # diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..cdd8bf4 --- /dev/null +++ b/MIRRORS @@ -0,0 +1,3 @@ +git@github.com:kevinveenbirkenbach/package-manager.git +ssh://git@git.veen.world:2201/kevinveenbirkenbach/pkgmgr.git +ssh://git@code.cymais.cloud:2201/kevinveenbirkenbach/pkgmgr.git \ No newline at end of file diff --git a/TODO.md b/TODO.md index 1e6efcf..c260c95 100644 --- a/TODO.md +++ b/TODO.md @@ -3,5 +3,4 @@ For the following checkout the implementation map: - Implement TAGS -- Implement MIRROR - Implement SIGNING_KEY \ No newline at end of file diff --git a/scripts/installation/run-package.sh b/scripts/installation/run-package.sh index 4b4d593..d782232 100755 --- a/scripts/installation/run-package.sh +++ b/scripts/installation/run-package.sh @@ -8,6 +8,12 @@ source "${SCRIPT_DIR}/lib.sh" OS_ID="$(detect_os_id)" +# Map Manjaro to Arch +if [[ "${OS_ID}" == "manjaro" ]]; then + echo "[run-package] Mapping OS 'manjaro' → 'arch'" + OS_ID="arch" +fi + echo "[run-package] Detected OS: ${OS_ID}" case "${OS_ID}" in diff --git a/scripts/test/test-container.sh b/scripts/test/test-container.sh index 5f60ec5..1d018fd 100755 --- a/scripts/test/test-container.sh +++ b/scripts/test/test-container.sh @@ -7,6 +7,8 @@ echo echo "------------------------------------------------------------" echo ">>> Testing container: $IMAGE" echo "------------------------------------------------------------" +echo "[test-container] Inspect image metadata:" +docker image inspect "$IMAGE" | sed -n '1,40p' echo "[test-container] Running: docker run --rm --entrypoint pkgmgr $IMAGE --help" echo diff --git a/src/pkgmgr/actions/mirror/__init__.py b/src/pkgmgr/actions/mirror/__init__.py new file mode 100644 index 0000000..d0594b6 --- /dev/null +++ b/src/pkgmgr/actions/mirror/__init__.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +""" +High-level mirror actions. + +Public API: + - list_mirrors + - diff_mirrors + - merge_mirrors + - setup_mirrors +""" + +from .types import Repository, MirrorMap +from .list_cmd import list_mirrors +from .diff_cmd import diff_mirrors +from .merge_cmd import merge_mirrors +from .setup_cmd import setup_mirrors + +__all__ = [ + "Repository", + "MirrorMap", + "list_mirrors", + "diff_mirrors", + "merge_mirrors", + "setup_mirrors", +] diff --git a/src/pkgmgr/actions/mirror/context.py b/src/pkgmgr/actions/mirror/context.py new file mode 100644 index 0000000..22591ee --- /dev/null +++ b/src/pkgmgr/actions/mirror/context.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import List + +from pkgmgr.core.repository.dir import get_repo_dir +from pkgmgr.core.repository.identifier import get_repo_identifier + +from .io import load_config_mirrors, read_mirrors_file +from .types import MirrorMap, RepoMirrorContext, Repository + + +def build_context( + repo: Repository, + repositories_base_dir: str, + all_repos: List[Repository], +) -> RepoMirrorContext: + """ + Build a RepoMirrorContext for a single repository. + """ + identifier = get_repo_identifier(repo, all_repos) + repo_dir = get_repo_dir(repositories_base_dir, repo) + + config_mirrors: MirrorMap = load_config_mirrors(repo) + file_mirrors: MirrorMap = read_mirrors_file(repo_dir) + + return RepoMirrorContext( + identifier=identifier, + repo_dir=repo_dir, + config_mirrors=config_mirrors, + file_mirrors=file_mirrors, + ) diff --git a/src/pkgmgr/actions/mirror/diff_cmd.py b/src/pkgmgr/actions/mirror/diff_cmd.py new file mode 100644 index 0000000..3b467b8 --- /dev/null +++ b/src/pkgmgr/actions/mirror/diff_cmd.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import List + +from .context import build_context +from .printing import print_header +from .types import Repository + + +def diff_mirrors( + selected_repos: List[Repository], + repositories_base_dir: str, + all_repos: List[Repository], +) -> None: + """ + Show differences between config mirrors and MIRRORS file. + + - Mirrors present only in config are reported as "ONLY IN CONFIG". + - Mirrors present only in MIRRORS file are reported as "ONLY IN FILE". + - Mirrors with same name but different URLs are reported as "URL MISMATCH". + """ + for repo in selected_repos: + ctx = build_context(repo, repositories_base_dir, all_repos) + + print_header("[MIRROR DIFF]", ctx) + + config_m = ctx.config_mirrors + file_m = ctx.file_mirrors + + if not config_m and not file_m: + print(" No mirrors configured in config or MIRRORS file.") + print() + continue + + # Mirrors only in config + for name, url in sorted(config_m.items()): + if name not in file_m: + print(f" [ONLY IN CONFIG] {name}: {url}") + + # Mirrors only in MIRRORS file + for name, url in sorted(file_m.items()): + if name not in config_m: + print(f" [ONLY IN FILE] {name}: {url}") + + # Mirrors with same name but different URLs + shared = set(config_m) & set(file_m) + for name in sorted(shared): + url_cfg = config_m.get(name) + url_file = file_m.get(name) + if url_cfg != url_file: + print( + f" [URL MISMATCH] {name}:\n" + f" config: {url_cfg}\n" + f" file: {url_file}" + ) + + if config_m and file_m and config_m == file_m: + print(" [OK] Mirrors in config and MIRRORS file are in sync.") + + print() diff --git a/src/pkgmgr/actions/mirror/git_remote.py b/src/pkgmgr/actions/mirror/git_remote.py new file mode 100644 index 0000000..9087deb --- /dev/null +++ b/src/pkgmgr/actions/mirror/git_remote.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import os +from typing import List, Optional, Set + +from pkgmgr.core.command.run import run_command +from pkgmgr.core.git import GitError, run_git + +from .types import MirrorMap, RepoMirrorContext, Repository + + +def build_default_ssh_url(repo: Repository) -> Optional[str]: + """ + Build a simple SSH URL from repo config if no explicit mirror is defined. + + Example: git@github.com:account/repository.git + """ + provider = repo.get("provider") + account = repo.get("account") + name = repo.get("repository") + port = repo.get("port") + + if not provider or not account or not name: + return None + + provider = str(provider) + account = str(account) + name = str(name) + + if port: + return f"ssh://git@{provider}:{port}/{account}/{name}.git" + + # GitHub-style shorthand + return f"git@{provider}:{account}/{name}.git" + + +def determine_primary_remote_url( + repo: Repository, + resolved_mirrors: MirrorMap, +) -> Optional[str]: + """ + Determine the primary remote URL in a consistent way: + + 1. resolved_mirrors["origin"] + 2. any resolved mirror (first by name) + 3. default SSH URL from provider/account/repository + """ + if "origin" in resolved_mirrors: + return resolved_mirrors["origin"] + + if resolved_mirrors: + first_name = sorted(resolved_mirrors.keys())[0] + return resolved_mirrors[first_name] + + return build_default_ssh_url(repo) + + +def _safe_git_output(args: List[str], cwd: str) -> Optional[str]: + """ + Run a Git command via run_git and return its stdout, or None on failure. + """ + try: + return run_git(args, cwd=cwd) + except GitError: + return None + + +def current_origin_url(repo_dir: str) -> Optional[str]: + """ + Return the current URL for remote 'origin', or None if not present. + """ + output = _safe_git_output(["remote", "get-url", "origin"], cwd=repo_dir) + if not output: + return None + url = output.strip() + return url or None + + +def has_origin_remote(repo_dir: str) -> bool: + """ + Check whether a remote called 'origin' exists in the repository. + """ + output = _safe_git_output(["remote"], cwd=repo_dir) + if not output: + return False + names = output.split() + return "origin" in names + + +def _ensure_push_urls_for_origin( + repo_dir: str, + mirrors: MirrorMap, + preview: bool, +) -> None: + """ + Ensure that all mirror URLs are present as push URLs on 'origin'. + """ + desired: Set[str] = {url for url in mirrors.values() if url} + if not desired: + return + + existing_output = _safe_git_output( + ["remote", "get-url", "--push", "--all", "origin"], + cwd=repo_dir, + ) + existing = set(existing_output.splitlines()) if existing_output else set() + + missing = sorted(desired - existing) + for url in missing: + cmd = f"git remote set-url --add --push origin {url}" + if preview: + print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}") + else: + print(f"[INFO] Adding push URL to 'origin': {url}") + run_command(cmd, cwd=repo_dir, preview=False) + + +def ensure_origin_remote( + repo: Repository, + ctx: RepoMirrorContext, + preview: bool, +) -> None: + """ + Ensure that a usable 'origin' remote exists and has all push URLs. + """ + repo_dir = ctx.repo_dir + resolved_mirrors = ctx.resolved_mirrors + + if not os.path.isdir(os.path.join(repo_dir, ".git")): + print(f"[WARN] {repo_dir} is not a Git repository (no .git directory).") + return + + url = determine_primary_remote_url(repo, resolved_mirrors) + + if not has_origin_remote(repo_dir): + if not url: + print( + "[WARN] Could not determine URL for 'origin' remote. " + "Please configure mirrors or provider/account/repository." + ) + return + + cmd = f"git remote add origin {url}" + if preview: + print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}") + else: + print(f"[INFO] Adding 'origin' remote in {repo_dir}: {url}") + run_command(cmd, cwd=repo_dir, preview=False) + else: + current = current_origin_url(repo_dir) + if current == url or not url: + print( + f"[INFO] 'origin' already points to " + f"{current or ''} (no change needed)." + ) + else: + # We do not auto-change origin here, only log the mismatch. + print( + "[INFO] 'origin' exists with URL " + f"{current or ''}; not changing to {url}." + ) + + # Ensure all mirrors are present as push URLs + _ensure_push_urls_for_origin(repo_dir, resolved_mirrors, preview) + + +def is_remote_reachable(url: str, cwd: Optional[str] = None) -> bool: + """ + Check whether a remote repository is reachable via `git ls-remote`. + + This does NOT modify anything; it only probes the remote. + """ + workdir = cwd or os.getcwd() + try: + # --exit-code → non-zero exit code if the remote does not exist + run_git(["ls-remote", "--exit-code", url], cwd=workdir) + return True + except GitError: + return False diff --git a/src/pkgmgr/actions/mirror/io.py b/src/pkgmgr/actions/mirror/io.py new file mode 100644 index 0000000..1b02455 --- /dev/null +++ b/src/pkgmgr/actions/mirror/io.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import os +from urllib.parse import urlparse +from typing import List, Mapping + +from .types import MirrorMap, Repository + + +def load_config_mirrors(repo: Repository) -> MirrorMap: + mirrors = repo.get("mirrors") or {} + result: MirrorMap = {} + + if isinstance(mirrors, dict): + for name, url in mirrors.items(): + if url: + result[str(name)] = str(url) + return result + + if isinstance(mirrors, list): + for entry in mirrors: + if isinstance(entry, dict): + name = entry.get("name") + url = entry.get("url") + if name and url: + result[str(name)] = str(url) + + return result + + +def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap: + """ + Supports: + NAME URL + URL → auto name = hostname + """ + path = os.path.join(repo_dir, filename) + mirrors: MirrorMap = {} + + if not os.path.exists(path): + return mirrors + + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + parts = stripped.split(None, 1) + + # Case 1: "name url" + if len(parts) == 2: + name, url = parts + # Case 2: "url" → auto-generate name + elif len(parts) == 1: + url = parts[0] + parsed = urlparse(url) + host = (parsed.netloc or "").split(":")[0] + base = host or "mirror" + name = base + i = 2 + while name in mirrors: + name = f"{base}{i}" + i += 1 + else: + continue + + mirrors[name] = url + except OSError as exc: + print(f"[WARN] Could not read MIRRORS file at {path}: {exc}") + + return mirrors + + +def write_mirrors_file( + repo_dir: str, + mirrors: Mapping[str, str], + filename: str = "MIRRORS", + preview: bool = False, +) -> None: + + path = os.path.join(repo_dir, filename) + lines = [f"{name} {url}" for name, url in sorted(mirrors.items())] + content = "\n".join(lines) + ("\n" if lines else "") + + if preview: + print(f"[PREVIEW] Would write MIRRORS file at {path}:") + print(content or "(empty)") + return + + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + print(f"[INFO] Wrote MIRRORS file at {path}") + except OSError as exc: + print(f"[ERROR] Failed to write MIRRORS file at {path}: {exc}") diff --git a/src/pkgmgr/actions/mirror/list_cmd.py b/src/pkgmgr/actions/mirror/list_cmd.py new file mode 100644 index 0000000..a3b094a --- /dev/null +++ b/src/pkgmgr/actions/mirror/list_cmd.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import List + +from .context import build_context +from .printing import print_header, print_named_mirrors +from .types import Repository + + +def list_mirrors( + selected_repos: List[Repository], + repositories_base_dir: str, + all_repos: List[Repository], + source: str = "all", +) -> None: + """ + List mirrors for the selected repositories. + + source: + - "config" → only mirrors from configuration + - "file" → only mirrors from MIRRORS file + - "resolved" → merged view (config + file, file wins) + - "all" → show config + file + resolved + """ + for repo in selected_repos: + ctx = build_context(repo, repositories_base_dir, all_repos) + resolved_m = ctx.resolved_mirrors + + print_header("[MIRROR]", ctx) + + if source in ("config", "all"): + print_named_mirrors("config mirrors", ctx.config_mirrors) + if source == "config": + print() + continue # next repo + + if source in ("file", "all"): + print_named_mirrors("MIRRORS file", ctx.file_mirrors) + if source == "file": + print() + continue # next repo + + if source in ("resolved", "all"): + print_named_mirrors("resolved mirrors", resolved_m) + + print() diff --git a/src/pkgmgr/actions/mirror/merge_cmd.py b/src/pkgmgr/actions/mirror/merge_cmd.py new file mode 100644 index 0000000..3f0596d --- /dev/null +++ b/src/pkgmgr/actions/mirror/merge_cmd.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import os +from typing import Dict, List, Tuple, Optional + +import yaml + +from pkgmgr.core.config.save import save_user_config + +from .context import build_context +from .io import write_mirrors_file +from .types import MirrorMap, Repository + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +def _repo_key(repo: Repository) -> Tuple[str, str, str]: + """ + Normalised key for identifying a repository in config files. + """ + return ( + str(repo.get("provider", "")), + str(repo.get("account", "")), + str(repo.get("repository", "")), + ) + + +def _load_user_config(path: str) -> Dict[str, object]: + """ + Load a user config YAML file as dict. + Non-dicts yield {}. + """ + if not os.path.exists(path): + return {} + + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +# ----------------------------------------------------------------------------- +# Main merge command +# ----------------------------------------------------------------------------- + +def merge_mirrors( + selected_repos: List[Repository], + repositories_base_dir: str, + all_repos: List[Repository], + source: str, + target: str, + preview: bool = False, + user_config_path: Optional[str] = None, +) -> None: + """ + Merge mirrors between config and MIRRORS file. + + Rules: + - source, target ∈ {"config", "file"}. + - merged = (target_mirrors overridden by source_mirrors) + - If target == "file" → write MIRRORS file. + - If target == "config": + * update the user config YAML directly + * write it using save_user_config() + + The merge strategy is: + dst + src (src wins on same name) + """ + + # Load user config once if we intend to write to it. + user_cfg: Optional[Dict[str, object]] = None + user_cfg_path_expanded: Optional[str] = None + + if target == "config" and user_config_path and not preview: + user_cfg_path_expanded = os.path.expanduser(user_config_path) + user_cfg = _load_user_config(user_cfg_path_expanded) + if not isinstance(user_cfg.get("repositories"), list): + user_cfg["repositories"] = [] + + for repo in selected_repos: + ctx = build_context(repo, repositories_base_dir, all_repos) + + print("============================================================") + print(f"[MIRROR MERGE] Repository: {ctx.identifier}") + print(f"[MIRROR MERGE] Directory: {ctx.repo_dir}") + print(f"[MIRROR MERGE] {source} → {target}") + print("============================================================") + + # Pick the correct source/target maps + if source == "config": + src = ctx.config_mirrors + dst = ctx.file_mirrors + else: # source == "file" + src = ctx.file_mirrors + dst = ctx.config_mirrors + + # Merge (src overrides dst) + merged: MirrorMap = dict(dst) + merged.update(src) + + # --------------------------------------------------------- + # WRITE TO FILE + # --------------------------------------------------------- + if target == "file": + write_mirrors_file(ctx.repo_dir, merged, preview=preview) + print() + continue + + # --------------------------------------------------------- + # WRITE TO CONFIG + # --------------------------------------------------------- + if target == "config": + # If preview or no config path → show intended output + if preview or not user_cfg: + print("[INFO] The following mirrors would be written to config:") + if not merged: + print(" (no mirrors)") + else: + for name, url in sorted(merged.items()): + print(f" - {name}: {url}") + print(" (Config not modified due to preview or missing path.)") + print() + continue + + repos = user_cfg.get("repositories") + target_key = _repo_key(repo) + existing_repo: Optional[Repository] = None + + # Find existing repo entry + for entry in repos: + if isinstance(entry, dict) and _repo_key(entry) == target_key: + existing_repo = entry + break + + # Create entry if missing + if existing_repo is None: + existing_repo = { + "provider": repo.get("provider"), + "account": repo.get("account"), + "repository": repo.get("repository"), + } + repos.append(existing_repo) + + # Write or delete mirrors + if merged: + existing_repo["mirrors"] = dict(merged) + else: + existing_repo.pop("mirrors", None) + + print(" [OK] Updated repo['mirrors'] in user config.") + print() + + # ------------------------------------------------------------- + # SAVE CONFIG (once at the end) + # ------------------------------------------------------------- + if user_cfg is not None and user_cfg_path_expanded is not None and not preview: + save_user_config(user_cfg, user_cfg_path_expanded) + print(f"[OK] Saved updated config: {user_cfg_path_expanded}") diff --git a/src/pkgmgr/actions/mirror/printing.py b/src/pkgmgr/actions/mirror/printing.py new file mode 100644 index 0000000..51c38e5 --- /dev/null +++ b/src/pkgmgr/actions/mirror/printing.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from .types import MirrorMap, RepoMirrorContext + + +def print_header( + title_prefix: str, + ctx: RepoMirrorContext, +) -> None: + """ + Print a standard header for mirror-related output. + + title_prefix examples: + - "[MIRROR]" + - "[MIRROR DIFF]" + - "[MIRROR MERGE]" + - "[MIRROR SETUP:LOCAL]" + - "[MIRROR SETUP:REMOTE]" + """ + print("============================================================") + print(f"{title_prefix} Repository: {ctx.identifier}") + print(f"{title_prefix} Directory: {ctx.repo_dir}") + print("============================================================") + + +def print_named_mirrors(label: str, mirrors: MirrorMap) -> None: + """ + Print a labeled mirror block (e.g. '[config mirrors]'). + """ + print(f" [{label}]") + if mirrors: + for name, url in sorted(mirrors.items()): + print(f" - {name}: {url}") + else: + print(" (none)") diff --git a/src/pkgmgr/actions/mirror/setup_cmd.py b/src/pkgmgr/actions/mirror/setup_cmd.py new file mode 100644 index 0000000..b4d1cbe --- /dev/null +++ b/src/pkgmgr/actions/mirror/setup_cmd.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import List, Tuple + +from pkgmgr.core.git import run_git, GitError + +from .context import build_context +from .git_remote import determine_primary_remote_url, ensure_origin_remote +from .types import Repository + + +def _setup_local_mirrors_for_repo( + repo: Repository, + repositories_base_dir: str, + all_repos: List[Repository], + preview: bool, +) -> None: + """ + Ensure local Git state is sane (currently: 'origin' remote). + """ + ctx = build_context(repo, repositories_base_dir, all_repos) + + print("------------------------------------------------------------") + print(f"[MIRROR SETUP:LOCAL] {ctx.identifier}") + print(f"[MIRROR SETUP:LOCAL] dir: {ctx.repo_dir}") + print("------------------------------------------------------------") + + ensure_origin_remote(repo, ctx, preview=preview) + print() + + +def _probe_mirror(url: str, repo_dir: str) -> Tuple[bool, str]: + """ + Probe a remote mirror by running `git ls-remote `. + + Returns: + (True, "") on success, + (False, error_message) on failure. + + Wichtig: + - Wir werten ausschließlich den Exit-Code aus. + - STDERR kann Hinweise/Warnings enthalten und ist NICHT automatisch ein Fehler. + """ + try: + # Wir ignorieren stdout komplett; wichtig ist nur, dass der Befehl ohne + # GitError (also Exit-Code 0) durchläuft. + run_git(["ls-remote", url], cwd=repo_dir) + return True, "" + except GitError as exc: + return False, str(exc) + + +def _setup_remote_mirrors_for_repo( + repo: Repository, + repositories_base_dir: str, + all_repos: List[Repository], + preview: bool, +) -> None: + """ + Remote-side setup / validation. + + Aktuell werden nur **nicht-destruktive Checks** gemacht: + + - Für jeden Mirror (aus config + MIRRORS-Datei, file gewinnt): + * `git ls-remote ` wird ausgeführt. + * Bei Exit-Code 0 → [OK] + * Bei Fehler → [WARN] + Details aus der GitError-Exception + + Es werden **keine** Provider-APIs aufgerufen und keine Repos angelegt. + """ + ctx = build_context(repo, repositories_base_dir, all_repos) + resolved_m = ctx.resolved_mirrors + + print("------------------------------------------------------------") + print(f"[MIRROR SETUP:REMOTE] {ctx.identifier}") + print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}") + print("------------------------------------------------------------") + + if not resolved_m: + # Optional: Fallback auf eine heuristisch bestimmte URL, falls wir + # irgendwann "automatisch anlegen" implementieren wollen. + primary_url = determine_primary_remote_url(repo, resolved_m) + if not primary_url: + print( + "[INFO] No mirrors configured (config or MIRRORS file), and no " + "primary URL could be derived from provider/account/repository." + ) + print() + return + + ok, error_message = _probe_mirror(primary_url, ctx.repo_dir) + if ok: + print(f"[OK] Remote mirror (primary) is reachable: {primary_url}") + else: + print("[WARN] Primary remote URL is NOT reachable:") + print(f" {primary_url}") + if error_message: + print(" Details:") + for line in error_message.splitlines(): + print(f" {line}") + + print() + print( + "[INFO] Remote checks are non-destructive and only use `git ls-remote` " + "to probe mirror URLs." + ) + print() + return + + # Normaler Fall: wir haben benannte Mirrors aus config/MIRRORS + for name, url in sorted(resolved_m.items()): + ok, error_message = _probe_mirror(url, ctx.repo_dir) + if ok: + print(f"[OK] Remote mirror '{name}' is reachable: {url}") + else: + print(f"[WARN] Remote mirror '{name}' is NOT reachable:") + print(f" {url}") + if error_message: + print(" Details:") + for line in error_message.splitlines(): + print(f" {line}") + + print() + print( + "[INFO] Remote checks are non-destructive and only use `git ls-remote` " + "to probe mirror URLs." + ) + print() + + +def setup_mirrors( + selected_repos: List[Repository], + repositories_base_dir: str, + all_repos: List[Repository], + preview: bool = False, + local: bool = True, + remote: bool = True, +) -> None: + """ + Setup mirrors for the selected repositories. + + local: + - Configure local Git remotes (currently: ensure 'origin' is present and + points to a reasonable URL). + + remote: + - Non-destructive remote checks using `git ls-remote` for each mirror URL. + Es werden keine Repositories auf dem Provider angelegt. + """ + for repo in selected_repos: + if local: + _setup_local_mirrors_for_repo( + repo, + repositories_base_dir=repositories_base_dir, + all_repos=all_repos, + preview=preview, + ) + + if remote: + _setup_remote_mirrors_for_repo( + repo, + repositories_base_dir=repositories_base_dir, + all_repos=all_repos, + preview=preview, + ) diff --git a/src/pkgmgr/actions/mirror/types.py b/src/pkgmgr/actions/mirror/types.py new file mode 100644 index 0000000..a95bf80 --- /dev/null +++ b/src/pkgmgr/actions/mirror/types.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + +Repository = Dict[str, Any] +MirrorMap = Dict[str, str] + + +@dataclass(frozen=True) +class RepoMirrorContext: + """ + Bundle mirror-related information for a single repository. + """ + + identifier: str + repo_dir: str + config_mirrors: MirrorMap + file_mirrors: MirrorMap + + @property + def resolved_mirrors(self) -> MirrorMap: + """ + Combined mirrors from config and MIRRORS file. + + Strategy: + - Start from config mirrors + - Overlay MIRRORS file (file wins on same name) + """ + merged: MirrorMap = dict(self.config_mirrors) + merged.update(self.file_mirrors) + return merged diff --git a/src/pkgmgr/cli/commands/__init__.py b/src/pkgmgr/cli/commands/__init__.py index d07d9a8..df7ea76 100644 --- a/src/pkgmgr/cli/commands/__init__.py +++ b/src/pkgmgr/cli/commands/__init__.py @@ -6,6 +6,7 @@ from .version import handle_version from .make import handle_make from .changelog import handle_changelog from .branch import handle_branch +from .mirror import handle_mirror_command __all__ = [ "handle_repos_command", @@ -16,4 +17,5 @@ __all__ = [ "handle_make", "handle_changelog", "handle_branch", + "handle_mirror_command", ] diff --git a/src/pkgmgr/cli/commands/mirror.py b/src/pkgmgr/cli/commands/mirror.py new file mode 100644 index 0000000..674e1fd --- /dev/null +++ b/src/pkgmgr/cli/commands/mirror.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import sys +from typing import Any, Dict, List + +from pkgmgr.actions.mirror import ( + diff_mirrors, + list_mirrors, + merge_mirrors, + setup_mirrors, +) +from pkgmgr.cli.context import CLIContext + +Repository = Dict[str, Any] + + +def handle_mirror_command( + args, + ctx: CLIContext, + selected: List[Repository], +) -> None: + """ + Entry point for 'pkgmgr mirror' subcommands. + + Subcommands: + - mirror list → list configured mirrors + - mirror diff → compare config vs MIRRORS file + - mirror merge → merge mirrors between config and MIRRORS file + - mirror setup → configure local Git + remote placeholders + """ + if not selected: + print("[INFO] No repositories selected for 'mirror' command.") + sys.exit(1) + + subcommand = getattr(args, "subcommand", None) + + # ------------------------------------------------------------ + # mirror list + # ------------------------------------------------------------ + if subcommand == "list": + source = getattr(args, "source", "all") + list_mirrors( + selected_repos=selected, + repositories_base_dir=ctx.repositories_base_dir, + all_repos=ctx.all_repositories, + source=source, + ) + return + + # ------------------------------------------------------------ + # mirror diff + # ------------------------------------------------------------ + if subcommand == "diff": + diff_mirrors( + selected_repos=selected, + repositories_base_dir=ctx.repositories_base_dir, + all_repos=ctx.all_repositories, + ) + return + + # ------------------------------------------------------------ + # mirror merge + # ------------------------------------------------------------ + if subcommand == "merge": + source = getattr(args, "source", None) + target = getattr(args, "target", None) + preview = getattr(args, "preview", False) + + if source == target: + print( + "[ERROR] For 'mirror merge', source and target " + "must differ (one of: config, file)." + ) + sys.exit(2) + + # Config file path can be passed explicitly via --config-path. + # If not given, fall back to the global context (if available). + explicit_config_path = getattr(args, "config_path", None) + user_config_path = explicit_config_path or getattr( + ctx, "user_config_path", None + ) + + merge_mirrors( + selected_repos=selected, + repositories_base_dir=ctx.repositories_base_dir, + all_repos=ctx.all_repositories, + source=source, + target=target, + preview=preview, + user_config_path=user_config_path, + ) + return + + # ------------------------------------------------------------ + # mirror setup + # ------------------------------------------------------------ + if subcommand == "setup": + local = getattr(args, "local", False) + remote = getattr(args, "remote", False) + preview = getattr(args, "preview", False) + + # If neither flag is set → default to both. + if not local and not remote: + local = True + remote = True + + setup_mirrors( + selected_repos=selected, + repositories_base_dir=ctx.repositories_base_dir, + all_repos=ctx.all_repositories, + preview=preview, + local=local, + remote=remote, + ) + return + + print(f"[ERROR] Unknown mirror subcommand: {subcommand}") + sys.exit(2) diff --git a/src/pkgmgr/cli/dispatch.py b/src/pkgmgr/cli/dispatch.py index 08b8f5c..6b3bfba 100644 --- a/src/pkgmgr/cli/dispatch.py +++ b/src/pkgmgr/cli/dispatch.py @@ -21,9 +21,9 @@ from pkgmgr.cli.commands import ( handle_make, handle_changelog, handle_branch, + handle_mirror_command, ) - def _has_explicit_selection(args) -> bool: """ Return True if the user explicitly selected repositories via @@ -108,6 +108,7 @@ def dispatch_command(args, ctx: CLIContext) -> None: "explore", "terminal", "code", + "mirror", ] if getattr(args, "command", None) in commands_with_selection: @@ -174,5 +175,9 @@ def dispatch_command(args, ctx: CLIContext) -> None: handle_branch(args, ctx) return + if args.command == "mirror": + handle_mirror_command(args, ctx, selected) + return + print(f"Unknown command: {args.command}") sys.exit(2) diff --git a/src/pkgmgr/cli/parser.py b/src/pkgmgr/cli/parser.py deleted file mode 100644 index 359dab1..0000000 --- a/src/pkgmgr/cli/parser.py +++ /dev/null @@ -1,505 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -from __future__ import annotations - -import argparse - -from pkgmgr.cli.proxy import register_proxy_commands - - -class SortedSubParsersAction(argparse._SubParsersAction): - """ - Subparsers action that keeps choices sorted alphabetically. - """ - - def add_parser(self, name, **kwargs): - parser = super().add_parser(name, **kwargs) - # Sort choices alphabetically by dest (subcommand name) - self._choices_actions.sort(key=lambda a: a.dest) - return parser - - -def add_identifier_arguments(subparser: argparse.ArgumentParser) -> None: - """ - Common identifier / selection arguments for many subcommands. - - Selection modes (mutual intent, not hard-enforced): - - identifiers (positional): select by alias / provider/account/repo - - --all: select all repositories - - --category / --string / --tag: filter-based selection on top - of the full repository set - """ - subparser.add_argument( - "identifiers", - nargs="*", - help=( - "Identifier(s) for repositories. " - "Default: Repository of current folder." - ), - ) - subparser.add_argument( - "--all", - action="store_true", - default=False, - help=( - "Apply the subcommand to all repositories in the config. " - "Some subcommands ask for confirmation. If you want to give this " - "confirmation for all repositories, pipe 'yes'. E.g: " - "yes | pkgmgr {subcommand} --all" - ), - ) - subparser.add_argument( - "--category", - nargs="+", - default=[], - help=( - "Filter repositories by category patterns derived from config " - "filenames or repo metadata (use filename without .yml/.yaml, " - "or /regex/ to use a regular expression)." - ), - ) - subparser.add_argument( - "--string", - default="", - help=( - "Filter repositories whose identifier / name / path contains this " - "substring (case-insensitive). Use /regex/ for regular expressions." - ), - ) - subparser.add_argument( - "--tag", - action="append", - default=[], - help=( - "Filter repositories by tag. Matches tags from the repository " - "collector and category tags. Use /regex/ for regular expressions." - ), - ) - subparser.add_argument( - "--preview", - action="store_true", - help="Preview changes without executing commands", - ) - subparser.add_argument( - "--list", - action="store_true", - help="List affected repositories (with preview or status)", - ) - subparser.add_argument( - "-a", - "--args", - nargs=argparse.REMAINDER, - dest="extra_args", - help="Additional parameters to be attached.", - default=[], - ) - - -def add_install_update_arguments(subparser: argparse.ArgumentParser) -> None: - """ - Common arguments for install/update commands. - """ - add_identifier_arguments(subparser) - subparser.add_argument( - "-q", - "--quiet", - action="store_true", - help="Suppress warnings and info messages", - ) - subparser.add_argument( - "--no-verification", - action="store_true", - default=False, - help="Disable verification via commit/gpg", - ) - subparser.add_argument( - "--dependencies", - action="store_true", - help="Also pull and update dependencies", - ) - subparser.add_argument( - "--clone-mode", - choices=["ssh", "https", "shallow"], - default="ssh", - help=( - "Specify the clone mode: ssh, https, or shallow " - "(HTTPS shallow clone; default: ssh)" - ), - ) - - -def create_parser(description_text: str) -> argparse.ArgumentParser: - """ - Create the top-level argument parser for pkgmgr. - """ - parser = argparse.ArgumentParser( - description=description_text, - formatter_class=argparse.RawTextHelpFormatter, - ) - subparsers = parser.add_subparsers( - dest="command", - help="Subcommands", - action=SortedSubParsersAction, - ) - - # ------------------------------------------------------------ - # install / update / deinstall / delete - # ------------------------------------------------------------ - install_parser = subparsers.add_parser( - "install", - help="Setup repository/repositories alias links to executables", - ) - add_install_update_arguments(install_parser) - - update_parser = subparsers.add_parser( - "update", - help="Update (pull + install) repository/repositories", - ) - add_install_update_arguments(update_parser) - update_parser.add_argument( - "--system", - action="store_true", - help="Include system update commands", - ) - - deinstall_parser = subparsers.add_parser( - "deinstall", - help="Remove alias links to repository/repositories", - ) - add_identifier_arguments(deinstall_parser) - - delete_parser = subparsers.add_parser( - "delete", - help="Delete repository/repositories alias links to executables", - ) - add_identifier_arguments(delete_parser) - - # ------------------------------------------------------------ - # create - # ------------------------------------------------------------ - create_cmd_parser = subparsers.add_parser( - "create", - help=( - "Create new repository entries: add them to the config if not " - "already present, initialize the local repository, and push " - "remotely if --remote is set." - ), - ) - add_identifier_arguments(create_cmd_parser) - create_cmd_parser.add_argument( - "--remote", - action="store_true", - help="If set, add the remote and push the initial commit.", - ) - - # ------------------------------------------------------------ - # status - # ------------------------------------------------------------ - status_parser = subparsers.add_parser( - "status", - help="Show status for repository/repositories or system", - ) - add_identifier_arguments(status_parser) - status_parser.add_argument( - "--system", - action="store_true", - help="Show system status", - ) - - # ------------------------------------------------------------ - # config - # ------------------------------------------------------------ - config_parser = subparsers.add_parser( - "config", - help="Manage configuration", - ) - config_subparsers = config_parser.add_subparsers( - dest="subcommand", - help="Config subcommands", - required=True, - ) - - config_show = config_subparsers.add_parser( - "show", - help="Show configuration", - ) - add_identifier_arguments(config_show) - - config_subparsers.add_parser( - "add", - help="Interactively add a new repository entry", - ) - - config_subparsers.add_parser( - "edit", - help="Edit configuration file with nano", - ) - - config_subparsers.add_parser( - "init", - help="Initialize user configuration by scanning the base directory", - ) - - config_delete = config_subparsers.add_parser( - "delete", - help="Delete repository entry from user config", - ) - add_identifier_arguments(config_delete) - - config_ignore = config_subparsers.add_parser( - "ignore", - help="Set ignore flag for repository entries in user config", - ) - add_identifier_arguments(config_ignore) - config_ignore.add_argument( - "--set", - choices=["true", "false"], - required=True, - help="Set ignore to true or false", - ) - - config_subparsers.add_parser( - "update", - help=( - "Update default config files in ~/.config/pkgmgr/ from the " - "installed pkgmgr package (does not touch config.yaml)." - ), - ) - - # ------------------------------------------------------------ - # path / explore / terminal / code / shell - # ------------------------------------------------------------ - path_parser = subparsers.add_parser( - "path", - help="Print the path(s) of repository/repositories", - ) - add_identifier_arguments(path_parser) - - explore_parser = subparsers.add_parser( - "explore", - help="Open repository in Nautilus file manager", - ) - add_identifier_arguments(explore_parser) - - terminal_parser = subparsers.add_parser( - "terminal", - help="Open repository in a new GNOME Terminal tab", - ) - add_identifier_arguments(terminal_parser) - - code_parser = subparsers.add_parser( - "code", - help="Open repository workspace with VS Code", - ) - add_identifier_arguments(code_parser) - - shell_parser = subparsers.add_parser( - "shell", - help="Execute a shell command in each repository", - ) - add_identifier_arguments(shell_parser) - shell_parser.add_argument( - "-c", - "--command", - nargs=argparse.REMAINDER, - dest="shell_command", - help=( - "The shell command (and its arguments) to execute in each " - "repository" - ), - default=[], - ) - - # ------------------------------------------------------------ - # branch - # ------------------------------------------------------------ - branch_parser = subparsers.add_parser( - "branch", - help="Branch-related utilities (e.g. open/close feature branches)", - ) - branch_subparsers = branch_parser.add_subparsers( - dest="subcommand", - help="Branch subcommands", - required=True, - ) - - branch_open = branch_subparsers.add_parser( - "open", - help="Create and push a new branch on top of a base branch", - ) - branch_open.add_argument( - "name", - nargs="?", - help=( - "Name of the new branch (optional; will be asked interactively " - "if omitted)" - ), - ) - branch_open.add_argument( - "--base", - default="main", - help="Base branch to create the new branch from (default: main)", - ) - - branch_close = branch_subparsers.add_parser( - "close", - help="Merge a feature branch into base and delete it", - ) - branch_close.add_argument( - "name", - nargs="?", - help=( - "Name of the branch to close (optional; current branch is used " - "if omitted)" - ), - ) - branch_close.add_argument( - "--base", - default="main", - help=( - "Base branch to merge into (default: main; falls back to master " - "internally if main does not exist)" - ), - ) - - # ------------------------------------------------------------ - # release - # ------------------------------------------------------------ - release_parser = subparsers.add_parser( - "release", - help=( - "Create a release for repository/ies by incrementing version " - "and updating the changelog." - ), - ) - release_parser.add_argument( - "release_type", - choices=["major", "minor", "patch"], - help="Type of version increment for the release (major, minor, patch).", - ) - release_parser.add_argument( - "-m", - "--message", - default=None, - help=( - "Optional release message to add to the changelog and tag." - ), - ) - # Generic selection / preview / list / extra_args - add_identifier_arguments(release_parser) - # Close current branch after successful release - release_parser.add_argument( - "--close", - action="store_true", - help=( - "Close the current branch after a successful release in each " - "repository, if it is not main/master." - ), - ) - # Force: skip preview+confirmation and run release directly - release_parser.add_argument( - "-f", - "--force", - action="store_true", - help=( - "Skip the interactive preview+confirmation step and run the " - "release directly." - ), - ) - - # ------------------------------------------------------------ - # version - # ------------------------------------------------------------ - version_parser = subparsers.add_parser( - "version", - help=( - "Show version information for repository/ies " - "(git tags, pyproject.toml, flake.nix, PKGBUILD, debian, spec, " - "Ansible Galaxy)." - ), - ) - add_identifier_arguments(version_parser) - - # ------------------------------------------------------------ - # changelog - # ------------------------------------------------------------ - changelog_parser = subparsers.add_parser( - "changelog", - help=( - "Show changelog derived from Git history. " - "By default, shows the changes between the last two SemVer tags." - ), - ) - changelog_parser.add_argument( - "range", - nargs="?", - default="", - help=( - "Optional tag or range (e.g. v1.2.3 or v1.2.0..v1.2.3). " - "If omitted, the changelog between the last two SemVer " - "tags is shown." - ), - ) - add_identifier_arguments(changelog_parser) - - # ------------------------------------------------------------ - # list - # ------------------------------------------------------------ - list_parser = subparsers.add_parser( - "list", - help="List all repositories with details and status", - ) - # dieselbe Selektionslogik wie bei install/update/etc.: - add_identifier_arguments(list_parser) - list_parser.add_argument( - "--status", - type=str, - default="", - help=( - "Filter repositories by status (case insensitive). " - "Use /regex/ for regular expressions." - ), - ) - list_parser.add_argument( - "--description", - action="store_true", - help=( - "Show an additional detailed section per repository " - "(description, homepage, tags, categories, paths)." - ), - ) - - - # ------------------------------------------------------------ - # make - # ------------------------------------------------------------ - make_parser = subparsers.add_parser( - "make", - help="Executes make commands", - ) - add_identifier_arguments(make_parser) - make_subparsers = make_parser.add_subparsers( - dest="subcommand", - help="Make subcommands", - required=True, - ) - - make_install = make_subparsers.add_parser( - "install", - help="Executes the make install command", - ) - add_identifier_arguments(make_install) - - make_deinstall = make_subparsers.add_parser( - "deinstall", - help="Executes the make deinstall command", - ) - add_identifier_arguments(make_deinstall) - - # ------------------------------------------------------------ - # Proxy commands (git, docker, docker compose, ...) - # ------------------------------------------------------------ - register_proxy_commands(subparsers) - - return parser diff --git a/src/pkgmgr/cli/parser/__init__.py b/src/pkgmgr/cli/parser/__init__.py new file mode 100644 index 0000000..ee974c1 --- /dev/null +++ b/src/pkgmgr/cli/parser/__init__.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from pkgmgr.cli.proxy import register_proxy_commands + +from .common import SortedSubParsersAction +from .install_update import add_install_update_subparsers +from .config_cmd import add_config_subparsers +from .navigation_cmd import add_navigation_subparsers +from .branch_cmd import add_branch_subparsers +from .release_cmd import add_release_subparser +from .version_cmd import add_version_subparser +from .changelog_cmd import add_changelog_subparser +from .list_cmd import add_list_subparser +from .make_cmd import add_make_subparsers +from .mirror_cmd import add_mirror_subparsers + + +def create_parser(description_text: str) -> argparse.ArgumentParser: + """ + Create the top-level argument parser for pkgmgr. + """ + parser = argparse.ArgumentParser( + description=description_text, + formatter_class=argparse.RawTextHelpFormatter, + ) + subparsers = parser.add_subparsers( + dest="command", + help="Subcommands", + action=SortedSubParsersAction, + ) + + # Core repo operations + add_install_update_subparsers(subparsers) + add_config_subparsers(subparsers) + + # Navigation / tooling around repos + add_navigation_subparsers(subparsers) + + # Branch & release workflow + add_branch_subparsers(subparsers) + add_release_subparser(subparsers) + + # Info commands + add_version_subparser(subparsers) + add_changelog_subparser(subparsers) + add_list_subparser(subparsers) + + # Make wrapper + add_make_subparsers(subparsers) + + # Mirror management + add_mirror_subparsers(subparsers) + + # Proxy commands (git, docker, docker compose, ...) + register_proxy_commands(subparsers) + + return parser + + +__all__ = [ + "create_parser", + "SortedSubParsersAction", +] diff --git a/src/pkgmgr/cli/parser/branch_cmd.py b/src/pkgmgr/cli/parser/branch_cmd.py new file mode 100644 index 0000000..e54af75 --- /dev/null +++ b/src/pkgmgr/cli/parser/branch_cmd.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + + +def add_branch_subparsers( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register branch command and its subcommands. + """ + branch_parser = subparsers.add_parser( + "branch", + help="Branch-related utilities (e.g. open/close feature branches)", + ) + branch_subparsers = branch_parser.add_subparsers( + dest="subcommand", + help="Branch subcommands", + required=True, + ) + + branch_open = branch_subparsers.add_parser( + "open", + help="Create and push a new branch on top of a base branch", + ) + branch_open.add_argument( + "name", + nargs="?", + help=( + "Name of the new branch (optional; will be asked interactively " + "if omitted)" + ), + ) + branch_open.add_argument( + "--base", + default="main", + help="Base branch to create the new branch from (default: main)", + ) + + branch_close = branch_subparsers.add_parser( + "close", + help="Merge a feature branch into base and delete it", + ) + branch_close.add_argument( + "name", + nargs="?", + help=( + "Name of the branch to close (optional; current branch is used " + "if omitted)" + ), + ) + branch_close.add_argument( + "--base", + default="main", + help=( + "Base branch to merge into (default: main; falls back to master " + "internally if main does not exist)" + ), + ) diff --git a/src/pkgmgr/cli/parser/changelog_cmd.py b/src/pkgmgr/cli/parser/changelog_cmd.py new file mode 100644 index 0000000..76e6123 --- /dev/null +++ b/src/pkgmgr/cli/parser/changelog_cmd.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_changelog_subparser( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register the changelog command. + """ + changelog_parser = subparsers.add_parser( + "changelog", + help=( + "Show changelog derived from Git history. " + "By default, shows the changes between the last two SemVer tags." + ), + ) + changelog_parser.add_argument( + "range", + nargs="?", + default="", + help=( + "Optional tag or range (e.g. v1.2.3 or v1.2.0..v1.2.3). " + "If omitted, the changelog between the last two SemVer " + "tags is shown." + ), + ) + add_identifier_arguments(changelog_parser) diff --git a/src/pkgmgr/cli/parser/common.py b/src/pkgmgr/cli/parser/common.py new file mode 100644 index 0000000..7d8143f --- /dev/null +++ b/src/pkgmgr/cli/parser/common.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + + +class SortedSubParsersAction(argparse._SubParsersAction): + """ + Subparsers action that keeps choices sorted alphabetically. + """ + + def add_parser(self, name, **kwargs): + parser = super().add_parser(name, **kwargs) + # Sort choices alphabetically by dest (subcommand name) + self._choices_actions.sort(key=lambda a: a.dest) + return parser + + +def add_identifier_arguments(subparser: argparse.ArgumentParser) -> None: + """ + Common identifier / selection arguments for many subcommands. + + Selection modes (mutual intent, not hard-enforced): + - identifiers (positional): select by alias / provider/account/repo + - --all: select all repositories + - --category / --string / --tag: filter-based selection on top + of the full repository set + """ + subparser.add_argument( + "identifiers", + nargs="*", + help=( + "Identifier(s) for repositories. " + "Default: Repository of current folder." + ), + ) + subparser.add_argument( + "--all", + action="store_true", + default=False, + help=( + "Apply the subcommand to all repositories in the config. " + "Some subcommands ask for confirmation. If you want to give this " + "confirmation for all repositories, pipe 'yes'. E.g: " + "yes | pkgmgr {subcommand} --all" + ), + ) + subparser.add_argument( + "--category", + nargs="+", + default=[], + help=( + "Filter repositories by category patterns derived from config " + "filenames or repo metadata (use filename without .yml/.yaml, " + "or /regex/ to use a regular expression)." + ), + ) + subparser.add_argument( + "--string", + default="", + help=( + "Filter repositories whose identifier / name / path contains this " + "substring (case-insensitive). Use /regex/ for regular expressions." + ), + ) + subparser.add_argument( + "--tag", + action="append", + default=[], + help=( + "Filter repositories by tag. Matches tags from the repository " + "collector and category tags. Use /regex/ for regular expressions." + ), + ) + subparser.add_argument( + "--preview", + action="store_true", + help="Preview changes without executing commands", + ) + subparser.add_argument( + "--list", + action="store_true", + help="List affected repositories (with preview or status)", + ) + subparser.add_argument( + "-a", + "--args", + nargs=argparse.REMAINDER, + dest="extra_args", + help="Additional parameters to be attached.", + default=[], + ) + + +def add_install_update_arguments(subparser: argparse.ArgumentParser) -> None: + """ + Common arguments for install/update commands. + """ + add_identifier_arguments(subparser) + subparser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Suppress warnings and info messages", + ) + subparser.add_argument( + "--no-verification", + action="store_true", + default=False, + help="Disable verification via commit/gpg", + ) + subparser.add_argument( + "--dependencies", + action="store_true", + help="Also pull and update dependencies", + ) + subparser.add_argument( + "--clone-mode", + choices=["ssh", "https", "shallow"], + default="ssh", + help=( + "Specify the clone mode: ssh, https, or shallow " + "(HTTPS shallow clone; default: ssh)" + ), + ) diff --git a/src/pkgmgr/cli/parser/config_cmd.py b/src/pkgmgr/cli/parser/config_cmd.py new file mode 100644 index 0000000..ecd15b6 --- /dev/null +++ b/src/pkgmgr/cli/parser/config_cmd.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_config_subparsers( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register config command and its subcommands. + """ + config_parser = subparsers.add_parser( + "config", + help="Manage configuration", + ) + config_subparsers = config_parser.add_subparsers( + dest="subcommand", + help="Config subcommands", + required=True, + ) + + config_show = config_subparsers.add_parser( + "show", + help="Show configuration", + ) + add_identifier_arguments(config_show) + + config_subparsers.add_parser( + "add", + help="Interactively add a new repository entry", + ) + + config_subparsers.add_parser( + "edit", + help="Edit configuration file with nano", + ) + + config_subparsers.add_parser( + "init", + help="Initialize user configuration by scanning the base directory", + ) + + config_delete = config_subparsers.add_parser( + "delete", + help="Delete repository entry from user config", + ) + add_identifier_arguments(config_delete) + + config_ignore = config_subparsers.add_parser( + "ignore", + help="Set ignore flag for repository entries in user config", + ) + add_identifier_arguments(config_ignore) + config_ignore.add_argument( + "--set", + choices=["true", "false"], + required=True, + help="Set ignore to true or false", + ) + + config_subparsers.add_parser( + "update", + help=( + "Update default config files in ~/.config/pkgmgr/ from the " + "installed pkgmgr package (does not touch config.yaml)." + ), + ) diff --git a/src/pkgmgr/cli/parser/install_update.py b/src/pkgmgr/cli/parser/install_update.py new file mode 100644 index 0000000..af78783 --- /dev/null +++ b/src/pkgmgr/cli/parser/install_update.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_install_update_arguments, add_identifier_arguments + + +def add_install_update_subparsers( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register install / update / deinstall / delete commands. + """ + install_parser = subparsers.add_parser( + "install", + help="Setup repository/repositories alias links to executables", + ) + add_install_update_arguments(install_parser) + + update_parser = subparsers.add_parser( + "update", + help="Update (pull + install) repository/repositories", + ) + add_install_update_arguments(update_parser) + update_parser.add_argument( + "--system", + action="store_true", + help="Include system update commands", + ) + + deinstall_parser = subparsers.add_parser( + "deinstall", + help="Remove alias links to repository/repositories", + ) + add_identifier_arguments(deinstall_parser) + + delete_parser = subparsers.add_parser( + "delete", + help="Delete repository/repositories alias links to executables", + ) + add_identifier_arguments(delete_parser) diff --git a/src/pkgmgr/cli/parser/list_cmd.py b/src/pkgmgr/cli/parser/list_cmd.py new file mode 100644 index 0000000..78a7085 --- /dev/null +++ b/src/pkgmgr/cli/parser/list_cmd.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_list_subparser( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register the list command. + """ + list_parser = subparsers.add_parser( + "list", + help="List all repositories with details and status", + ) + add_identifier_arguments(list_parser) + list_parser.add_argument( + "--status", + type=str, + default="", + help=( + "Filter repositories by status (case insensitive). " + "Use /regex/ for regular expressions." + ), + ) + list_parser.add_argument( + "--description", + action="store_true", + help=( + "Show an additional detailed section per repository " + "(description, homepage, tags, categories, paths)." + ), + ) diff --git a/src/pkgmgr/cli/parser/make_cmd.py b/src/pkgmgr/cli/parser/make_cmd.py new file mode 100644 index 0000000..0aebb18 --- /dev/null +++ b/src/pkgmgr/cli/parser/make_cmd.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_make_subparsers( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register make command and its subcommands. + """ + make_parser = subparsers.add_parser( + "make", + help="Executes make commands", + ) + add_identifier_arguments(make_parser) + make_subparsers = make_parser.add_subparsers( + dest="subcommand", + help="Make subcommands", + required=True, + ) + + make_install = make_subparsers.add_parser( + "install", + help="Executes the make install command", + ) + add_identifier_arguments(make_install) + + make_deinstall = make_subparsers.add_parser( + "deinstall", + help="Executes the make deinstall command", + ) + add_identifier_arguments(make_deinstall) diff --git a/src/pkgmgr/cli/parser/mirror_cmd.py b/src/pkgmgr/cli/parser/mirror_cmd.py new file mode 100644 index 0000000..346d019 --- /dev/null +++ b/src/pkgmgr/cli/parser/mirror_cmd.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_mirror_subparsers( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register mirror command and its subcommands (list, diff, merge, setup). + """ + mirror_parser = subparsers.add_parser( + "mirror", + help="Mirror-related utilities (list, diff, merge, setup)", + ) + mirror_subparsers = mirror_parser.add_subparsers( + dest="subcommand", + help="Mirror subcommands", + required=True, + ) + + # ------------------------------------------------------------------ + # mirror list + # ------------------------------------------------------------------ + mirror_list = mirror_subparsers.add_parser( + "list", + help="List configured mirrors for repositories", + ) + add_identifier_arguments(mirror_list) + mirror_list.add_argument( + "--source", + choices=["all", "config", "file", "resolved"], + default="all", + help="Which mirror source to show.", + ) + + # ------------------------------------------------------------------ + # mirror diff + # ------------------------------------------------------------------ + mirror_diff = mirror_subparsers.add_parser( + "diff", + help="Show differences between config mirrors and MIRRORS file", + ) + add_identifier_arguments(mirror_diff) + + # ------------------------------------------------------------------ + # mirror merge {config,file} {config,file} + # ------------------------------------------------------------------ + mirror_merge = mirror_subparsers.add_parser( + "merge", + help=( + "Merge mirrors between config and MIRRORS file " + "(example: pkgmgr mirror merge config file --all)" + ), + ) + + # First define merge direction positionals, then selection args. + mirror_merge.add_argument( + "source", + choices=["config", "file"], + help="Source of mirrors.", + ) + mirror_merge.add_argument( + "target", + choices=["config", "file"], + help="Target of mirrors.", + ) + + # Selection / filter / preview arguments + add_identifier_arguments(mirror_merge) + + mirror_merge.add_argument( + "--config-path", + help=( + "Path to the user config file to update. " + "If omitted, the global config path is used." + ), + ) + # Note: --preview, --all, --category, --tag, --list, etc. are provided + # by add_identifier_arguments(). + + # ------------------------------------------------------------------ + # mirror setup + # ------------------------------------------------------------------ + mirror_setup = mirror_subparsers.add_parser( + "setup", + help=( + "Setup mirror configuration for repositories.\n" + " --local → configure local Git (remotes, pushurls)\n" + " --remote → create remote repositories if missing\n" + "Default: both local and remote." + ), + ) + add_identifier_arguments(mirror_setup) + mirror_setup.add_argument( + "--local", + action="store_true", + help="Only configure the local Git repository.", + ) + mirror_setup.add_argument( + "--remote", + action="store_true", + help="Only operate on remote repositories.", + ) + # Note: --preview also comes from add_identifier_arguments(). diff --git a/src/pkgmgr/cli/parser/navigation_cmd.py b/src/pkgmgr/cli/parser/navigation_cmd.py new file mode 100644 index 0000000..364a620 --- /dev/null +++ b/src/pkgmgr/cli/parser/navigation_cmd.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_navigation_subparsers( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register path / explore / terminal / code / shell commands. + """ + path_parser = subparsers.add_parser( + "path", + help="Print the path(s) of repository/repositories", + ) + add_identifier_arguments(path_parser) + + explore_parser = subparsers.add_parser( + "explore", + help="Open repository in Nautilus file manager", + ) + add_identifier_arguments(explore_parser) + + terminal_parser = subparsers.add_parser( + "terminal", + help="Open repository in a new GNOME Terminal tab", + ) + add_identifier_arguments(terminal_parser) + + code_parser = subparsers.add_parser( + "code", + help="Open repository workspace with VS Code", + ) + add_identifier_arguments(code_parser) + + shell_parser = subparsers.add_parser( + "shell", + help="Execute a shell command in each repository", + ) + add_identifier_arguments(shell_parser) + shell_parser.add_argument( + "-c", + "--command", + nargs=argparse.REMAINDER, + dest="shell_command", + help=( + "The shell command (and its arguments) to execute in each " + "repository" + ), + default=[], + ) diff --git a/src/pkgmgr/cli/parser/release_cmd.py b/src/pkgmgr/cli/parser/release_cmd.py new file mode 100644 index 0000000..472be81 --- /dev/null +++ b/src/pkgmgr/cli/parser/release_cmd.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_release_subparser( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register the release command. + """ + release_parser = subparsers.add_parser( + "release", + help=( + "Create a release for repository/ies by incrementing version " + "and updating the changelog." + ), + ) + release_parser.add_argument( + "release_type", + choices=["major", "minor", "patch"], + help="Type of version increment for the release (major, minor, patch).", + ) + release_parser.add_argument( + "-m", + "--message", + default=None, + help=( + "Optional release message to add to the changelog and tag." + ), + ) + # Generic selection / preview / list / extra_args + add_identifier_arguments(release_parser) + # Close current branch after successful release + release_parser.add_argument( + "--close", + action="store_true", + help=( + "Close the current branch after a successful release in each " + "repository, if it is not main/master." + ), + ) + # Force: skip preview+confirmation and run release directly + release_parser.add_argument( + "-f", + "--force", + action="store_true", + help=( + "Skip the interactive preview+confirmation step and run the " + "release directly." + ), + ) diff --git a/src/pkgmgr/cli/parser/version_cmd.py b/src/pkgmgr/cli/parser/version_cmd.py new file mode 100644 index 0000000..c0c07a9 --- /dev/null +++ b/src/pkgmgr/cli/parser/version_cmd.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import argparse + +from .common import add_identifier_arguments + + +def add_version_subparser( + subparsers: argparse._SubParsersAction, +) -> None: + """ + Register the version command. + """ + version_parser = subparsers.add_parser( + "version", + help=( + "Show version information for repository/ies " + "(git tags, pyproject.toml, flake.nix, PKGBUILD, debian, spec, " + "Ansible Galaxy)." + ), + ) + add_identifier_arguments(version_parser) diff --git a/tests/e2e/test_mirror_commands.py b/tests/e2e/test_mirror_commands.py new file mode 100644 index 0000000..a808463 --- /dev/null +++ b/tests/e2e/test_mirror_commands.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +E2E integration tests for the `pkgmgr mirror` command family. + +This test class covers: + + - pkgmgr mirror --help + - pkgmgr mirror list --preview --all + - pkgmgr mirror diff --preview --all + - pkgmgr mirror merge config file --preview --all + - pkgmgr mirror setup --preview --all + +All of these subcommands are fully wired at CLI level and do not +require mocks. With --preview, merge and setup do not perform +destructive actions, making them safe for CI execution. +""" + +from __future__ import annotations + +import io +import runpy +import sys +import unittest +from contextlib import redirect_stdout, redirect_stderr + + +class TestIntegrationMirrorCommands(unittest.TestCase): + """ + E2E tests for `pkgmgr mirror` commands. + """ + + # ------------------------------------------------------------ + # Helper + # ------------------------------------------------------------ + def _run_pkgmgr(self, args: list[str]) -> str: + """ + Execute pkgmgr with the given arguments and return captured stdout+stderr. + + - Treat SystemExit(0) or SystemExit(None) as success. + - Convert non-zero exit codes into AssertionError. + """ + original_argv = list(sys.argv) + buffer = io.StringIO() + cmd_repr = "pkgmgr " + " ".join(args) + + try: + sys.argv = ["pkgmgr"] + args + + try: + with redirect_stdout(buffer), redirect_stderr(buffer): + runpy.run_module("main", run_name="__main__") + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else None + if code not in (0, None): + raise AssertionError( + f"{cmd_repr!r} failed with exit code {exc.code}. " + "Scroll up to inspect the pkgmgr output." + ) from exc + + return buffer.getvalue() + + finally: + sys.argv = original_argv + + # ------------------------------------------------------------ + # Tests + # ------------------------------------------------------------ + + def test_mirror_help(self) -> None: + """ + Ensure `pkgmgr mirror --help` runs successfully + and prints a usage message for the mirror command. + """ + output = self._run_pkgmgr(["mirror", "--help"]) + self.assertIn("usage:", output) + self.assertIn("pkgmgr mirror", output) + + def test_mirror_list_preview_all(self) -> None: + """ + `pkgmgr mirror list --preview --all` should run without error + and produce some output for the selected repositories. + """ + output = self._run_pkgmgr(["mirror", "list", "--preview", "--all"]) + # Do not assert specific wording; just ensure something was printed. + self.assertTrue( + output.strip(), + msg="Expected `pkgmgr mirror list --preview --all` to produce output.", + ) + + def test_mirror_diff_preview_all(self) -> None: + """ + `pkgmgr mirror diff --preview --all` should run without error + and produce some diagnostic output (diff header, etc.). + """ + output = self._run_pkgmgr(["mirror", "diff", "--preview", "--all"]) + self.assertTrue( + output.strip(), + msg="Expected `pkgmgr mirror diff --preview --all` to produce output.", + ) + + def test_mirror_merge_config_to_file_preview_all(self) -> None: + """ + `pkgmgr mirror merge config file --preview --all` should run without error. + + In preview mode this does not change either config or MIRRORS files; + it only prints what would be merged. + """ + output = self._run_pkgmgr( + [ + "mirror", + "merge", + "config", + "file", + "--preview", + "--all", + ] + ) + self.assertTrue( + output.strip(), + msg=( + "Expected `pkgmgr mirror merge config file --preview --all` " + "to produce output." + ), + ) + + def test_mirror_setup_preview_all(self) -> None: + """ + `pkgmgr mirror setup --preview --all` should run without error. + + In preview mode only the intended Git operations and remote + suggestions are printed; no real changes are made. + """ + output = self._run_pkgmgr(["mirror", "setup", "--preview", "--all"]) + self.assertTrue( + output.strip(), + msg="Expected `pkgmgr mirror setup --preview --all` to produce output.", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/mirror/__init__.py b/tests/unit/pkgmgr/actions/mirror/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/mirror/test_git_remote.py b/tests/unit/pkgmgr/actions/mirror/test_git_remote.py new file mode 100644 index 0000000..699ab28 --- /dev/null +++ b/tests/unit/pkgmgr/actions/mirror/test_git_remote.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import unittest + +from pkgmgr.actions.mirror.git_remote import ( + build_default_ssh_url, + determine_primary_remote_url, +) +from pkgmgr.actions.mirror.types import MirrorMap, Repository + + +class TestMirrorGitRemote(unittest.TestCase): + """ + Unit tests for SSH URL and primary remote selection logic. + """ + + def test_build_default_ssh_url_without_port(self) -> None: + repo: Repository = { + "provider": "github.com", + "account": "kevinveenbirkenbach", + "repository": "package-manager", + } + + url = build_default_ssh_url(repo) + self.assertEqual( + url, + "git@github.com:kevinveenbirkenbach/package-manager.git", + ) + + def test_build_default_ssh_url_with_port(self) -> None: + repo: Repository = { + "provider": "code.cymais.cloud", + "account": "kevinveenbirkenbach", + "repository": "pkgmgr", + "port": 2201, + } + + url = build_default_ssh_url(repo) + self.assertEqual( + url, + "ssh://git@code.cymais.cloud:2201/kevinveenbirkenbach/pkgmgr.git", + ) + + def test_build_default_ssh_url_missing_fields_returns_none(self) -> None: + repo: Repository = { + "provider": "github.com", + "account": "kevinveenbirkenbach", + # "repository" fehlt absichtlich + } + + url = build_default_ssh_url(repo) + self.assertIsNone(url) + + def test_determine_primary_remote_url_prefers_origin_in_resolved_mirrors( + self, + ) -> None: + repo: Repository = { + "provider": "github.com", + "account": "kevinveenbirkenbach", + "repository": "package-manager", + } + mirrors: MirrorMap = { + "origin": "git@github.com:kevinveenbirkenbach/package-manager.git", + "backup": "ssh://git@git.veen.world:2201/kevinveenbirkenbach/pkgmgr.git", + } + + url = determine_primary_remote_url(repo, mirrors) + self.assertEqual( + url, + "git@github.com:kevinveenbirkenbach/package-manager.git", + ) + + def test_determine_primary_remote_url_uses_any_mirror_if_no_origin(self) -> None: + repo: Repository = { + "provider": "github.com", + "account": "kevinveenbirkenbach", + "repository": "package-manager", + } + mirrors: MirrorMap = { + "backup": "ssh://git@git.veen.world:2201/kevinveenbirkenbach/pkgmgr.git", + "mirror2": "ssh://git@code.cymais.cloud:2201/kevinveenbirkenbach/pkgmgr.git", + } + + url = determine_primary_remote_url(repo, mirrors) + # Alphabetisch sortiert: backup, mirror2 → backup gewinnt + self.assertEqual( + url, + "ssh://git@git.veen.world:2201/kevinveenbirkenbach/pkgmgr.git", + ) + + def test_determine_primary_remote_url_falls_back_to_default_ssh(self) -> None: + repo: Repository = { + "provider": "github.com", + "account": "kevinveenbirkenbach", + "repository": "package-manager", + } + mirrors: MirrorMap = {} + + url = determine_primary_remote_url(repo, mirrors) + self.assertEqual( + url, + "git@github.com:kevinveenbirkenbach/package-manager.git", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/mirror/test_io.py b/tests/unit/pkgmgr/actions/mirror/test_io.py new file mode 100644 index 0000000..baa5cf1 --- /dev/null +++ b/tests/unit/pkgmgr/actions/mirror/test_io.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import os +import tempfile +import unittest + +from pkgmgr.actions.mirror.io import ( + load_config_mirrors, + read_mirrors_file, +) + + +class TestMirrorIO(unittest.TestCase): + """ + Unit tests for pkgmgr.actions.mirror.io helpers. + """ + + # ------------------------------------------------------------------ + # load_config_mirrors + # ------------------------------------------------------------------ + def test_load_config_mirrors_from_dict(self) -> None: + repo = { + "mirrors": { + "origin": "ssh://git@example.com/account/repo.git", + "backup": "ssh://git@backup/account/repo.git", + "empty": "", + "none": None, + } + } + + mirrors = load_config_mirrors(repo) + + self.assertEqual( + mirrors, + { + "origin": "ssh://git@example.com/account/repo.git", + "backup": "ssh://git@backup/account/repo.git", + }, + ) + + def test_load_config_mirrors_from_list(self) -> None: + repo = { + "mirrors": [ + {"name": "origin", "url": "ssh://git@example.com/account/repo.git"}, + {"name": "backup", "url": "ssh://git@backup/account/repo.git"}, + {"name": "", "url": "ssh://git@invalid/ignored.git"}, + {"name": "missing-url"}, + "not-a-dict", + ] + } + + mirrors = load_config_mirrors(repo) + + self.assertEqual( + mirrors, + { + "origin": "ssh://git@example.com/account/repo.git", + "backup": "ssh://git@backup/account/repo.git", + }, + ) + + def test_load_config_mirrors_empty_when_missing(self) -> None: + repo = {} + mirrors = load_config_mirrors(repo) + self.assertEqual(mirrors, {}) + + # ------------------------------------------------------------------ + # read_mirrors_file + # ------------------------------------------------------------------ + def test_read_mirrors_file_with_named_and_url_only_entries(self) -> None: + """ + Ensure that the MIRRORS file format is parsed correctly: + + - 'name url' → exact name + - 'url' → auto name derived from netloc (host[:port]), + with numeric suffix if duplicated. + """ + with tempfile.TemporaryDirectory() as tmpdir: + mirrors_path = os.path.join(tmpdir, "MIRRORS") + content = "\n".join( + [ + "# comment", + "", + "origin ssh://git@example.com/account/repo.git", + "https://github.com/kevinveenbirkenbach/package-manager", + "https://github.com/kevinveenbirkenbach/another-repo", + "ssh://git@git.veen.world:2201/kevinveenbirkenbach/pkgmgr.git", + ] + ) + + with open(mirrors_path, "w", encoding="utf-8") as fh: + fh.write(content + "\n") + + mirrors = read_mirrors_file(tmpdir) + + # 'origin' is preserved as given + self.assertIn("origin", mirrors) + self.assertEqual( + mirrors["origin"], + "ssh://git@example.com/account/repo.git", + ) + + # Two GitHub URLs → auto names: github.com, github.com2 + github_urls = { + mirrors.get("github.com"), + mirrors.get("github.com2"), + } + self.assertIn( + "https://github.com/kevinveenbirkenbach/package-manager", + github_urls, + ) + self.assertIn( + "https://github.com/kevinveenbirkenbach/another-repo", + github_urls, + ) + + # SSH-URL mit User-Teil → netloc ist "git@git.veen.world:2201" + # → host = "git@git.veen.world" + self.assertIn("git@git.veen.world", mirrors) + self.assertEqual( + mirrors["git@git.veen.world"], + "ssh://git@git.veen.world:2201/kevinveenbirkenbach/pkgmgr.git", + ) + + def test_read_mirrors_file_missing_returns_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + mirrors = read_mirrors_file(tmpdir) # no MIRRORS file + self.assertEqual(mirrors, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/mirror/test_setup_cmd.py b/tests/unit/pkgmgr/actions/mirror/test_setup_cmd.py new file mode 100644 index 0000000..06a7b51 --- /dev/null +++ b/tests/unit/pkgmgr/actions/mirror/test_setup_cmd.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.mirror.setup_cmd import _probe_mirror +from pkgmgr.core.git import GitError + + +class TestMirrorSetupCmd(unittest.TestCase): + """ + Unit tests for the non-destructive remote probing logic in setup_cmd. + """ + + @patch("pkgmgr.actions.mirror.setup_cmd.run_git") + def test_probe_mirror_success_returns_true_and_empty_message( + self, + mock_run_git, + ) -> None: + """ + If run_git returns successfully, _probe_mirror must report (True, ""). + """ + mock_run_git.return_value = "dummy-output" + + ok, message = _probe_mirror( + "ssh://git@code.cymais.cloud:2201/kevinveenbirkenbach/pkgmgr.git", + "/tmp/some-repo", + ) + + self.assertTrue(ok) + self.assertEqual(message, "") + mock_run_git.assert_called_once() + + @patch("pkgmgr.actions.mirror.setup_cmd.run_git") + def test_probe_mirror_failure_returns_false_and_error_message( + self, + mock_run_git, + ) -> None: + """ + If run_git raises GitError, _probe_mirror must report (False, ), + and not re-raise the exception. + """ + mock_run_git.side_effect = GitError("Git command failed (simulated)") + + ok, message = _probe_mirror( + "ssh://git@code.cymais.cloud:2201/kevinveenbirkenbach/pkgmgr.git", + "/tmp/some-repo", + ) + + self.assertFalse(ok) + self.assertIn("Git command failed", message) + mock_run_git.assert_called_once() + + +if __name__ == "__main__": + unittest.main()