From 1807949c6f2bdea464ce17411aa4dfc40c5c5e36 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 11 Dec 2025 16:10:19 +0100 Subject: [PATCH 1/6] Add mirror management commands and refactor CLI parser into modules - Implement new mirror actions: - list_mirrors: show mirrors from config, MIRRORS file, or merged view - diff_mirrors: compare config mirrors with MIRRORS file (ONLY IN CONFIG, ONLY IN FILE, URL MISMATCH, OK) - merge_mirrors: merge mirrors between config and MIRRORS file in both directions, with preview mode and user config writing via save_user_config - setup_mirrors: prepare local Git remotes (ensure origin) and print provider-URL suggestions for remote repositories - Introduce mirror utilities: - RepoMirrorContext with resolved_mirrors (config + file, file wins) - load_config_mirrors supporting dict and list-of-dicts shapes - read/write MIRRORS file with simple "name url" format and preview mode - helper for building default SSH URLs from provider/account/repository - Wire mirror commands into CLI: - Add handle_mirror_command and integrate "mirror" into dispatch - Add dedicated CLI parser modules under pkgmgr.cli.parser: * common, install_update, config_cmd, navigation_cmd, branch_cmd, release_cmd, version_cmd, changelog_cmd, list_cmd, make_cmd, mirror_cmd - Replace old flat cli/parser.py with modular parser package and SortedSubParsersAction in common.py - Update TODO.md to mark MIRROR as implemented - Add E2E tests for mirror commands: - test_mirror_help - test_mirror_list_preview_all - test_mirror_diff_preview_all - test_mirror_merge_config_to_file_preview_all - test_mirror_setup_preview_all https://chatgpt.com/share/693adee0-aa3c-800f-b72a-98473fdaf760 --- TODO.md | 1 - src/pkgmgr/actions/mirror/__init__.py | 26 ++ src/pkgmgr/actions/mirror/context.py | 31 ++ src/pkgmgr/actions/mirror/diff_cmd.py | 60 +++ src/pkgmgr/actions/mirror/git_remote.py | 141 +++++++ src/pkgmgr/actions/mirror/io.py | 115 ++++++ src/pkgmgr/actions/mirror/list_cmd.py | 46 +++ src/pkgmgr/actions/mirror/merge_cmd.py | 162 ++++++++ src/pkgmgr/actions/mirror/printing.py | 35 ++ src/pkgmgr/actions/mirror/setup_cmd.py | 109 +++++ src/pkgmgr/actions/mirror/types.py | 32 ++ src/pkgmgr/cli/commands/__init__.py | 2 + src/pkgmgr/cli/commands/mirror.py | 118 ++++++ src/pkgmgr/cli/dispatch.py | 7 +- src/pkgmgr/cli/parser.py | 505 ------------------------ src/pkgmgr/cli/parser/__init__.py | 68 ++++ src/pkgmgr/cli/parser/branch_cmd.py | 62 +++ src/pkgmgr/cli/parser/changelog_cmd.py | 34 ++ src/pkgmgr/cli/parser/common.py | 127 ++++++ src/pkgmgr/cli/parser/config_cmd.py | 72 ++++ src/pkgmgr/cli/parser/install_update.py | 44 +++ src/pkgmgr/cli/parser/list_cmd.py | 38 ++ src/pkgmgr/cli/parser/make_cmd.py | 38 ++ src/pkgmgr/cli/parser/mirror_cmd.py | 110 ++++++ src/pkgmgr/cli/parser/navigation_cmd.py | 56 +++ src/pkgmgr/cli/parser/release_cmd.py | 57 +++ src/pkgmgr/cli/parser/version_cmd.py | 25 ++ tests/e2e/test_mirror_commands.py | 143 +++++++ 28 files changed, 1757 insertions(+), 507 deletions(-) create mode 100644 src/pkgmgr/actions/mirror/__init__.py create mode 100644 src/pkgmgr/actions/mirror/context.py create mode 100644 src/pkgmgr/actions/mirror/diff_cmd.py create mode 100644 src/pkgmgr/actions/mirror/git_remote.py create mode 100644 src/pkgmgr/actions/mirror/io.py create mode 100644 src/pkgmgr/actions/mirror/list_cmd.py create mode 100644 src/pkgmgr/actions/mirror/merge_cmd.py create mode 100644 src/pkgmgr/actions/mirror/printing.py create mode 100644 src/pkgmgr/actions/mirror/setup_cmd.py create mode 100644 src/pkgmgr/actions/mirror/types.py create mode 100644 src/pkgmgr/cli/commands/mirror.py delete mode 100644 src/pkgmgr/cli/parser.py create mode 100644 src/pkgmgr/cli/parser/__init__.py create mode 100644 src/pkgmgr/cli/parser/branch_cmd.py create mode 100644 src/pkgmgr/cli/parser/changelog_cmd.py create mode 100644 src/pkgmgr/cli/parser/common.py create mode 100644 src/pkgmgr/cli/parser/config_cmd.py create mode 100644 src/pkgmgr/cli/parser/install_update.py create mode 100644 src/pkgmgr/cli/parser/list_cmd.py create mode 100644 src/pkgmgr/cli/parser/make_cmd.py create mode 100644 src/pkgmgr/cli/parser/mirror_cmd.py create mode 100644 src/pkgmgr/cli/parser/navigation_cmd.py create mode 100644 src/pkgmgr/cli/parser/release_cmd.py create mode 100644 src/pkgmgr/cli/parser/version_cmd.py create mode 100644 tests/e2e/test_mirror_commands.py 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/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..d49df75 --- /dev/null +++ b/src/pkgmgr/actions/mirror/git_remote.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import os +from typing import List, Optional + +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_origin_remote( + repo: Repository, + ctx: RepoMirrorContext, + preview: bool, +) -> None: + """ + Ensure that a usable 'origin' remote exists. + + Priority for choosing URL: + 1. resolved_mirrors["origin"] + 2. any resolved mirror (first by name) + 3. default SSH URL derived from provider/account/repository + """ + 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 url: + print( + "[WARN] Could not determine URL for 'origin' remote. " + "Please configure mirrors or provider/account/repository." + ) + return + + if not has_origin_remote(repo_dir): + 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) + return + + current = current_origin_url(repo_dir) + if current == url: + print(f"[INFO] 'origin' already points to {url} (no change needed).") + return + + cmd = f"git remote set-url origin {url}" + if preview: + print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}") + else: + print( + f"[INFO] Updating 'origin' remote in {repo_dir} " + f"from {current or ''} to {url}" + ) + run_command(cmd, cwd=repo_dir, preview=False) diff --git a/src/pkgmgr/actions/mirror/io.py b/src/pkgmgr/actions/mirror/io.py new file mode 100644 index 0000000..1b057b8 --- /dev/null +++ b/src/pkgmgr/actions/mirror/io.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os +from typing import List, Mapping + +from .types import MirrorMap, Repository + + +def load_config_mirrors(repo: Repository) -> MirrorMap: + """ + Load mirrors from the repository configuration entry. + + Supported shapes: + + repo["mirrors"] = { + "origin": "ssh://git@example.com/...", + "backup": "ssh://git@backup/...", + } + + or + + repo["mirrors"] = [ + {"name": "origin", "url": "ssh://git@example.com/..."}, + {"name": "backup", "url": "ssh://git@backup/..."}, + ] + """ + mirrors = repo.get("mirrors") or {} + result: MirrorMap = {} + + if isinstance(mirrors, dict): + for name, url in mirrors.items(): + if not url: + continue + result[str(name)] = str(url) + return result + + if isinstance(mirrors, list): + for entry in mirrors: + if not isinstance(entry, dict): + continue + name = entry.get("name") + url = entry.get("url") + if not name or not url: + continue + result[str(name)] = str(url) + + return result + + +def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap: + """ + Read mirrors from the MIRRORS file in the repository directory. + + Simple text format: + + # comment + origin ssh://git@example.com/account/repo.git + backup ssh://git@backup/account/repo.git + """ + 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) + if len(parts) != 2: + # Ignore malformed lines silently + continue + name, url = parts + 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: + """ + Write mirrors to MIRRORS file. + + Existing file is overwritten. In preview mode we only print what would + be written. + """ + path = os.path.join(repo_dir, filename) + lines: List[str] = [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}:") + if content: + print(content.rstrip()) + else: + print("(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..278a2cc --- /dev/null +++ b/src/pkgmgr/actions/mirror/setup_cmd.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import List + +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: + 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 _setup_remote_mirrors_for_repo( + repo: Repository, + repositories_base_dir: str, + all_repos: List[Repository], + preview: bool, +) -> None: + """ + Placeholder for remote-side setup. + + This is intentionally conservative: + - We *do not* call any provider APIs automatically here. + - Instead, we show what should exist and which URL should be created. + """ + ctx = build_context(repo, repositories_base_dir, all_repos) + resolved_m = ctx.resolved_mirrors + + primary_url = determine_primary_remote_url(repo, resolved_m) + + print("------------------------------------------------------------") + print(f"[MIRROR SETUP:REMOTE] {ctx.identifier}") + print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}") + print("------------------------------------------------------------") + + if not primary_url: + print( + "[WARN] Could not determine primary remote URL for this repository.\n" + " Please ensure provider/account/repository and/or mirrors " + "are set in your config." + ) + print() + return + + if preview: + print( + "[PREVIEW] Would ensure that a remote repository exists for:\n" + f" {primary_url}\n" + " (Provider-specific API calls not implemented yet.)" + ) + else: + print( + "[INFO] Remote-setup logic is not implemented yet.\n" + " Please create the remote repository manually if needed:\n" + f" {primary_url}\n" + ) + + 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: + - Placeholder that prints what should exist on the remote side. + Actual API calls to providers are not implemented yet. + """ + 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() From 7057ccfb95bff2b4b3c05a1000fe767e0b4be69b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 11 Dec 2025 16:17:10 +0100 Subject: [PATCH 2/6] CI: Always rebuild test images with --no-cache before container and E2E tests This ensures that GitHub Actions never reuses outdated Docker layers and that each test run starts from a fully clean environment. The workflows for test-container and test-e2e now explicitly invoke: distro="${{ matrix.distro }}" make build-no-cache before executing the actual tests. This aligns the CI behaviour with local testing, eliminates hidden caching differences, and guarantees deterministic test results across all distros. https://chatgpt.com/share/693ae07a-8c58-800f-88e6-254cdb00b676 --- .github/workflows/test-container.yml | 5 +++++ .github/workflows/test-e2e.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/test-container.yml b/.github/workflows/test-container.yml index a60c327..602a83d 100644 --- a/.github/workflows/test-container.yml +++ b/.github/workflows/test-container.yml @@ -19,6 +19,11 @@ jobs: - name: Show Docker version run: docker version + - name: Build test image with no cache (${{ matrix.distro }}) + run: | + set -euo pipefail + distro="${{ matrix.distro }}" make build-no-cache + - name: Run container tests (${{ matrix.distro }}) run: | set -euo pipefail diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 384b6fe..6842e37 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -19,6 +19,11 @@ jobs: - name: Show Docker version run: docker version + - name: Build test image with no cache (${{ matrix.distro }}) + run: | + set -euo pipefail + distro="${{ matrix.distro }}" make build-no-cache + - name: Run E2E tests via make (${{ matrix.distro }}) run: | set -euo pipefail From 2776d18a42cb68ddf94e2b7a01b0d3e34b658894 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 11 Dec 2025 16:31:00 +0100 Subject: [PATCH 3/6] Implemented arch support --- .github/workflows/test-container.yml | 5 ----- .github/workflows/test-e2e.yml | 5 ----- scripts/installation/run-package.sh | 6 ++++++ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-container.yml b/.github/workflows/test-container.yml index 602a83d..a60c327 100644 --- a/.github/workflows/test-container.yml +++ b/.github/workflows/test-container.yml @@ -19,11 +19,6 @@ jobs: - name: Show Docker version run: docker version - - name: Build test image with no cache (${{ matrix.distro }}) - run: | - set -euo pipefail - distro="${{ matrix.distro }}" make build-no-cache - - name: Run container tests (${{ matrix.distro }}) run: | set -euo pipefail diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 6842e37..384b6fe 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -19,11 +19,6 @@ jobs: - name: Show Docker version run: docker version - - name: Build test image with no cache (${{ matrix.distro }}) - run: | - set -euo pipefail - distro="${{ matrix.distro }}" make build-no-cache - - name: Run E2E tests via make (${{ matrix.distro }}) run: | set -euo pipefail 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 From 26c9d798142550fef9da3e3cb9918337275c164b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 11 Dec 2025 16:47:23 +0100 Subject: [PATCH 4/6] Added mirrors --- MIRRORS | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 MIRRORS diff --git a/MIRRORS b/MIRRORS new file mode 100644 index 0000000..032c296 --- /dev/null +++ b/MIRRORS @@ -0,0 +1,3 @@ +https://github.com/kevinveenbirkenbach/package-manager +https://git.veen.world/kevinveenbirkenbach/package-manager +https://code.infinito.nexus/kevinveenbirkenbach/package-manager \ No newline at end of file From 39b16b87a8066f51eb94e0d679de814297d57ddf Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 11 Dec 2025 17:27:57 +0100 Subject: [PATCH 5/6] CI: Add debugging instrumentation to identify container build/run anomalies - Added `git rev-parse HEAD` to test-container workflow to confirm the exact commit SHA used during CI runs. - Updated Dockerfile to print BASE_IMAGE and OS release information during build for better reproducibility diagnostics. - Extended test-container script to dump the first 40 lines of `docker image inspect` output, allowing verification of the image ID, creation time, and applied build args. These additions help trace discrepancies between local builds and GitHub Actions, ensuring we can detect mismatches in commit SHA, base image, or container metadata. https://chatgpt.com/share/693ae07a-8c58-800f-88e6-254cdb00b676 --- .github/workflows/test-container.yml | 3 +++ Dockerfile | 5 ++++- scripts/test/test-container.sh | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) 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/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 From f3c5460e486923ca29c12e61dc89bbbcd2e032c7 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 11 Dec 2025 17:49:31 +0100 Subject: [PATCH 6/6] feat(mirror): support SSH MIRRORS, multi-push origin and remote probe - Switch MIRRORS to SSH-based URLs including custom ports/domains (GitHub, git.veen.world, code.cymais.cloud) - Extend mirror IO: - load_config_mirrors filters empty values - read_mirrors_file now supports: * "name url" lines * "url" lines with auto-generated names from URL host (host[:port]) - write_mirrors_file prints full preview content - Enhance git_remote: - determine_primary_remote_url used for origin bootstrap - ensure_origin_remote keeps existing origin URL and adds all mirror URLs as additional push URLs - add is_remote_reachable() helper based on `git ls-remote --exit-code` - Implement non-destructive remote mirror checks in setup_cmd: - `_probe_mirror()` wraps `git ls-remote` and returns (ok, message) - `pkgmgr mirror setup --remote` now probes each mirror URL and prints [OK]/[WARN] with details instead of placeholder text - Add unit tests for mirror actions: - test_git_remote: default SSH URL building and primary URL selection - test_io: config + MIRRORS parsing including auto-named URL-only entries - test_setup_cmd: probe_mirror success/failure handling https://chatgpt.com/share/693adee0-aa3c-800f-b72a-98473fdaf760 --- MIRRORS | 6 +- src/pkgmgr/actions/mirror/git_remote.py | 96 +++++++++---- src/pkgmgr/actions/mirror/io.py | 77 ++++------ src/pkgmgr/actions/mirror/setup_cmd.py | 106 ++++++++++---- tests/unit/pkgmgr/actions/mirror/__init__.py | 0 .../pkgmgr/actions/mirror/test_git_remote.py | 110 ++++++++++++++ tests/unit/pkgmgr/actions/mirror/test_io.py | 135 ++++++++++++++++++ .../pkgmgr/actions/mirror/test_setup_cmd.py | 59 ++++++++ 8 files changed, 485 insertions(+), 104 deletions(-) create mode 100644 tests/unit/pkgmgr/actions/mirror/__init__.py create mode 100644 tests/unit/pkgmgr/actions/mirror/test_git_remote.py create mode 100644 tests/unit/pkgmgr/actions/mirror/test_io.py create mode 100644 tests/unit/pkgmgr/actions/mirror/test_setup_cmd.py diff --git a/MIRRORS b/MIRRORS index 032c296..cdd8bf4 100644 --- a/MIRRORS +++ b/MIRRORS @@ -1,3 +1,3 @@ -https://github.com/kevinveenbirkenbach/package-manager -https://git.veen.world/kevinveenbirkenbach/package-manager -https://code.infinito.nexus/kevinveenbirkenbach/package-manager \ No newline at end of file +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/src/pkgmgr/actions/mirror/git_remote.py b/src/pkgmgr/actions/mirror/git_remote.py index d49df75..9087deb 100644 --- a/src/pkgmgr/actions/mirror/git_remote.py +++ b/src/pkgmgr/actions/mirror/git_remote.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import List, Optional +from typing import List, Optional, Set from pkgmgr.core.command.run import run_command from pkgmgr.core.git import GitError, run_git @@ -87,18 +87,41 @@ def has_origin_remote(repo_dir: str) -> bool: 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. - - Priority for choosing URL: - 1. resolved_mirrors["origin"] - 2. any resolved mirror (first by name) - 3. default SSH URL derived from provider/account/repository + Ensure that a usable 'origin' remote exists and has all push URLs. """ repo_dir = ctx.repo_dir resolved_mirrors = ctx.resolved_mirrors @@ -109,33 +132,48 @@ def ensure_origin_remote( url = determine_primary_remote_url(repo, resolved_mirrors) - if not url: - print( - "[WARN] Could not determine URL for 'origin' remote. " - "Please configure mirrors or provider/account/repository." - ) - return - 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) - return - - current = current_origin_url(repo_dir) - if current == url: - print(f"[INFO] 'origin' already points to {url} (no change needed).") - return - - cmd = f"git remote set-url origin {url}" - if preview: - print(f"[PREVIEW] Would run in {repo_dir!r}: {cmd}") else: - print( - f"[INFO] Updating 'origin' remote in {repo_dir} " - f"from {current or ''} to {url}" - ) - run_command(cmd, cwd=repo_dir, preview=False) + 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 index 1b057b8..1b02455 100644 --- a/src/pkgmgr/actions/mirror/io.py +++ b/src/pkgmgr/actions/mirror/io.py @@ -1,61 +1,38 @@ 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: - """ - Load mirrors from the repository configuration entry. - - Supported shapes: - - repo["mirrors"] = { - "origin": "ssh://git@example.com/...", - "backup": "ssh://git@backup/...", - } - - or - - repo["mirrors"] = [ - {"name": "origin", "url": "ssh://git@example.com/..."}, - {"name": "backup", "url": "ssh://git@backup/..."}, - ] - """ mirrors = repo.get("mirrors") or {} result: MirrorMap = {} if isinstance(mirrors, dict): for name, url in mirrors.items(): - if not url: - continue - result[str(name)] = str(url) + if url: + result[str(name)] = str(url) return result if isinstance(mirrors, list): for entry in mirrors: - if not isinstance(entry, dict): - continue - name = entry.get("name") - url = entry.get("url") - if not name or not url: - continue - result[str(name)] = str(url) + 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: """ - Read mirrors from the MIRRORS file in the repository directory. - - Simple text format: - - # comment - origin ssh://git@example.com/account/repo.git - backup ssh://git@backup/account/repo.git + Supports: + NAME URL + URL → auto name = hostname """ path = os.path.join(repo_dir, filename) mirrors: MirrorMap = {} @@ -71,10 +48,24 @@ def read_mirrors_file(repo_dir: str, filename: str = "MIRRORS") -> MirrorMap: continue parts = stripped.split(None, 1) - if len(parts) != 2: - # Ignore malformed lines silently + + # 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 - name, url = parts + mirrors[name] = url except OSError as exc: print(f"[WARN] Could not read MIRRORS file at {path}: {exc}") @@ -88,22 +79,14 @@ def write_mirrors_file( filename: str = "MIRRORS", preview: bool = False, ) -> None: - """ - Write mirrors to MIRRORS file. - Existing file is overwritten. In preview mode we only print what would - be written. - """ path = os.path.join(repo_dir, filename) - lines: List[str] = [f"{name} {url}" for name, url in sorted(mirrors.items())] + 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}:") - if content: - print(content.rstrip()) - else: - print("(empty)") + print(content or "(empty)") return try: diff --git a/src/pkgmgr/actions/mirror/setup_cmd.py b/src/pkgmgr/actions/mirror/setup_cmd.py index 278a2cc..b4d1cbe 100644 --- a/src/pkgmgr/actions/mirror/setup_cmd.py +++ b/src/pkgmgr/actions/mirror/setup_cmd.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import List +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 @@ -13,6 +15,9 @@ def _setup_local_mirrors_for_repo( 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("------------------------------------------------------------") @@ -24,6 +29,27 @@ def _setup_local_mirrors_for_repo( 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, @@ -31,45 +57,75 @@ def _setup_remote_mirrors_for_repo( preview: bool, ) -> None: """ - Placeholder for remote-side setup. + Remote-side setup / validation. - This is intentionally conservative: - - We *do not* call any provider APIs automatically here. - - Instead, we show what should exist and which URL should be created. + 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 - primary_url = determine_primary_remote_url(repo, resolved_m) - print("------------------------------------------------------------") print(f"[MIRROR SETUP:REMOTE] {ctx.identifier}") print(f"[MIRROR SETUP:REMOTE] dir: {ctx.repo_dir}") print("------------------------------------------------------------") - if not primary_url: + 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( - "[WARN] Could not determine primary remote URL for this repository.\n" - " Please ensure provider/account/repository and/or mirrors " - "are set in your config." + "[INFO] Remote checks are non-destructive and only use `git ls-remote` " + "to probe mirror URLs." ) print() return - if preview: - print( - "[PREVIEW] Would ensure that a remote repository exists for:\n" - f" {primary_url}\n" - " (Provider-specific API calls not implemented yet.)" - ) - else: - print( - "[INFO] Remote-setup logic is not implemented yet.\n" - " Please create the remote repository manually if needed:\n" - f" {primary_url}\n" - ) + # 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( @@ -88,8 +144,8 @@ def setup_mirrors( points to a reasonable URL). remote: - - Placeholder that prints what should exist on the remote side. - Actual API calls to providers are not implemented yet. + - 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: 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()