diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ecb4b9f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + + - name: Install dependencies + run: | + uv pip install --system -e ".[test]" + uv pip install --system ruff isort + + - name: Run ruff check + run: ruff check src/ + + - name: Run isort check + run: isort --check src/ + + - name: Run tests with coverage + run: pytest --cov=mxrepo --cov-report=term-missing --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: matrix.python-version == '3.13' + with: + file: ./coverage.xml + fail_ci_if_error: false + continue-on-error: true diff --git a/.gitignore b/.gitignore index 8539fe6..91dcf14 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__ /.mxmake/ /requirements-mxdev.txt +/.claude/ +CLAUDE.md diff --git a/Makefile b/Makefile index 5093871..bd4174a 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,13 @@ # # DOMAINS: #: core.base +#: core.help #: core.mxenv #: core.mxfiles #: core.packages #: qa.isort #: qa.ruff +#: qa.test # # SETTINGS (ALL CHANGES MADE BELOW SETTINGS WILL BE LOST) ############################################################################## @@ -41,26 +43,30 @@ EXTRA_PATH?= # Primary Python interpreter to use. It is used to create the # virtual environment if `VENV_ENABLED` and `VENV_CREATE` are set to `true`. +# If global `uv` is used, this value is passed as `--python VALUE` to the venv creation. +# uv then downloads the Python interpreter if it is not available. +# for more on this feature read the [uv python documentation](https://docs.astral.sh/uv/concepts/python-versions/) # Default: python3 PRIMARY_PYTHON?=python3 # Minimum required Python version. -# Default: 3.9 +# Default: 3.10 PYTHON_MIN_VERSION?=3.9 # Install packages using the given package installer method. -# Supported are `pip` and `uv`. If uv is used, its global availability is -# checked. Otherwise, it is installed, either in the virtual environment or -# using the `PRIMARY_PYTHON`, dependent on the `VENV_ENABLED` setting. If -# `VENV_ENABLED` and uv is selected, uv is used to create the virtual -# environment. +# Supported are `pip` and `uv`. When `uv` is selected, a global installation +# is auto-detected and used if available. Otherwise, uv is installed in the +# virtual environment or using `PRIMARY_PYTHON`, depending on the +# `VENV_ENABLED` setting. # Default: pip PYTHON_PACKAGE_INSTALLER?=uv -# Flag whether to use a global installed 'uv' or install -# it in the virtual environment. -# Default: false -MXENV_UV_GLOBAL?=true +# Python version for UV to install/use when creating virtual +# environments with global UV. Passed to `uv venv -p VALUE`. Supports version +# specs like `3.11`, `3.14`, `cpython@3.14`. Defaults to PRIMARY_PYTHON value +# for backward compatibility. +# Default: $(PRIMARY_PYTHON) +UV_PYTHON?=$(PRIMARY_PYTHON) # Flag whether to use virtual environment. If `false`, the # interpreter according to `PRIMARY_PYTHON` found in `PATH` is used. @@ -114,6 +120,28 @@ PROJECT_CONFIG?=mx.ini # Default: false PACKAGES_ALLOW_PRERELEASES?=false +## qa.test + +# The command which gets executed. Defaults to the location the +# :ref:`run-tests` template gets rendered to if configured. +# Default: .mxmake/files/run-tests.sh +TEST_COMMAND?=.mxmake/files/run-tests.sh + +# Additional Python requirements for running tests to be +# installed (via pip). +# Default: pytest +TEST_REQUIREMENTS?=pytest + +# Additional make targets the test target depends on. +# No default value. +TEST_DEPENDENCY_TARGETS?= + +## core.help + +# Request to show all targets, descriptions and arguments for a given domain. +# No default value. +HELP_DOMAIN?= + ############################################################################## # END SETTINGS - DO NOT EDIT BELOW THIS LINE ############################################################################## @@ -152,7 +180,7 @@ $(SENTINEL): $(firstword $(MAKEFILE_LIST)) # mxenv ############################################################################## -export OS:=$(OS) +OS?= # Determine the executable path ifeq ("$(VENV_ENABLED)", "true") @@ -168,26 +196,61 @@ else MXENV_PYTHON=$(PRIMARY_PYTHON) endif -# Determine the package installer +# Determine the package installer with non-interactive flags ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") -PYTHON_PACKAGE_COMMAND=uv pip +PYTHON_PACKAGE_COMMAND=uv pip --no-progress else PYTHON_PACKAGE_COMMAND=$(MXENV_PYTHON) -m pip endif +# Auto-detect global uv availability (simple existence check) +ifeq ("$(PYTHON_PACKAGE_INSTALLER)","uv") +UV_AVAILABLE:=$(shell command -v uv >/dev/null 2>&1 && echo "true" || echo "false") +else +UV_AVAILABLE:=false +endif + +# Determine installation strategy +# depending on the PYTHON_PACKAGE_INSTALLER and UV_AVAILABLE +# - both vars can be false or +# - one of them can be true, +# - but never boths. +USE_GLOBAL_UV:=$(shell [[ "$(PYTHON_PACKAGE_INSTALLER)" == "uv" && "$(UV_AVAILABLE)" == "true" ]] && echo "true" || echo "false") +USE_LOCAL_UV:=$(shell [[ "$(PYTHON_PACKAGE_INSTALLER)" == "uv" && "$(UV_AVAILABLE)" == "false" ]] && echo "true" || echo "false") + +# Check if global UV is outdated (non-blocking warning) +ifeq ("$(USE_GLOBAL_UV)","true") +UV_OUTDATED:=$(shell uv self update --dry-run 2>&1 | grep -q "Would update" && echo "true" || echo "false") +else +UV_OUTDATED:=false +endif + MXENV_TARGET:=$(SENTINEL_FOLDER)/mxenv.sentinel $(MXENV_TARGET): $(SENTINEL) + # Validation: Check Python version if not using global uv +ifneq ("$(USE_GLOBAL_UV)","true") @$(PRIMARY_PYTHON) -c "import sys; vi = sys.version_info; sys.exit(1 if (int(vi[0]), int(vi[1])) >= tuple(map(int, '$(PYTHON_MIN_VERSION)'.split('.'))) else 0)" \ && echo "Need Python >= $(PYTHON_MIN_VERSION)" && exit 1 || : +else + @echo "Using global uv for Python $(UV_PYTHON)" +endif + # Validation: Check VENV_FOLDER is set if venv enabled @[[ "$(VENV_ENABLED)" == "true" && "$(VENV_FOLDER)" == "" ]] \ && echo "VENV_FOLDER must be configured if VENV_ENABLED is true" && exit 1 || : - @[[ "$(VENV_ENABLED)$(PYTHON_PACKAGE_INSTALLER)" == "falseuv" ]] \ + # Validation: Check uv not used with system Python + @[[ "$(VENV_ENABLED)" == "false" && "$(PYTHON_PACKAGE_INSTALLER)" == "uv" ]] \ && echo "Package installer uv does not work with a global Python interpreter." && exit 1 || : + # Warning: Notify if global UV is outdated +ifeq ("$(UV_OUTDATED)","true") + @echo "WARNING: A newer version of uv is available. Run 'uv self update' to upgrade." +endif + + # Create virtual environment ifeq ("$(VENV_ENABLED)", "true") ifeq ("$(VENV_CREATE)", "true") -ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvtrue") - @echo "Setup Python Virtual Environment using package 'uv' at '$(VENV_FOLDER)'" - @uv venv -p $(PRIMARY_PYTHON) --seed $(VENV_FOLDER) +ifeq ("$(USE_GLOBAL_UV)","true") + @echo "Setup Python Virtual Environment using global uv at '$(VENV_FOLDER)'" + @uv venv --allow-existing --no-progress -p $(UV_PYTHON) --seed $(VENV_FOLDER) else @echo "Setup Python Virtual Environment using module 'venv' at '$(VENV_FOLDER)'" @$(PRIMARY_PYTHON) -m venv $(VENV_FOLDER) @@ -197,10 +260,14 @@ endif else @echo "Using system Python interpreter" endif -ifeq ("$(PYTHON_PACKAGE_INSTALLER)$(MXENV_UV_GLOBAL)","uvfalse") - @echo "Install uv" + + # Install uv locally if needed +ifeq ("$(USE_LOCAL_UV)","true") + @echo "Install uv in virtual environment" @$(MXENV_PYTHON) -m pip install uv endif + + # Install/upgrade core packages @$(PYTHON_PACKAGE_COMMAND) install -U pip setuptools wheel @echo "Install/Update MXStack Python packages" @$(PYTHON_PACKAGE_COMMAND) install -U $(MXDEV) $(MXMAKE) @@ -397,6 +464,43 @@ INSTALL_TARGETS+=packages DIRTY_TARGETS+=packages-dirty CLEAN_TARGETS+=packages-clean +############################################################################## +# test +############################################################################## + +TEST_TARGET:=$(SENTINEL_FOLDER)/test.sentinel +$(TEST_TARGET): $(MXENV_TARGET) + @echo "Install $(TEST_REQUIREMENTS)" + @$(PYTHON_PACKAGE_COMMAND) install $(TEST_REQUIREMENTS) + @touch $(TEST_TARGET) + +.PHONY: test +test: $(FILES_TARGET) $(SOURCES_TARGET) $(PACKAGES_TARGET) $(TEST_TARGET) $(TEST_DEPENDENCY_TARGETS) + @test -z "$(TEST_COMMAND)" && echo "No test command defined" && exit 1 || : + @echo "Run tests using $(TEST_COMMAND)" + @/usr/bin/env bash -c "$(TEST_COMMAND)" + +.PHONY: test-dirty +test-dirty: + @rm -f $(TEST_TARGET) + +.PHONY: test-clean +test-clean: test-dirty + @test -e $(MXENV_PYTHON) && $(MXENV_PYTHON) -m pip uninstall -y $(TEST_REQUIREMENTS) || : + @rm -rf .pytest_cache + +INSTALL_TARGETS+=$(TEST_TARGET) +CLEAN_TARGETS+=test-clean +DIRTY_TARGETS+=test-dirty + +############################################################################## +# help +############################################################################## + +.PHONY: help +help: $(MXENV_TARGET) + @mxmake help-generator + ############################################################################## # Custom includes ############################################################################## diff --git a/mx.ini b/mx.ini index 185bcc2..43dc4de 100644 --- a/mx.ini +++ b/mx.ini @@ -1,2 +1,12 @@ [settings] -main-package = -e . +main-package = -e .[test] + +mxmake-templates = + run-tests + +mxmake-test-path = tests +mxmake-test-runner = pytest +mxmake-test-runner-args = --junitxml=reports/pyjunit.xml --cov=mxrepo --cov-report=term --cov-report=xml:reports/pycoverage.xml --cov-report=html:reports/pycoverage/html +mxmake-source-path = src/heidi/cloud + +[mxmake-run-tests] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e17e467..3737951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,11 @@ classifiers = [ dynamic = ["readme"] [project.optional-dependencies] -test = ["pytest"] +test = [ + "pytest", + "pytest-cov", + "pytest-mock", +] [project.urls] Homepage = "https://github.com/mxstack/mxrepo" @@ -54,5 +58,35 @@ ignore_missing_imports = true python_version = "3.9" [tool.ruff] -# Exclude a variety of commonly ignored directories. -exclude = [] +line-length = 88 +exclude = [ + ".git", + ".venv", + "__pycache__", + "build", + "dist", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "B", # flake8-bugbear + "UP", # pyupgrade + "S", # bandit (security) +] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] # Allow assert in tests + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "-ra", +] diff --git a/src/mxrepo/main.py b/src/mxrepo/main.py index 46b4fea..097be97 100644 --- a/src/mxrepo/main.py +++ b/src/mxrepo/main.py @@ -32,19 +32,19 @@ def hilite(string, color, bold): def query_repos(context): - org_url = "https://api.github.com/orgs/%s/repos" % context - user_url = "https://api.github.com/users/%s/repos" % context - query = "%s?page=%i&per_page=50" + org_url = f"https://api.github.com/orgs/{context}/repos" + user_url = f"https://api.github.com/users/{context}/repos" + query = "{}?page={}&per_page=50" data = list() page = 1 while True: try: - url = query % (org_url, page) - res = urllib.request.urlopen(url) + url = query.format(org_url, page) + res = urllib.request.urlopen(url) # noqa: S310 except urllib.error.URLError: try: - url = query % (user_url, page) - res = urllib.request.urlopen(url) + url = query.format(user_url, page) + res = urllib.request.urlopen(url) # noqa: S310 except urllib.error.URLError as e: print(e) sys.exit(0) @@ -54,21 +54,21 @@ def query_repos(context): break data += page_data page += 1 - print("Fetched %i repositories for '%s'" % (len(data), context)) + print(f"Fetched {len(data)} repositories for '{context}'") return data def perform_clone(arguments): - base_uri = "git@github.com:%s/%s.git" + base_uri = "git@github.com:{}/{}.git" context = arguments.context[0] if arguments.repository: repos = arguments.repository else: repos = [_["name"] for _ in query_repos(arguments.context)] for repo in repos: - uri = base_uri % (context, repo) + uri = base_uri.format(context, repo) cmd = ["git", "clone", uri] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 sub = subparsers.add_parser("clone", help="Clone from an organisation or a user") @@ -82,14 +82,11 @@ def perform_clone(arguments): def get_branch(): - cmd = "git branch" - p = subprocess.Popen( + cmd = ["git", "branch"] + p = subprocess.Popen( # noqa: S603 cmd, - shell=True, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - close_fds=True, ) output = p.stdout.readlines() for line in output: @@ -109,9 +106,9 @@ def perform_pull(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Perform pull for '%s'" % hilite(child, "blue", True)) + print(f"Perform pull for '{hilite(child, 'blue', True)}'") cmd = ["git", "pull", "origin", get_branch()] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") @@ -125,7 +122,7 @@ def perform_pull(arguments): def perform(cmd): - pr = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + pr = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # noqa: S603 stdout, stderr = pr.communicate() print(stdout.decode()) if pr.returncode != 0: @@ -140,18 +137,18 @@ def perform_backup(arguments): os.chdir(context) contents = listdir(".") data = query_repos(context) - base_uri = "git@github.com:%s/%s.git" + base_uri = "git@github.com:{}/{}.git" for repo in data: name = repo["name"] - fs_name = "%s.git" % name + fs_name = f"{name}.git" if fs_name in contents: - print("Fetching existing local repository '%s'" % fs_name) + print(f"Fetching existing local repository '{fs_name}'") os.chdir(fs_name) perform(["git", "fetch", "origin"]) os.chdir("..") else: - print("Cloning new repository '%s'" % fs_name) - uri = base_uri % (context, name) + print(f"Cloning new repository '{fs_name}'") + uri = base_uri.format(context, name) perform(["git", "clone", "--bare", "--mirror", uri]) @@ -173,9 +170,9 @@ def perform_status(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Status for '%s'" % hilite(child, "blue", True)) + print(f"Status for '{hilite(child, 'blue', True)}'") cmd = ["git", "status"] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") @@ -201,9 +198,9 @@ def perform_branch(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Branches for '%s'" % hilite(child, "blue", True)) + print(f"Branches for '{hilite(child, 'blue', True)}'") cmd = ["git", "branch"] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") @@ -229,9 +226,9 @@ def perform_diff(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Diff for '%s'" % hilite(child, "blue", True)) + print(f"Diff for '{hilite(child, 'blue', True)}'") cmd = ["git", "diff"] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") @@ -258,9 +255,9 @@ def perform_commit(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Commit all changes resources for '%s'" % hilite(child, "blue", True)) + print(f"Commit all changes resources for '{hilite(child, 'blue', True)}'") cmd = ["git", "commit", "-am", message] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") @@ -288,9 +285,9 @@ def perform_push(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Perform push for '%s'" % hilite(child, "blue", True)) + print(f"Perform push for '{hilite(child, 'blue', True)}'") cmd = ["git", "push", "origin", get_branch()] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") @@ -314,9 +311,9 @@ def perform_checkout(arguments): if ".git" not in listdir(child): continue os.chdir(child) - print("Perform checkout for '%s'" % hilite(child, "blue", True)) + print(f"Perform checkout for '{hilite(child, 'blue', True)}'") cmd = ["git", "checkout", "."] - subprocess.call(cmd) + subprocess.call(cmd) # noqa: S603 os.chdir("..") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2201d16 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Test package for mxrepo diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f6ac7d1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_git_repo(tmp_path): + """Create a mock git repository structure.""" + repo_dir = tmp_path / "test_repo" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + return repo_dir + + +@pytest.fixture +def mock_multiple_repos(tmp_path): + """Create multiple mock git repositories.""" + repos = [] + for name in ["repo1", "repo2", "repo3"]: + repo_dir = tmp_path / name + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + repos.append(repo_dir) + + # Add a non-git directory + non_git = tmp_path / "not_a_repo" + non_git.mkdir() + + return tmp_path, repos + + +@pytest.fixture +def mock_github_api_response(): + """Create a mock GitHub API response.""" + return [ + { + "name": "repo1", + "clone_url": "https://github.com/testorg/repo1.git", + "ssh_url": "git@github.com:testorg/repo1.git", + }, + { + "name": "repo2", + "clone_url": "https://github.com/testorg/repo2.git", + "ssh_url": "git@github.com:testorg/repo2.git", + }, + ] + + +@pytest.fixture +def mock_git_branch_output(): + """Mock output from 'git branch' command.""" + return b" develop\n* main\n feature/test\n" + + +@pytest.fixture +def mock_subprocess_popen(mocker): + """Mock subprocess.Popen for git commands.""" + mock_process = MagicMock() + mock_popen = mocker.patch("subprocess.Popen") + mock_popen.return_value = mock_process + return mock_popen, mock_process + + +@pytest.fixture +def mock_subprocess_call(mocker): + """Mock subprocess.call for git commands.""" + return mocker.patch("subprocess.call", return_value=0) + + +@pytest.fixture +def mock_urlopen(mocker, mock_github_api_response): + """Mock urllib.request.urlopen for GitHub API calls.""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(mock_github_api_response).encode() + mock_response.close.return_value = None + + mock_url = mocker.patch("urllib.request.urlopen") + mock_url.return_value = mock_response + return mock_url diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..e1bfbec --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,511 @@ +import json +import os +from argparse import Namespace +from unittest.mock import MagicMock +from unittest.mock import call +from unittest.mock import patch + +import pytest +import urllib.error + +from mxrepo.main import get_branch +from mxrepo.main import hilite +from mxrepo.main import listdir +from mxrepo.main import perform_backup +from mxrepo.main import perform_branch +from mxrepo.main import perform_checkout +from mxrepo.main import perform_clone +from mxrepo.main import perform_commit +from mxrepo.main import perform_diff +from mxrepo.main import perform_pull +from mxrepo.main import perform_push +from mxrepo.main import perform_status +from mxrepo.main import query_repos + + +class TestListdir: + """Tests for the listdir function.""" + + def test_listdir_default_current_directory(self, tmp_path, monkeypatch): + """Test listdir with default current directory.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "file1.txt").touch() + (tmp_path / "file2.txt").touch() + + result = listdir() + assert result == ["file1.txt", "file2.txt"] + + def test_listdir_with_path(self, tmp_path): + """Test listdir with specific path.""" + (tmp_path / "file1.txt").touch() + (tmp_path / "file2.txt").touch() + + result = listdir(str(tmp_path)) + assert result == ["file1.txt", "file2.txt"] + + def test_listdir_returns_sorted(self, tmp_path): + """Test that listdir returns sorted results.""" + (tmp_path / "zebra.txt").touch() + (tmp_path / "alpha.txt").touch() + (tmp_path / "beta.txt").touch() + + result = listdir(str(tmp_path)) + assert result == ["alpha.txt", "beta.txt", "zebra.txt"] + + +class TestHilite: + """Tests for the hilite function.""" + + def test_hilite_green_no_bold(self): + """Test green color without bold.""" + result = hilite("test", "green", False) + assert "\x1b[32m" in result + assert "test" in result + assert "\x1b[0m" in result + assert "1" not in result.split("\x1b[")[1] + + def test_hilite_red_no_bold(self): + """Test red color without bold.""" + result = hilite("test", "red", False) + assert "\x1b[31m" in result + assert "test" in result + assert "\x1b[0m" in result + + def test_hilite_blue_no_bold(self): + """Test blue color without bold.""" + result = hilite("test", "blue", False) + assert "\x1b[34m" in result + assert "test" in result + assert "\x1b[0m" in result + + def test_hilite_green_with_bold(self): + """Test green color with bold.""" + result = hilite("test", "green", True) + assert "\x1b[32;1m" in result + assert "test" in result + assert "\x1b[0m" in result + + def test_hilite_unknown_color(self): + """Test with unknown color (should not add color code).""" + result = hilite("test", "unknown", False) + assert "test" in result + assert "\x1b[0m" in result + + +class TestGetBranch: + """Tests for the get_branch function.""" + + def test_get_branch_returns_current_branch(self, mocker): + """Test that get_branch returns the current branch.""" + mock_process = MagicMock() + mock_process.stdout.readlines.return_value = [ + b" develop\n", + b"* main\n", + b" feature/test\n", + ] + + mock_popen = mocker.patch("subprocess.Popen") + mock_popen.return_value = mock_process + + result = get_branch() + assert result == "main" + + def test_get_branch_with_different_branch(self, mocker): + """Test get_branch with different active branch.""" + mock_process = MagicMock() + mock_process.stdout.readlines.return_value = [ + b"* develop\n", + b" main\n", + ] + + mock_popen = mocker.patch("subprocess.Popen") + mock_popen.return_value = mock_process + + result = get_branch() + assert result == "develop" + + def test_get_branch_with_special_characters(self, mocker): + """Test get_branch with branch containing special characters.""" + mock_process = MagicMock() + mock_process.stdout.readlines.return_value = [ + b"* feature/PROJ-123-my-feature\n", + b" main\n", + ] + + mock_popen = mocker.patch("subprocess.Popen") + mock_popen.return_value = mock_process + + result = get_branch() + assert result == "feature/PROJ-123-my-feature" + + +class TestQueryRepos: + """Tests for the query_repos function.""" + + def test_query_repos_organization_success(self, mocker): + """Test querying repositories from an organization.""" + mock_response1 = MagicMock() + mock_response1.read.return_value = json.dumps([ + {"name": "repo1"}, + {"name": "repo2"}, + ]).encode() + + mock_response2 = MagicMock() + mock_response2.read.return_value = json.dumps([]).encode() + + mock_urlopen = mocker.patch("urllib.request.urlopen") + mock_urlopen.side_effect = [mock_response1, mock_response2] + + result = query_repos("testorg") + + assert len(result) == 2 + assert result[0]["name"] == "repo1" + assert result[1]["name"] == "repo2" + + def test_query_repos_user_fallback(self, mocker): + """Test fallback to user endpoint when org fails.""" + mock_response1 = MagicMock() + mock_response1.read.return_value = json.dumps([ + {"name": "user_repo1"}, + ]).encode() + + mock_response2 = MagicMock() + mock_response2.read.return_value = json.dumps([]).encode() + + mock_urlopen = mocker.patch("urllib.request.urlopen") + # First call (org) fails, second call (user) succeeds, third call (pagination) returns empty + mock_urlopen.side_effect = [ + urllib.error.URLError("Not found"), + mock_response1, + mock_response2, + ] + + result = query_repos("testuser") + + assert len(result) == 1 + assert result[0]["name"] == "user_repo1" + assert mock_urlopen.call_count == 3 + + def test_query_repos_pagination(self, mocker, capsys): + """Test that query_repos handles pagination.""" + # First page has data, second page is empty + mock_response1 = MagicMock() + mock_response1.read.return_value = json.dumps([ + {"name": "repo1"}, + ]).encode() + + mock_response2 = MagicMock() + mock_response2.read.return_value = json.dumps([]).encode() + + mock_urlopen = mocker.patch("urllib.request.urlopen") + mock_urlopen.side_effect = [mock_response1, mock_response2] + + result = query_repos("testorg") + + assert len(result) == 1 + # Verify pagination was attempted + assert mock_urlopen.call_count == 2 + + +class TestPerformClone: + """Tests for the perform_clone function.""" + + def test_perform_clone_specific_repos(self, mocker): + """Test cloning specific repositories.""" + args = Namespace( + context=["testorg"], + repository=["repo1", "repo2"], + ) + + mock_call = mocker.patch("subprocess.call") + + perform_clone(args) + + assert mock_call.call_count == 2 + mock_call.assert_any_call(["git", "clone", "git@github.com:testorg/repo1.git"]) + mock_call.assert_any_call(["git", "clone", "git@github.com:testorg/repo2.git"]) + + def test_perform_clone_all_repos(self, mocker): + """Test cloning all repositories from organization.""" + args = Namespace( + context=["testorg"], + repository=None, + ) + + mock_query = mocker.patch("mxrepo.main.query_repos") + mock_query.return_value = [ + {"name": "repo1"}, + {"name": "repo2"}, + ] + + mock_call = mocker.patch("subprocess.call") + + perform_clone(args) + + mock_query.assert_called_once() + assert mock_call.call_count == 2 + + +class TestPerformPull: + """Tests for the perform_pull function.""" + + def test_perform_pull_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test pulling all repositories in directory.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(repository=None) + + mock_call = mocker.patch("subprocess.call") + mock_get_branch = mocker.patch("mxrepo.main.get_branch", return_value="main") + + perform_pull(args) + + # Should be called for each git repo (3) + assert mock_call.call_count == 3 + + def test_perform_pull_specific_repos(self, tmp_path, mocker, monkeypatch): + """Test pulling specific repositories.""" + monkeypatch.chdir(tmp_path) + + # Create repos + repo1 = tmp_path / "repo1" + repo1.mkdir() + (repo1 / ".git").mkdir() + + args = Namespace(repository=["repo1"]) + + mock_call = mocker.patch("subprocess.call") + mock_get_branch = mocker.patch("mxrepo.main.get_branch", return_value="main") + + perform_pull(args) + + assert mock_call.call_count == 1 + mock_call.assert_called_with(["git", "pull", "origin", "main"]) + + +class TestPerformStatus: + """Tests for the perform_status function.""" + + def test_perform_status_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test status for all repositories.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_status(args) + + assert mock_call.call_count == 3 + + def test_perform_status_specific_repo(self, tmp_path, mocker, monkeypatch): + """Test status for specific repository.""" + monkeypatch.chdir(tmp_path) + + repo1 = tmp_path / "repo1" + repo1.mkdir() + (repo1 / ".git").mkdir() + + args = Namespace(repository=["repo1"]) + mock_call = mocker.patch("subprocess.call") + + perform_status(args) + + assert mock_call.call_count == 1 + mock_call.assert_called_with(["git", "status"]) + + +class TestPerformBranch: + """Tests for the perform_branch function.""" + + def test_perform_branch_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test branch listing for all repositories.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_branch(args) + + assert mock_call.call_count == 3 + + +class TestPerformDiff: + """Tests for the perform_diff function.""" + + def test_perform_diff_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test diff for all repositories.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_diff(args) + + assert mock_call.call_count == 3 + + +class TestPerformCommit: + """Tests for the perform_commit function.""" + + def test_perform_commit_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test committing changes in all repositories.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(message=["Test commit"], repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_commit(args) + + assert mock_call.call_count == 3 + for call_args in mock_call.call_args_list: + assert call_args[0][0] == ["git", "commit", "-am", "Test commit"] + + def test_perform_commit_specific_repo(self, tmp_path, mocker, monkeypatch): + """Test committing changes in specific repository.""" + monkeypatch.chdir(tmp_path) + + repo1 = tmp_path / "repo1" + repo1.mkdir() + (repo1 / ".git").mkdir() + + args = Namespace(message=["Fix bug"], repository=["repo1"]) + mock_call = mocker.patch("subprocess.call") + + perform_commit(args) + + assert mock_call.call_count == 1 + mock_call.assert_called_with(["git", "commit", "-am", "Fix bug"]) + + +class TestPerformPush: + """Tests for the perform_push function.""" + + def test_perform_push_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test pushing all repositories.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + mock_get_branch = mocker.patch("mxrepo.main.get_branch", return_value="main") + + perform_push(args) + + assert mock_call.call_count == 3 + + +class TestPerformCheckout: + """Tests for the perform_checkout function.""" + + def test_perform_checkout_all_repos(self, mock_multiple_repos, mocker, monkeypatch): + """Test discarding changes in all repositories.""" + temp_dir, repos = mock_multiple_repos + monkeypatch.chdir(temp_dir) + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_checkout(args) + + assert mock_call.call_count == 3 + for call_args in mock_call.call_args_list: + assert call_args[0][0] == ["git", "checkout", "."] + + +class TestPerformBackup: + """Tests for the perform_backup function.""" + + def test_perform_backup_new_repos(self, tmp_path, mocker, monkeypatch): + """Test backing up new repositories.""" + monkeypatch.chdir(tmp_path) + + args = Namespace(context=["testorg"]) + + mock_query = mocker.patch("mxrepo.main.query_repos") + mock_query.return_value = [ + {"name": "repo1"}, + {"name": "repo2"}, + ] + + mock_perform = mocker.patch("mxrepo.main.perform") + + perform_backup(args) + + # Should create directory and clone repos + assert (tmp_path / "testorg").exists() + assert mock_perform.call_count == 2 + + def test_perform_backup_existing_repos(self, tmp_path, mocker, monkeypatch): + """Test backing up when repos already exist.""" + monkeypatch.chdir(tmp_path) + + # Create context directory with existing repo + context_dir = tmp_path / "testorg" + context_dir.mkdir() + (context_dir / "repo1.git").mkdir() + + monkeypatch.chdir(tmp_path) + + args = Namespace(context=["testorg"]) + + mock_query = mocker.patch("mxrepo.main.query_repos") + mock_query.return_value = [ + {"name": "repo1"}, + ] + + mock_perform = mocker.patch("mxrepo.main.perform") + + perform_backup(args) + + # Should fetch instead of clone + mock_perform.assert_called_once() + call_args = mock_perform.call_args[0][0] + assert "fetch" in call_args + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_non_git_directories_are_skipped(self, tmp_path, mocker, monkeypatch): + """Test that non-git directories are skipped.""" + monkeypatch.chdir(tmp_path) + + # Create git and non-git directories + git_repo = tmp_path / "git_repo" + git_repo.mkdir() + (git_repo / ".git").mkdir() + + non_git = tmp_path / "non_git" + non_git.mkdir() + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_status(args) + + # Should only be called for git_repo + assert mock_call.call_count == 1 + + def test_files_are_skipped(self, tmp_path, mocker, monkeypatch): + """Test that files (not directories) are skipped.""" + monkeypatch.chdir(tmp_path) + + # Create a git repo and a file + git_repo = tmp_path / "git_repo" + git_repo.mkdir() + (git_repo / ".git").mkdir() + + (tmp_path / "somefile.txt").touch() + + args = Namespace(repository=None) + mock_call = mocker.patch("subprocess.call") + + perform_status(args) + + # Should only be called for git_repo + assert mock_call.call_count == 1