diff --git a/flake.nix b/flake.nix index e225217..f026f60 100644 --- a/flake.nix +++ b/flake.nix @@ -49,6 +49,7 @@ # Runtime dependencies (matches [project.dependencies] in pyproject.toml) propagatedBuildInputs = [ pyPkgs.pyyaml + pyPkgs.jinja2 pyPkgs.pip ]; @@ -78,6 +79,7 @@ pythonWithDeps = python.withPackages (ps: [ ps.pip ps.pyyaml + ps.jinja2 ]); in { diff --git a/pyproject.toml b/pyproject.toml index ab487e9..90e4c70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ authors = [ dependencies = [ "PyYAML>=6.0", "tomli; python_version < \"3.11\"", + "jinja2>=3.1" ] [project.urls] diff --git a/src/pkgmgr/actions/repository/create.py b/src/pkgmgr/actions/repository/create.py index 8993cf3..6aeb601 100644 --- a/src/pkgmgr/actions/repository/create.py +++ b/src/pkgmgr/actions/repository/create.py @@ -1,143 +1,257 @@ +from __future__ import annotations + import os +import re import subprocess +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse + import yaml + +from pkgmgr.actions.mirror.io import write_mirrors_file +from pkgmgr.actions.mirror.setup_cmd import setup_mirrors +from pkgmgr.actions.repository.scaffold import render_default_templates from pkgmgr.core.command.alias import generate_alias from pkgmgr.core.config.save import save_user_config -def create_repo(identifier, config_merged, user_config_path, bin_dir, remote=False, preview=False): - """ - Creates a new repository by performing the following steps: - - 1. Parses the identifier (provider:port/account/repository) and adds a new entry to the user config - if it is not already present. The provider part is split into provider and port (if provided). - 2. Creates the local repository directory and initializes a Git repository. - 3. If --remote is set, checks for an existing "origin" remote (removing it if found), - adds the remote using a URL built from provider, port, account, and repository, - creates an initial commit (e.g. with a README.md), and pushes to the remote. - The push is attempted on both "main" and "master" branches. - """ - parts = identifier.split("/") +Repository = Dict[str, Any] + +_NAME_RE = re.compile(r"^[a-z0-9_-]+$") + + +@dataclass(frozen=True) +class RepoParts: + host: str + port: Optional[str] + owner: str + name: str + + +def _run(cmd: str, cwd: str, preview: bool) -> None: + if preview: + print(f"[Preview] Would run in {cwd}: {cmd}") + return + subprocess.run(cmd, cwd=cwd, shell=True, check=True) + + +def _git_get(key: str) -> str: + try: + out = subprocess.run( + f"git config --get {key}", + shell=True, + check=False, + capture_output=True, + text=True, + ) + return (out.stdout or "").strip() + except Exception: + return "" + + +def _split_host_port(host_with_port: str) -> Tuple[str, Optional[str]]: + if ":" in host_with_port: + host, port = host_with_port.split(":", 1) + return host, port or None + return host_with_port, None + + +def _strip_git_suffix(name: str) -> str: + return name[:-4] if name.endswith(".git") else name + + +def _parse_git_url(url: str) -> RepoParts: + if url.startswith("git@") and "://" not in url: + left, right = url.split(":", 1) + host = left.split("@", 1)[1] + path = right.lstrip("/") + owner, name = path.split("/", 1) + return RepoParts(host=host, port=None, owner=owner, name=_strip_git_suffix(name)) + + parsed = urlparse(url) + host = (parsed.hostname or "").strip() + port = str(parsed.port) if parsed.port else None + path = (parsed.path or "").strip("/") + + if not host or not path or "/" not in path: + raise ValueError(f"Could not parse git URL: {url}") + + owner, name = path.split("/", 1) + return RepoParts(host=host, port=port, owner=owner, name=_strip_git_suffix(name)) + + +def _parse_identifier(identifier: str) -> RepoParts: + ident = identifier.strip() + + if "://" in ident or ident.startswith("git@"): + return _parse_git_url(ident) + + parts = ident.split("/") if len(parts) != 3: - print("Identifier must be in the format 'provider:port/account/repository' (port is optional).") + raise ValueError("Identifier must be URL or 'provider(:port)/owner/repo'.") + + host_with_port, owner, name = parts + host, port = _split_host_port(host_with_port) + return RepoParts(host=host, port=port, owner=owner, name=name) + + +def _ensure_valid_repo_name(name: str) -> None: + if not name or not _NAME_RE.fullmatch(name): + raise ValueError("Repository name must match: lowercase a-z, 0-9, '_' and '-'.") + + +def _repo_homepage(host: str, owner: str, name: str) -> str: + return f"https://{host}/{owner}/{name}" + + +def _build_default_primary_url(parts: RepoParts) -> str: + if parts.port: + return f"ssh://git@{parts.host}:{parts.port}/{parts.owner}/{parts.name}.git" + return f"git@{parts.host}:{parts.owner}/{parts.name}.git" + + +def _write_default_mirrors(repo_dir: str, primary: str, name: str, preview: bool) -> None: + mirrors = {"origin": primary, "pypi": f"https://pypi.org/project/{name}/"} + write_mirrors_file(repo_dir, mirrors, preview=preview) + + +def _git_init_and_initial_commit(repo_dir: str, preview: bool) -> None: + _run("git init", cwd=repo_dir, preview=preview) + _run("git add -A", cwd=repo_dir, preview=preview) + + if preview: + print(f'[Preview] Would run in {repo_dir}: git commit -m "Initial commit"') return - provider_with_port, account, repository = parts - # Split provider and port if a colon is present. - if ":" in provider_with_port: - provider_name, port = provider_with_port.split(":", 1) - else: - provider_name = provider_with_port - port = None + subprocess.run('git commit -m "Initial commit"', cwd=repo_dir, shell=True, check=False) - # Check if the repository is already present in the merged config (including port) - exists = False - for repo in config_merged.get("repositories", []): - if (repo.get("provider") == provider_name and - repo.get("account") == account and - repo.get("repository") == repository): - exists = True - print(f"Repository {identifier} already exists in the configuration.") - break + +def _git_push_main_or_master(repo_dir: str, preview: bool) -> None: + _run("git branch -M main", cwd=repo_dir, preview=preview) + try: + _run("git push -u origin main", cwd=repo_dir, preview=preview) + return + except subprocess.CalledProcessError: + pass + + try: + _run("git branch -M master", cwd=repo_dir, preview=preview) + _run("git push -u origin master", cwd=repo_dir, preview=preview) + except subprocess.CalledProcessError as exc: + print(f"[WARN] Push failed: {exc}") + + +def create_repo( + identifier: str, + config_merged: Dict[str, Any], + user_config_path: str, + bin_dir: str, + *, + remote: bool = False, + preview: bool = False, +) -> None: + parts = _parse_identifier(identifier) + _ensure_valid_repo_name(parts.name) + + directories = config_merged.get("directories") or {} + base_dir = os.path.expanduser(str(directories.get("repositories", "~/Repositories"))) + repo_dir = os.path.join(base_dir, parts.host, parts.owner, parts.name) + + author_name = _git_get("user.name") or "Unknown Author" + author_email = _git_get("user.email") or "unknown@example.invalid" + + homepage = _repo_homepage(parts.host, parts.owner, parts.name) + primary_url = _build_default_primary_url(parts) + + repositories = config_merged.get("repositories") or [] + exists = any( + ( + r.get("provider") == parts.host + and r.get("account") == parts.owner + and r.get("repository") == parts.name + ) + for r in repositories + ) if not exists: - # Create a new entry with an automatically generated alias. - new_entry = { - "provider": provider_name, - "port": port, - "account": account, - "repository": repository, - "alias": generate_alias({"repository": repository, "provider": provider_name, "account": account}, bin_dir, existing_aliases=set()), - "verified": {} # No initial verification info + new_entry: Repository = { + "provider": parts.host, + "port": parts.port, + "account": parts.owner, + "repository": parts.name, + "homepage": homepage, + "alias": generate_alias( + {"repository": parts.name, "provider": parts.host, "account": parts.owner}, + bin_dir, + existing_aliases=set(), + ), + "verified": {}, } - # Load or initialize the user configuration. + if os.path.exists(user_config_path): - with open(user_config_path, "r") as f: + with open(user_config_path, "r", encoding="utf-8") as f: user_config = yaml.safe_load(f) or {} else: user_config = {"repositories": []} + user_config.setdefault("repositories", []) user_config["repositories"].append(new_entry) - save_user_config(user_config, user_config_path) - print(f"Repository {identifier} added to the configuration.") - # Also update the merged configuration object. - config_merged.setdefault("repositories", []).append(new_entry) - # Create the local repository directory based on the configured base directory. - base_dir = os.path.expanduser(config_merged["directories"]["repositories"]) - repo_dir = os.path.join(base_dir, provider_name, account, repository) - if not os.path.exists(repo_dir): - os.makedirs(repo_dir, exist_ok=True) - print(f"Local repository directory created: {repo_dir}") - else: - print(f"Local repository directory already exists: {repo_dir}") - - # Initialize a Git repository if not already initialized. - if not os.path.exists(os.path.join(repo_dir, ".git")): - cmd_init = "git init" if preview: - print(f"[Preview] Would execute: '{cmd_init}' in {repo_dir}") + print(f"[Preview] Would save user config: {user_config_path}") else: - subprocess.run(cmd_init, cwd=repo_dir, shell=True, check=True) - print(f"Git repository initialized in {repo_dir}.") + save_user_config(user_config, user_config_path) + + config_merged.setdefault("repositories", []).append(new_entry) + repo = new_entry + print(f"[INFO] Added repository to configuration: {parts.host}/{parts.owner}/{parts.name}") else: - print("Git repository is already initialized.") + repo = next( + r + for r in repositories + if ( + r.get("provider") == parts.host + and r.get("account") == parts.owner + and r.get("repository") == parts.name + ) + ) + print(f"[INFO] Repository already in configuration: {parts.host}/{parts.owner}/{parts.name}") + + if preview: + print(f"[Preview] Would ensure directory exists: {repo_dir}") + else: + os.makedirs(repo_dir, exist_ok=True) + + tpl_context = { + "provider": parts.host, + "port": parts.port, + "account": parts.owner, + "repository": parts.name, + "homepage": homepage, + "author_name": author_name, + "author_email": author_email, + "license_text": f"All rights reserved by {author_name}", + "primary_remote": primary_url, + } + + render_default_templates(repo_dir, context=tpl_context, preview=preview) + _git_init_and_initial_commit(repo_dir, preview=preview) + + _write_default_mirrors(repo_dir, primary=primary_url, name=parts.name, preview=preview) + + repo.setdefault("mirrors", {}) + repo["mirrors"].setdefault("origin", primary_url) + repo["mirrors"].setdefault("pypi", f"https://pypi.org/project/{parts.name}/") + + setup_mirrors( + selected_repos=[repo], + repositories_base_dir=base_dir, + all_repos=config_merged.get("repositories", []), + preview=preview, + local=True, + remote=True, + ensure_remote=bool(remote), + ) if remote: - # Create a README.md if it does not exist to have content for an initial commit. - readme_path = os.path.join(repo_dir, "README.md") - if not os.path.exists(readme_path): - if preview: - print(f"[Preview] Would create README.md in {repo_dir}.") - else: - with open(readme_path, "w") as f: - f.write(f"# {repository}\n") - subprocess.run("git add README.md", cwd=repo_dir, shell=True, check=True) - subprocess.run('git commit -m "Initial commit"', cwd=repo_dir, shell=True, check=True) - print("README.md created and initial commit made.") - - # Build the remote URL. - if provider_name.lower() == "github.com": - remote_url = f"git@{provider_name}:{account}/{repository}.git" - else: - if port: - remote_url = f"ssh://git@{provider_name}:{port}/{account}/{repository}.git" - else: - remote_url = f"ssh://git@{provider_name}/{account}/{repository}.git" - - # Check if the remote "origin" already exists. - cmd_list = "git remote" - if preview: - print(f"[Preview] Would check for existing remotes in {repo_dir}") - remote_exists = False # Assume no remote in preview mode. - else: - result = subprocess.run(cmd_list, cwd=repo_dir, shell=True, capture_output=True, text=True, check=True) - remote_list = result.stdout.strip().split() - remote_exists = "origin" in remote_list - - if remote_exists: - # Remove the existing remote "origin". - cmd_remove = "git remote remove origin" - if preview: - print(f"[Preview] Would execute: '{cmd_remove}' in {repo_dir}") - else: - subprocess.run(cmd_remove, cwd=repo_dir, shell=True, check=True) - print("Existing remote 'origin' removed.") - - # Now add the new remote. - cmd_remote = f"git remote add origin {remote_url}" - if preview: - print(f"[Preview] Would execute: '{cmd_remote}' in {repo_dir}") - else: - try: - subprocess.run(cmd_remote, cwd=repo_dir, shell=True, check=True) - print(f"Remote 'origin' added: {remote_url}") - except subprocess.CalledProcessError: - print(f"Failed to add remote using URL: {remote_url}.") - - # Push the initial commit to the remote repository - cmd_push = "git push -u origin master" - if preview: - print(f"[Preview] Would execute: '{cmd_push}' in {repo_dir}") - else: - subprocess.run(cmd_push, cwd=repo_dir, shell=True, check=True) - print("Initial push to the remote repository completed.") \ No newline at end of file + _git_push_main_or_master(repo_dir, preview=preview) diff --git a/src/pkgmgr/actions/repository/scaffold.py b/src/pkgmgr/actions/repository/scaffold.py new file mode 100644 index 0000000..fa9ad6a --- /dev/null +++ b/src/pkgmgr/actions/repository/scaffold.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Any, Dict, Optional + +try: + from jinja2 import Environment, FileSystemLoader, StrictUndefined +except Exception as exc: # pragma: no cover + Environment = None # type: ignore[assignment] + FileSystemLoader = None # type: ignore[assignment] + StrictUndefined = None # type: ignore[assignment] + _JINJA_IMPORT_ERROR = exc +else: + _JINJA_IMPORT_ERROR = None + + +def _repo_root_from_here(anchor: Optional[Path] = None) -> str: + """ + Prefer git root (robust in editable installs / different layouts). + Fallback to a conservative relative parent lookup. + """ + here = (anchor or Path(__file__)).resolve().parent + try: + r = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(here), + check=False, + capture_output=True, + text=True, + ) + if r.returncode == 0: + top = (r.stdout or "").strip() + if top: + return top + except Exception: + pass + + # Fallback: src/pkgmgr/actions/repository/scaffold.py -> = parents[5] + p = (anchor or Path(__file__)).resolve() + if len(p.parents) < 6: + raise RuntimeError(f"Unexpected path depth for: {p}") + return str(p.parents[5]) + + +def _templates_dir() -> str: + return os.path.join(_repo_root_from_here(), "templates", "default") + + +def render_default_templates( + repo_dir: str, + *, + context: Dict[str, Any], + preview: bool, +) -> None: + """ + Render templates/default/*.j2 into repo_dir. + Keeps create.py clean: create.py calls this function only. + """ + tpl_dir = _templates_dir() + if not os.path.isdir(tpl_dir): + raise RuntimeError(f"Templates directory not found: {tpl_dir}") + + # Preview mode: do not require Jinja2 at all. We only print planned outputs. + if preview: + for root, _, files in os.walk(tpl_dir): + for fn in files: + if not fn.endswith(".j2"): + continue + abs_src = os.path.join(root, fn) + rel_src = os.path.relpath(abs_src, tpl_dir) + rel_out = rel_src[:-3] + print(f"[Preview] Would render template: {rel_src} -> {rel_out}") + return + + if Environment is None or FileSystemLoader is None or StrictUndefined is None: + raise RuntimeError( + "Jinja2 is required for repo templates but is not available. " + f"Import error: {_JINJA_IMPORT_ERROR}" + ) + + env = Environment( + loader=FileSystemLoader(tpl_dir), + undefined=StrictUndefined, + autoescape=False, + keep_trailing_newline=True, + ) + + for root, _, files in os.walk(tpl_dir): + for fn in files: + if not fn.endswith(".j2"): + continue + + abs_src = os.path.join(root, fn) + rel_src = os.path.relpath(abs_src, tpl_dir) + rel_out = rel_src[:-3] + abs_out = os.path.join(repo_dir, rel_out) + + os.makedirs(os.path.dirname(abs_out), exist_ok=True) + template = env.get_template(rel_src) + rendered = template.render(**context) + + with open(abs_out, "w", encoding="utf-8") as f: + f.write(rendered) diff --git a/src/pkgmgr/cli/parser/__init__.py b/src/pkgmgr/cli/parser/__init__.py index ede8a00..2c9917c 100644 --- a/src/pkgmgr/cli/parser/__init__.py +++ b/src/pkgmgr/cli/parser/__init__.py @@ -4,18 +4,18 @@ 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 .publish_cmd import add_publish_subparser -from .version_cmd import add_version_subparser from .changelog_cmd import add_changelog_subparser +from .common import SortedSubParsersAction +from .config_cmd import add_config_subparsers +from .install_update import add_install_update_subparsers from .list_cmd import add_list_subparser from .make_cmd import add_make_subparsers from .mirror_cmd import add_mirror_subparsers +from .navigation_cmd import add_navigation_subparsers +from .publish_cmd import add_publish_subparser +from .release_cmd import add_release_subparser +from .version_cmd import add_version_subparser def create_parser(description_text: str) -> argparse.ArgumentParser: @@ -23,12 +23,34 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: description=description_text, formatter_class=argparse.RawTextHelpFormatter, ) + subparsers = parser.add_subparsers( dest="command", help="Subcommands", action=SortedSubParsersAction, ) + # create + p_create = subparsers.add_parser( + "create", + help="Create a new repository (scaffold + config).", + ) + p_create.add_argument( + "identifiers", + nargs="+", + help="Repository identifier(s): URL or 'provider(:port)/owner/repo'.", + ) + p_create.add_argument( + "--remote", + action="store_true", + help="Also push an initial commit to the remote (main/master).", + ) + p_create.add_argument( + "--preview", + action="store_true", + help="Print actions without writing files or executing commands.", + ) + add_install_update_subparsers(subparsers) add_config_subparsers(subparsers) add_navigation_subparsers(subparsers) diff --git a/templates/default/.gitignore.j2 b/templates/default/.gitignore.j2 new file mode 100644 index 0000000..cb54ccd --- /dev/null +++ b/templates/default/.gitignore.j2 @@ -0,0 +1,5 @@ +.venv/ +dist/ +build/ +__pycache__/ +*.pyc diff --git a/templates/default/LICENSE.j2 b/templates/default/LICENSE.j2 new file mode 100644 index 0000000..3764068 --- /dev/null +++ b/templates/default/LICENSE.j2 @@ -0,0 +1 @@ +{{ license_text }} diff --git a/templates/default/README.md.j2 b/templates/default/README.md.j2 new file mode 100644 index 0000000..83fd293 --- /dev/null +++ b/templates/default/README.md.j2 @@ -0,0 +1,6 @@ +# {{ repository }} + +Homepage: {{ homepage }} + +## Author +{{ author_name }} <{{ author_email }}> diff --git a/templates/default/flake.nix.j2 b/templates/default/flake.nix.j2 new file mode 100644 index 0000000..ff81a8a --- /dev/null +++ b/templates/default/flake.nix.j2 @@ -0,0 +1,11 @@ +{ + description = "{{ repository }}"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + outputs = { self, nixpkgs }: + let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; + in { + devShells.${system}.default = pkgs.mkShell { + packages = with pkgs; [ python312 python312Packages.pytest python312Packages.ruff ]; + }; + }; +} diff --git a/templates/default/pyproject.toml.j2 b/templates/default/pyproject.toml.j2 new file mode 100644 index 0000000..d10c146 --- /dev/null +++ b/templates/default/pyproject.toml.j2 @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{ repository }}" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "{{ author_name }}", email = "{{ author_email }}" }] +license = { text = "{{ license_text }}" } +urls = { Homepage = "{{ homepage }}" } + +dependencies = [] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/tests/e2e/test_repos_create_preview_output.py b/tests/e2e/test_repos_create_preview_output.py new file mode 100644 index 0000000..5952c73 --- /dev/null +++ b/tests/e2e/test_repos_create_preview_output.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import io +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from pkgmgr.actions.repository.create import create_repo + + +class TestE2ECreateRepoPreviewOutput(unittest.TestCase): + def test_create_repo_preview_prints_expected_steps(self) -> None: + cfg = {"directories": {"repositories": "/tmp/Repositories"}, "repositories": []} + + out = io.StringIO() + with ( + redirect_stdout(out), + patch("pkgmgr.actions.repository.create.os.path.exists", return_value=False), + patch("pkgmgr.actions.repository.create.generate_alias", return_value="repo"), + patch("pkgmgr.actions.repository.create.save_user_config"), + patch("pkgmgr.actions.repository.create.os.makedirs"), + patch("pkgmgr.actions.repository.create.render_default_templates"), + patch("pkgmgr.actions.repository.create.write_mirrors_file"), + patch("pkgmgr.actions.repository.create.setup_mirrors"), + patch("pkgmgr.actions.repository.create.subprocess.run"), + ): + create_repo( + "github.com/acme/repo", + cfg, + "/tmp/user.yml", + "/tmp/bin", + remote=False, + preview=True, + ) + + s = out.getvalue() + self.assertIn("[Preview] Would save user config:", s) + self.assertIn("[Preview] Would ensure directory exists:", s) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_repos_create_preview.py b/tests/integration/test_repos_create_preview.py new file mode 100644 index 0000000..8c74327 --- /dev/null +++ b/tests/integration/test_repos_create_preview.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib +import io +import unittest +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + + +class TestIntegrationReposCreatePreview(unittest.TestCase): + def test_repos_create_preview_wires_create_repo(self) -> None: + # Import lazily to avoid hard-failing if the CLI module/function name differs. + try: + repos_mod = importlib.import_module("pkgmgr.cli.commands.repos") + except Exception as exc: + self.skipTest(f"CLI module not available: {exc}") + + handle = getattr(repos_mod, "handle_repos_command", None) + if handle is None: + self.skipTest("handle_repos_command not found in pkgmgr.cli.commands.repos") + + ctx = SimpleNamespace( + repositories_base_dir="/tmp/Repositories", + binaries_dir="/tmp/bin", + all_repositories=[], + config_merged={"directories": {"repositories": "/tmp/Repositories"}, "repositories": []}, + user_config_path="/tmp/user.yml", + ) + + args = SimpleNamespace( + command="create", + identifiers=["github.com/acme/repo"], + remote=False, + preview=True, + ) + + out = io.StringIO() + with ( + redirect_stdout(out), + patch("pkgmgr.cli.commands.repos.create_repo") as create_repo, + ): + handle(args, ctx, selected=[]) + + create_repo.assert_called_once() + called = create_repo.call_args.kwargs + self.assertEqual(called["remote"], False) + self.assertEqual(called["preview"], True) + self.assertEqual(create_repo.call_args.args[0], "github.com/acme/repo") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/repository/test_create_parsing.py b/tests/unit/pkgmgr/actions/repository/test_create_parsing.py new file mode 100644 index 0000000..8c52ce0 --- /dev/null +++ b/tests/unit/pkgmgr/actions/repository/test_create_parsing.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import unittest + +from pkgmgr.actions.repository.create import ( + RepoParts, + _parse_identifier, + _parse_git_url, + _strip_git_suffix, + _split_host_port, +) + + +class TestRepositoryCreateParsing(unittest.TestCase): + def test_strip_git_suffix(self) -> None: + self.assertEqual(_strip_git_suffix("repo.git"), "repo") + self.assertEqual(_strip_git_suffix("repo"), "repo") + + def test_split_host_port(self) -> None: + self.assertEqual(_split_host_port("example.com"), ("example.com", None)) + self.assertEqual(_split_host_port("example.com:2222"), ("example.com", "2222")) + self.assertEqual(_split_host_port("example.com:"), ("example.com", None)) + + def test_parse_identifier_plain(self) -> None: + parts = _parse_identifier("github.com/owner/repo") + self.assertIsInstance(parts, RepoParts) + self.assertEqual(parts.host, "github.com") + self.assertEqual(parts.port, None) + self.assertEqual(parts.owner, "owner") + self.assertEqual(parts.name, "repo") + + def test_parse_identifier_with_port(self) -> None: + parts = _parse_identifier("gitea.example.com:2222/org/repo") + self.assertEqual(parts.host, "gitea.example.com") + self.assertEqual(parts.port, "2222") + self.assertEqual(parts.owner, "org") + self.assertEqual(parts.name, "repo") + + def test_parse_git_url_scp_style(self) -> None: + parts = _parse_git_url("git@github.com:owner/repo.git") + self.assertEqual(parts.host, "github.com") + self.assertEqual(parts.port, None) + self.assertEqual(parts.owner, "owner") + self.assertEqual(parts.name, "repo") + + def test_parse_git_url_https(self) -> None: + parts = _parse_git_url("https://github.com/owner/repo.git") + self.assertEqual(parts.host, "github.com") + self.assertEqual(parts.port, None) + self.assertEqual(parts.owner, "owner") + self.assertEqual(parts.name, "repo") + + def test_parse_git_url_ssh_with_port(self) -> None: + parts = _parse_git_url("ssh://git@gitea.example.com:2222/org/repo.git") + self.assertEqual(parts.host, "gitea.example.com") + self.assertEqual(parts.port, "2222") + self.assertEqual(parts.owner, "org") + self.assertEqual(parts.name, "repo") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/actions/repository/test_scaffold_render_preview.py b/tests/unit/pkgmgr/actions/repository/test_scaffold_render_preview.py new file mode 100644 index 0000000..0b81ef0 --- /dev/null +++ b/tests/unit/pkgmgr/actions/repository/test_scaffold_render_preview.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from pkgmgr.actions.repository.scaffold import render_default_templates + + +class TestScaffoldRenderPreview(unittest.TestCase): + def test_render_preview_does_not_write(self) -> None: + with ( + patch("pkgmgr.actions.repository.scaffold._templates_dir", return_value="/tpl"), + patch("pkgmgr.actions.repository.scaffold.os.path.isdir", return_value=True), + patch("pkgmgr.actions.repository.scaffold.os.walk", return_value=[("/tpl", [], ["README.md.j2"])]), + patch("pkgmgr.actions.repository.scaffold.os.path.relpath", return_value="README.md.j2"), + patch("pkgmgr.actions.repository.scaffold.os.makedirs") as mk, + patch("pkgmgr.actions.repository.scaffold.open", create=True) as op, + patch("pkgmgr.actions.repository.scaffold.Environment") as env_cls, + ): + env = env_cls.return_value + env.get_template.return_value.render.return_value = "X" + + render_default_templates( + "/repo", + context={"repository": "x"}, + preview=True, + ) + + mk.assert_not_called() + op.assert_not_called() + env.get_template.assert_not_called() + + +if __name__ == "__main__": + unittest.main()