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