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
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -3,5 +3,4 @@
|
|||||||
For the following checkout the implementation map:
|
For the following checkout the implementation map:
|
||||||
|
|
||||||
- Implement TAGS
|
- Implement TAGS
|
||||||
- Implement MIRROR
|
|
||||||
- Implement SIGNING_KEY
|
- Implement SIGNING_KEY
|
||||||
26
src/pkgmgr/actions/mirror/__init__.py
Normal file
26
src/pkgmgr/actions/mirror/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
31
src/pkgmgr/actions/mirror/context.py
Normal file
31
src/pkgmgr/actions/mirror/context.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
60
src/pkgmgr/actions/mirror/diff_cmd.py
Normal file
60
src/pkgmgr/actions/mirror/diff_cmd.py
Normal file
@@ -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()
|
||||||
141
src/pkgmgr/actions/mirror/git_remote.py
Normal file
141
src/pkgmgr/actions/mirror/git_remote.py
Normal file
@@ -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 '<unknown>'} to {url}"
|
||||||
|
)
|
||||||
|
run_command(cmd, cwd=repo_dir, preview=False)
|
||||||
115
src/pkgmgr/actions/mirror/io.py
Normal file
115
src/pkgmgr/actions/mirror/io.py
Normal file
@@ -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}")
|
||||||
46
src/pkgmgr/actions/mirror/list_cmd.py
Normal file
46
src/pkgmgr/actions/mirror/list_cmd.py
Normal file
@@ -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()
|
||||||
162
src/pkgmgr/actions/mirror/merge_cmd.py
Normal file
162
src/pkgmgr/actions/mirror/merge_cmd.py
Normal file
@@ -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}")
|
||||||
35
src/pkgmgr/actions/mirror/printing.py
Normal file
35
src/pkgmgr/actions/mirror/printing.py
Normal file
@@ -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)")
|
||||||
109
src/pkgmgr/actions/mirror/setup_cmd.py
Normal file
109
src/pkgmgr/actions/mirror/setup_cmd.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
32
src/pkgmgr/actions/mirror/types.py
Normal file
32
src/pkgmgr/actions/mirror/types.py
Normal file
@@ -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
|
||||||
@@ -6,6 +6,7 @@ from .version import handle_version
|
|||||||
from .make import handle_make
|
from .make import handle_make
|
||||||
from .changelog import handle_changelog
|
from .changelog import handle_changelog
|
||||||
from .branch import handle_branch
|
from .branch import handle_branch
|
||||||
|
from .mirror import handle_mirror_command
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"handle_repos_command",
|
"handle_repos_command",
|
||||||
@@ -16,4 +17,5 @@ __all__ = [
|
|||||||
"handle_make",
|
"handle_make",
|
||||||
"handle_changelog",
|
"handle_changelog",
|
||||||
"handle_branch",
|
"handle_branch",
|
||||||
|
"handle_mirror_command",
|
||||||
]
|
]
|
||||||
|
|||||||
118
src/pkgmgr/cli/commands/mirror.py
Normal file
118
src/pkgmgr/cli/commands/mirror.py
Normal file
@@ -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)
|
||||||
@@ -21,9 +21,9 @@ from pkgmgr.cli.commands import (
|
|||||||
handle_make,
|
handle_make,
|
||||||
handle_changelog,
|
handle_changelog,
|
||||||
handle_branch,
|
handle_branch,
|
||||||
|
handle_mirror_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _has_explicit_selection(args) -> bool:
|
def _has_explicit_selection(args) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if the user explicitly selected repositories via
|
Return True if the user explicitly selected repositories via
|
||||||
@@ -108,6 +108,7 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
"explore",
|
"explore",
|
||||||
"terminal",
|
"terminal",
|
||||||
"code",
|
"code",
|
||||||
|
"mirror",
|
||||||
]
|
]
|
||||||
|
|
||||||
if getattr(args, "command", None) in commands_with_selection:
|
if getattr(args, "command", None) in commands_with_selection:
|
||||||
@@ -174,5 +175,9 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
handle_branch(args, ctx)
|
handle_branch(args, ctx)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.command == "mirror":
|
||||||
|
handle_mirror_command(args, ctx, selected)
|
||||||
|
return
|
||||||
|
|
||||||
print(f"Unknown command: {args.command}")
|
print(f"Unknown command: {args.command}")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|||||||
@@ -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
|
|
||||||
68
src/pkgmgr/cli/parser/__init__.py
Normal file
68
src/pkgmgr/cli/parser/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
62
src/pkgmgr/cli/parser/branch_cmd.py
Normal file
62
src/pkgmgr/cli/parser/branch_cmd.py
Normal file
@@ -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)"
|
||||||
|
),
|
||||||
|
)
|
||||||
34
src/pkgmgr/cli/parser/changelog_cmd.py
Normal file
34
src/pkgmgr/cli/parser/changelog_cmd.py
Normal file
@@ -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)
|
||||||
127
src/pkgmgr/cli/parser/common.py
Normal file
127
src/pkgmgr/cli/parser/common.py
Normal file
@@ -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)"
|
||||||
|
),
|
||||||
|
)
|
||||||
72
src/pkgmgr/cli/parser/config_cmd.py
Normal file
72
src/pkgmgr/cli/parser/config_cmd.py
Normal file
@@ -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)."
|
||||||
|
),
|
||||||
|
)
|
||||||
44
src/pkgmgr/cli/parser/install_update.py
Normal file
44
src/pkgmgr/cli/parser/install_update.py
Normal file
@@ -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)
|
||||||
38
src/pkgmgr/cli/parser/list_cmd.py
Normal file
38
src/pkgmgr/cli/parser/list_cmd.py
Normal file
@@ -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)."
|
||||||
|
),
|
||||||
|
)
|
||||||
38
src/pkgmgr/cli/parser/make_cmd.py
Normal file
38
src/pkgmgr/cli/parser/make_cmd.py
Normal file
@@ -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)
|
||||||
110
src/pkgmgr/cli/parser/mirror_cmd.py
Normal file
110
src/pkgmgr/cli/parser/mirror_cmd.py
Normal file
@@ -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().
|
||||||
56
src/pkgmgr/cli/parser/navigation_cmd.py
Normal file
56
src/pkgmgr/cli/parser/navigation_cmd.py
Normal file
@@ -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=[],
|
||||||
|
)
|
||||||
57
src/pkgmgr/cli/parser/release_cmd.py
Normal file
57
src/pkgmgr/cli/parser/release_cmd.py
Normal file
@@ -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."
|
||||||
|
),
|
||||||
|
)
|
||||||
25
src/pkgmgr/cli/parser/version_cmd.py
Normal file
25
src/pkgmgr/cli/parser/version_cmd.py
Normal file
@@ -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)
|
||||||
143
tests/e2e/test_mirror_commands.py
Normal file
143
tests/e2e/test_mirror_commands.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user