From c4395a476474d92a6c8de0d731074bf35ceccd62 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 3 Dec 2025 16:09:42 +0100 Subject: [PATCH] Add Arch-based Docker test setup, shallow clone mode support and pkgmgr tests (see ChatGPT conversation: https://chatgpt.com/share/693052a1-edd0-800f-a9d6-c154b8e7d8e0) --- .github/workflows/test.yml | 25 +++ Dockerfile | 39 ++-- Makefile | 4 + main.py | 47 ++++- pkgmgr/clone_repos.py | 37 +++- tests/test_clone_repos.py | 168 ++++++++++++++++++ tests/test_install_repos.py | 129 ++++++++++++++ tests/test_integration_install_all_shallow.py | 47 +++++ tests/test_main.py | 19 ++ 9 files changed, 484 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/test_clone_repos.py create mode 100644 tests/test_install_repos.py create mode 100644 tests/test_integration_install_all_shallow.py create mode 100644 tests/test_main.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..209723e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test package-manager + +on: + push: + branches: + - main + - master + - develop + - "*" + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Show Docker version + run: docker version + + - name: Run tests via make (builds Docker image and runs unit + integration tests) + run: make test diff --git a/Dockerfile b/Dockerfile index 9673c6c..ae63572 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,40 @@ -FROM python:3.11-slim +FROM archlinux:latest -# Install system dependencies (make, pip) as per README -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - make \ - python3-pip \ - python3-venv \ - && rm -rf /var/lib/apt/lists/* +# Update system and install core tooling +RUN pacman -Syu --noconfirm \ + && pacman -S --noconfirm --needed \ + git \ + make \ + sudo \ + python \ + python-pip \ + python-virtualenv \ + python-setuptools \ + python-wheel \ + && pacman -Scc --noconfirm -# Ensure local bin is in PATH (for aliases) as per README +# Ensure local bin is in PATH (for pkgmgr links) ENV PATH="/root/.local/bin:$PATH" -# Create and activate a virtual environment +# Create virtual environment ENV VIRTUAL_ENV=/root/.venvs/pkgmgr -RUN python3 -m venv $VIRTUAL_ENV +RUN python -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -# Copy local package-manager source into the image +# Working directory for the package-manager project WORKDIR /root/Repositories/github.com/kevinveenbirkenbach/package-manager + +# Copy local package-manager source into container COPY . . -# Install Python dependencies and set up the tool non-interactively +# Install Python dependencies and register pkgmgr inside the venv RUN pip install --upgrade pip \ && pip install PyYAML \ && chmod +x main.py \ - && python main.py install package-manager --quiet --clone-mode https + && python main.py install package-manager --quiet --clone-mode shallow --no-verification + +# Copy again to allow rebuild-based code changes +COPY . . -# Default entrypoint for pkgmgr ENTRYPOINT ["pkgmgr"] CMD ["--help"] diff --git a/Makefile b/Makefile index ea4a6fa..c80afeb 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,10 @@ setup: install @python3 main.py install +test: + docker build -t package-manager-test . + docker run --rm --entrypoint python package-manager-test -m unittest discover -s tests -p "test_*.py" + install: @echo "Making 'main.py' executable..." @chmod +x main.py diff --git a/main.py b/main.py index 6e23e18..3b6969f 100755 --- a/main.py +++ b/main.py @@ -112,10 +112,29 @@ For detailed help on each command, use: def add_install_update_arguments(subparser): 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"], default="ssh", help="Specify the clone mode (default: ssh)") + 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)", + ) install_parser = subparsers.add_parser("install", help="Setup repository/repositories alias links to executables") add_install_update_arguments(install_parser) @@ -213,10 +232,20 @@ For detailed help on each command, use: description=f"Executes '{command} {subcommand}' for the identified repos.\nTo recieve more help execute '{command} {subcommand} --help'", formatter_class=argparse.RawTextHelpFormatter ) - if subcommand in ["pull","clone"]: - proxy_command_parsers[f"{command}_{subcommand}"].add_argument("--no-verification", action="store_true", default=False, help="Disable verification via commit/gpg") + if subcommand in ["pull", "clone"]: + proxy_command_parsers[f"{command}_{subcommand}"].add_argument( + "--no-verification", + action="store_true", + default=False, + help="Disable verification via commit/gpg", + ) if subcommand == "clone": - proxy_command_parsers[f"{command}_{subcommand}"].add_argument("--clone-mode", choices=["ssh", "https"], default="ssh", help="Specify the clone mode (default: ssh)") + proxy_command_parsers[f"{command}_{subcommand}"].add_argument( + "--clone-mode", + choices=["ssh", "https", "shallow"], + default="ssh", + help="Specify the clone mode: ssh, https, or shallow (HTTPS shallow clone; default: ssh)", + ) add_identifier_arguments(proxy_command_parsers[f"{command}_{subcommand}"]) args = parser.parse_args() @@ -331,7 +360,7 @@ For detailed help on each command, use: status_repos(selected,REPOSITORIES_BASE_DIR, ALL_REPOSITORIES, args.extra_args, list_only=args.list, system_status=args.system, preview=args.preview) elif args.command == "explore": for repository in selected: - run_command(f"nautilus {repository["directory"]} & disown") + run_command(f"nautilus {repository['directory']} & disown") elif args.command == "code": if not selected: print("No repositories selected.") @@ -371,7 +400,7 @@ For detailed help on each command, use: # Join the provided shell command parts into one string. command_to_run = " ".join(args.shell_command) for repository in selected: - print(f"Executing in '{repository["directory"]}': {command_to_run}") + print(f"Executing in '{repository['directory']}': {command_to_run}") run_command(command_to_run, cwd=repository["directory"], preview=args.preview) elif args.command == "config": if args.subcommand == "show": diff --git a/pkgmgr/clone_repos.py b/pkgmgr/clone_repos.py index 034fd8a..6b096b7 100644 --- a/pkgmgr/clone_repos.py +++ b/pkgmgr/clone_repos.py @@ -22,25 +22,48 @@ def clone_repos( parent_dir = os.path.dirname(repo_dir) os.makedirs(parent_dir, exist_ok=True) # Build clone URL based on the clone_mode + # Build clone URL based on the clone_mode if clone_mode == "ssh": - clone_url = f"git@{repo.get('provider')}:{repo.get('account')}/{repo.get('repository')}.git" - elif clone_mode == "https": + clone_url = ( + f"git@{repo.get('provider')}:" + f"{repo.get('account')}/" + f"{repo.get('repository')}.git" + ) + elif clone_mode in ("https", "shallow"): # Use replacement if defined, otherwise construct from provider/account/repository if repo.get("replacement"): clone_url = f"https://{repo.get('replacement')}.git" else: - clone_url = f"https://{repo.get('provider')}/{repo.get('account')}/{repo.get('repository')}.git" + clone_url = ( + f"https://{repo.get('provider')}/" + f"{repo.get('account')}/" + f"{repo.get('repository')}.git" + ) else: print(f"Unknown clone mode '{clone_mode}'. Aborting clone for {repo_identifier}.") continue - print(f"[INFO] Attempting to clone '{repo_identifier}' using {clone_mode.upper()} from {clone_url} into '{repo_dir}'.") - + # Build base clone command + base_clone_cmd = "git clone" + if clone_mode == "shallow": + # Shallow clone: only latest state via HTTPS, no full history + base_clone_cmd += " --depth 1 --single-branch" + + mode_label = "HTTPS (shallow)" if clone_mode == "shallow" else clone_mode.upper() + print( + f"[INFO] Attempting to clone '{repo_identifier}' using {mode_label} " + f"from {clone_url} into '{repo_dir}'." + ) + if preview: - print(f"[Preview] Would run: git clone {clone_url} {repo_dir} in {parent_dir}") + print(f"[Preview] Would run: {base_clone_cmd} {clone_url} {repo_dir} in {parent_dir}") result = subprocess.CompletedProcess(args=[], returncode=0) else: - result = subprocess.run(f"git clone {clone_url} {repo_dir}", cwd=parent_dir, shell=True) + result = subprocess.run( + f"{base_clone_cmd} {clone_url} {repo_dir}", + cwd=parent_dir, + shell=True, + ) if result.returncode != 0: # Only offer fallback if the original mode was SSH. diff --git a/tests/test_clone_repos.py b/tests/test_clone_repos.py new file mode 100644 index 0000000..da2200b --- /dev/null +++ b/tests/test_clone_repos.py @@ -0,0 +1,168 @@ +# tests/test_clone_repos.py +import unittest +from unittest.mock import patch, MagicMock + +from pkgmgr.clone_repos import clone_repos + + +class TestCloneRepos(unittest.TestCase): + def setUp(self): + self.repo = { + "provider": "github.com", + "account": "user", + "repository": "repo", + } + self.selected = [self.repo] + self.base_dir = "/tmp/repos" + self.all_repos = self.selected + + @patch("pkgmgr.clone_repos.verify_repository") + @patch("pkgmgr.clone_repos.subprocess.run") + @patch("pkgmgr.clone_repos.os.makedirs") + @patch("pkgmgr.clone_repos.os.path.exists") + @patch("pkgmgr.clone_repos.get_repo_dir") + @patch("pkgmgr.clone_repos.get_repo_identifier") + def test_clone_ssh_mode_uses_ssh_url( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_exists, + mock_makedirs, + mock_run, + mock_verify, + ): + mock_get_repo_identifier.return_value = "github.com/user/repo" + mock_get_repo_dir.return_value = "/tmp/repos/user/repo" + mock_exists.return_value = False + mock_run.return_value = MagicMock(returncode=0) + mock_verify.return_value = (True, [], "hash", "key") + + clone_repos( + self.selected, + self.base_dir, + self.all_repos, + preview=False, + no_verification=True, + clone_mode="ssh", + ) + + mock_run.assert_called_once() + # subprocess.run wird mit positional args aufgerufen + cmd = mock_run.call_args[0][0] + cwd = mock_run.call_args[1]["cwd"] + + self.assertIn("git clone", cmd) + self.assertIn("git@github.com:user/repo.git", cmd) + self.assertEqual(cwd, "/tmp/repos/user") + + @patch("pkgmgr.clone_repos.verify_repository") + @patch("pkgmgr.clone_repos.subprocess.run") + @patch("pkgmgr.clone_repos.os.makedirs") + @patch("pkgmgr.clone_repos.os.path.exists") + @patch("pkgmgr.clone_repos.get_repo_dir") + @patch("pkgmgr.clone_repos.get_repo_identifier") + def test_clone_https_mode_uses_https_url( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_exists, + mock_makedirs, + mock_run, + mock_verify, + ): + mock_get_repo_identifier.return_value = "github.com/user/repo" + mock_get_repo_dir.return_value = "/tmp/repos/user/repo" + mock_exists.return_value = False + mock_run.return_value = MagicMock(returncode=0) + mock_verify.return_value = (True, [], "hash", "key") + + clone_repos( + self.selected, + self.base_dir, + self.all_repos, + preview=False, + no_verification=True, + clone_mode="https", + ) + + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + cwd = mock_run.call_args[1]["cwd"] + + self.assertIn("git clone", cmd) + self.assertIn("https://github.com/user/repo.git", cmd) + self.assertEqual(cwd, "/tmp/repos/user") + + @patch("pkgmgr.clone_repos.verify_repository") + @patch("pkgmgr.clone_repos.subprocess.run") + @patch("pkgmgr.clone_repos.os.makedirs") + @patch("pkgmgr.clone_repos.os.path.exists") + @patch("pkgmgr.clone_repos.get_repo_dir") + @patch("pkgmgr.clone_repos.get_repo_identifier") + def test_clone_shallow_mode_uses_https_with_depth( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_exists, + mock_makedirs, + mock_run, + mock_verify, + ): + mock_get_repo_identifier.return_value = "github.com/user/repo" + mock_get_repo_dir.return_value = "/tmp/repos/user/repo" + mock_exists.return_value = False + mock_run.return_value = MagicMock(returncode=0) + mock_verify.return_value = (True, [], "hash", "key") + + clone_repos( + self.selected, + self.base_dir, + self.all_repos, + preview=False, + no_verification=True, + clone_mode="shallow", + ) + + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + cwd = mock_run.call_args[1]["cwd"] + + self.assertIn("git clone --depth 1 --single-branch", cmd) + self.assertIn("https://github.com/user/repo.git", cmd) + self.assertEqual(cwd, "/tmp/repos/user") + + @patch("pkgmgr.clone_repos.verify_repository") + @patch("pkgmgr.clone_repos.subprocess.run") + @patch("pkgmgr.clone_repos.os.makedirs") + @patch("pkgmgr.clone_repos.os.path.exists") + @patch("pkgmgr.clone_repos.get_repo_dir") + @patch("pkgmgr.clone_repos.get_repo_identifier") + def test_preview_mode_does_not_call_subprocess_run( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_exists, + mock_makedirs, + mock_run, + mock_verify, + ): + mock_get_repo_identifier.return_value = "github.com/user/repo" + mock_get_repo_dir.return_value = "/tmp/repos/user/repo" + mock_exists.return_value = False + mock_verify.return_value = (True, [], "hash", "key") + + clone_repos( + self.selected, + self.base_dir, + self.all_repos, + preview=True, + no_verification=True, + clone_mode="shallow", + ) + + # Im Preview-Modus sollte subprocess.run nicht aufgerufen werden + mock_run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_repos.py b/tests/test_install_repos.py new file mode 100644 index 0000000..3c17b52 --- /dev/null +++ b/tests/test_install_repos.py @@ -0,0 +1,129 @@ +# tests/test_install_repos.py +import os +import unittest +from unittest.mock import patch, MagicMock, mock_open + +from pkgmgr.install_repos import install_repos + + +class TestInstallRepos(unittest.TestCase): + def setUp(self): + self.repo = { + "provider": "github.com", + "account": "user", + "repository": "repo", + } + self.selected = [self.repo] + self.base_dir = "/tmp/repos" + self.bin_dir = "/tmp/bin" + self.all_repos = self.selected + + @patch("pkgmgr.install_repos.clone_repos") + @patch("pkgmgr.install_repos.os.path.exists") + @patch("pkgmgr.install_repos.get_repo_dir") + @patch("pkgmgr.install_repos.get_repo_identifier") + def test_calls_clone_repos_with_clone_mode( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_exists, + mock_clone_repos, + ): + mock_get_repo_identifier.return_value = "github.com/user/repo" + mock_get_repo_dir.return_value = "/tmp/repos/user/repo" + # Repo-Verzeichnis existiert nicht -> soll geklont werden + mock_exists.return_value = False + + install_repos( + self.selected, + self.base_dir, + self.bin_dir, + self.all_repos, + no_verification=True, + preview=False, + quiet=True, + clone_mode="shallow", + update_dependencies=False, + ) + + mock_clone_repos.assert_called_once() + args, kwargs = mock_clone_repos.call_args + # clone_mode ist letztes Argument + self.assertEqual(args[-1], "shallow") + + @patch("pkgmgr.install_repos.run_command") + @patch("pkgmgr.install_repos.open", new_callable=mock_open, create=True) + @patch("pkgmgr.install_repos.yaml.safe_load") + @patch("pkgmgr.install_repos.os.path.exists") + @patch("pkgmgr.install_repos.create_ink") + @patch("pkgmgr.install_repos.verify_repository") + @patch("pkgmgr.install_repos.get_repo_dir") + @patch("pkgmgr.install_repos.get_repo_identifier") + def test_pkgmgr_requirements_propagate_clone_mode( + self, + mock_get_repo_identifier, + mock_get_repo_dir, + mock_verify, + mock_create_ink, + mock_exists, + mock_safe_load, + mock_open_file, + mock_run_command, + ): + mock_get_repo_identifier.return_value = "github.com/user/repo" + repo_dir = "/tmp/repos/user/repo" + mock_get_repo_dir.return_value = repo_dir + + # exists() muss True für repo_dir & requirements.yml liefern, + # sonst werden die Anforderungen nie verarbeitet. + def exists_side_effect(path): + if path == repo_dir: + return True + if path == os.path.join(repo_dir, "requirements.yml"): + return True + # requirements.txt und Makefile sollen "nicht existieren" + return False + + mock_exists.side_effect = exists_side_effect + + mock_verify.return_value = (True, [], "hash", "key") + + # requirements.yml enthält pkgmgr-Dependencies + mock_safe_load.return_value = { + "pkgmgr": ["github.com/other/account/dep"], + } + + commands = [] + + def run_command_side_effect(cmd, cwd=None, preview=False): + commands.append((cmd, cwd, preview)) + + mock_run_command.side_effect = run_command_side_effect + + install_repos( + self.selected, + self.base_dir, + self.bin_dir, + self.all_repos, + no_verification=False, + preview=False, + quiet=True, + clone_mode="shallow", + update_dependencies=False, + ) + + # Prüfen, dass ein pkgmgr install Befehl mit --clone-mode shallow gebaut wurde + pkgmgr_install_cmds = [ + c for (c, cwd, preview) in commands if "pkgmgr install" in c + ] + self.assertTrue( + pkgmgr_install_cmds, + f"No pkgmgr install command was executed. Commands seen: {commands}", + ) + + cmd = pkgmgr_install_cmds[0] + self.assertIn("--clone-mode shallow", cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration_install_all_shallow.py b/tests/test_integration_install_all_shallow.py new file mode 100644 index 0000000..326d736 --- /dev/null +++ b/tests/test_integration_install_all_shallow.py @@ -0,0 +1,47 @@ +# tests/test_integration_install_all_shallow.py +""" +Integration test: install all configured repositories using +--clone-mode shallow (HTTPS shallow clone) and --no-verification. + +This test is intended to be run inside the Docker container where: + - network access is available, + - the config/config.yaml is present, + - and it is safe to perform real git operations. + +It passes if the command completes without raising an exception. +""" + +import runpy +import sys +import unittest + + +class TestIntegrationInstallAllShallow(unittest.TestCase): + def test_install_all_repositories_shallow(self): + """ + Run: pkgmgr install --all --clone-mode shallow --no-verification + + This will perform real installations/clones inside the container. + The test succeeds if no exception is raised. + """ + original_argv = sys.argv + try: + sys.argv = [ + "pkgmgr", + "install", + "--all", + "--clone-mode", + "shallow", + "--no-verification", + ] + + # Execute main.py as if it was called from CLI. + # This will run the full install pipeline inside the container. + runpy.run_module("main", run_name="__main__") + + finally: + sys.argv = original_argv + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..fc63fdf --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,19 @@ +# tests/test_main.py +import unittest +import main + + +class TestMainModule(unittest.TestCase): + def test_proxy_commands_defined(self): + """ + Basic sanity check: main.py should define PROXY_COMMANDS + with git/docker/docker compose entries. + """ + self.assertTrue(hasattr(main, "PROXY_COMMANDS")) + self.assertIn("git", main.PROXY_COMMANDS) + self.assertIn("docker", main.PROXY_COMMANDS) + self.assertIn("docker compose", main.PROXY_COMMANDS) + + +if __name__ == "__main__": + unittest.main()