diff --git a/src/rhiza/cli.py b/src/rhiza/cli.py index 1062ef0..46eb993 100644 --- a/src/rhiza/cli.py +++ b/src/rhiza/cli.py @@ -4,6 +4,7 @@ Commands are thin wrappers around implementations in `rhiza.commands.*`. """ +import shlex from pathlib import Path from typing import Annotated @@ -157,6 +158,11 @@ def materialize( help="Create and checkout a new branch in the target repository for changes", ), force: bool = typer.Option(False, "--force", "-y", help="Overwrite existing files"), + sync_only: str = typer.Option( + None, + "--sync-only", + help="Space-separated list of specific files/directories to sync (e.g., 'ruff.toml docker/')", + ), ) -> None: r"""Inject Rhiza configuration templates into a target repository. @@ -175,8 +181,13 @@ def materialize( rhiza materialize --force rhiza materialize --target-branch feature/update-templates rhiza materialize /path/to/project -b v2.0 -y + rhiza materialize --sync-only "ruff.toml docker/" . + rhiza materialize --force --sync-only "ruff.toml docker/" . """ - materialize_cmd(target, branch, target_branch, force) + # Parse sync_only into a list if provided + # Use shlex.split to properly handle quoted paths with spaces + sync_only_list = shlex.split(sync_only) if sync_only else None + materialize_cmd(target, branch, target_branch, force, sync_only_list) @app.command() diff --git a/src/rhiza/commands/materialize.py b/src/rhiza/commands/materialize.py index 2866139..dba3ba2 100644 --- a/src/rhiza/commands/materialize.py +++ b/src/rhiza/commands/materialize.py @@ -90,12 +90,13 @@ def _handle_target_branch( sys.exit(1) -def _validate_and_load_template(target: Path, branch: str) -> tuple[RhizaTemplate, str, str, list[str], list[str]]: +def _validate_and_load_template(target: Path, branch: str, sync_only: list[str] | None = None) -> tuple[RhizaTemplate, str, str, list[str], list[str]]: """Validate configuration and load template settings. Args: target: Path to the target repository. branch: The Rhiza template branch to use (CLI argument). + sync_only: Optional list of specific paths to sync (overrides include paths). Returns: Tuple of (template, rhiza_repo, rhiza_branch, include_paths, excluded_paths). @@ -117,14 +118,26 @@ def _validate_and_load_template(target: Path, branch: str) -> tuple[RhizaTemplat logger.error("template-repository is not configured in template.yml") raise RuntimeError("template-repository is required") # noqa: TRY003 rhiza_branch = template.template_branch or branch - include_paths = template.include + + # Use sync_only if provided, otherwise use include from template + if sync_only is not None: + include_paths = sync_only + logger.info("Using --sync-only paths (overriding template.yml include)") + else: + include_paths = template.include + excluded_paths = template.exclude # Validate that we have paths to include if not include_paths: - logger.error("No include paths found in template.yml") - logger.error("Add at least one path to the 'include' list in template.yml") - raise RuntimeError("No include paths found in template.yml") # noqa: TRY003 + if sync_only is not None: + logger.error("No paths specified in --sync-only") + logger.error("Please provide at least one file or directory to sync") + raise RuntimeError("No paths specified in --sync-only") # noqa: TRY003 + else: + logger.error("No include paths found in template.yml") + logger.error("Add at least one path to the 'include' list in template.yml") + raise RuntimeError("No include paths found in template.yml") # noqa: TRY003 # Log the paths we'll be including logger.info("Include paths:") @@ -464,7 +477,7 @@ def __expand_paths(base_dir: Path, paths: list[str]) -> list[Path]: return all_files -def materialize(target: Path, branch: str, target_branch: str | None, force: bool) -> None: +def materialize(target: Path, branch: str, target_branch: str | None, force: bool, sync_only: list[str] | None = None) -> None: """Materialize Rhiza templates into the target repository. This performs a sparse checkout of the template repository and copies the @@ -477,6 +490,8 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo target_branch (str | None): Optional branch name to create/checkout in the target repository. force (bool): Whether to overwrite existing files. + sync_only (list[str] | None): Optional list of specific paths to sync + (overrides include paths from template.yml). """ target = target.resolve() logger.info(f"Target repository: {target}") @@ -492,7 +507,7 @@ def materialize(target: Path, branch: str, target_branch: str | None, force: boo _handle_target_branch(target, target_branch, git_executable, git_env) # Validate and load template configuration - template, rhiza_repo, rhiza_branch, include_paths, excluded_paths = _validate_and_load_template(target, branch) + template, rhiza_repo, rhiza_branch, include_paths, excluded_paths = _validate_and_load_template(target, branch, sync_only) rhiza_host = template.template_host or "github" # Construct git URL diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 47976f9..fcb0e39 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -6,6 +6,7 @@ - The inject/materialize commands """ +import re import shutil import subprocess import sys @@ -254,3 +255,22 @@ def test_summarise_cli_wrapper_coverage(self, tmp_path): assert "test/repo" in result.stdout assert "custom-branch" in result.stdout assert "files added" in result.stdout + + +class TestMaterializeCommand: + """Tests for the materialize command.""" + + def test_materialize_help_includes_sync_only(self): + """Test that materialize command help includes --sync-only option.""" + result = subprocess.run( + [sys.executable, "-m", "rhiza", "materialize", "--help"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + # Strip ANSI color codes for easier testing + clean_output = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout) + + assert "--sync-only" in clean_output + assert "Space-separated list of specific files/directories to sync" in clean_output diff --git a/tests/test_commands/test_materialize.py b/tests/test_commands/test_materialize.py index a62be95..1cdb1f8 100644 --- a/tests/test_commands/test_materialize.py +++ b/tests/test_commands/test_materialize.py @@ -1871,3 +1871,110 @@ def test_materialize_does_not_delete_orphaned_template_yml( content = template_file.read_text() assert "template-repository: test/repo" in content assert 'include: ["other.txt"]' in content + + @patch("rhiza.commands.materialize.subprocess.run") + @patch("rhiza.commands.materialize.shutil.rmtree") + @patch("rhiza.commands.materialize.shutil.copy2") + @patch("rhiza.commands.materialize.tempfile.mkdtemp") + def test_sync_only_overrides_include_paths(self, mock_mkdtemp, mock_copy2, mock_rmtree, mock_subprocess, tmp_path): + """Test that --sync-only parameter overrides include paths from template.yml.""" + # Setup git repo + git_dir = tmp_path / ".git" + git_dir.mkdir() + + # Create pyproject.toml for validation + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + # Create template.yml with multiple include paths + rhiza_dir = tmp_path / ".rhiza" + rhiza_dir.mkdir(parents=True, exist_ok=True) + template_file = rhiza_dir / "template.yml" + + with open(template_file, "w") as f: + yaml.dump( + { + "template-repository": "test/repo", + "template-branch": "main", + "include": [".github", "ruff.toml", "docker"], + }, + f, + ) + + # Mock tempfile + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + mock_mkdtemp.return_value = str(temp_dir) + + # Create files in temp directory + (temp_dir / "ruff.toml").write_text("ruff config") + docker_dir = temp_dir / "docker" + docker_dir.mkdir() + (docker_dir / "Dockerfile").write_text("FROM python:3.11") + + mock_subprocess.return_value = Mock(returncode=0) + + # Run materialize with sync_only + sync_only_list = ["ruff.toml", "docker/"] + materialize(tmp_path, "main", None, False, sync_only_list) + + # Verify that git sparse-checkout set was called with sync_only paths + # Find the sparse-checkout set call by checking each call's args + sparse_checkout_set_call = None + for call in mock_subprocess.call_args_list: + if call[0] and isinstance(call[0][0], list): + cmd = call[0][0] + if len(cmd) > 2 and cmd[1] == "sparse-checkout" and cmd[2] == "set": + sparse_checkout_set_call = cmd + break + + # Should have found the sparse-checkout set call + assert sparse_checkout_set_call is not None, "sparse-checkout set command not found" + + # Verify sync_only paths are used, not template include paths + assert "ruff.toml" in sparse_checkout_set_call + assert "docker/" in sparse_checkout_set_call + # .github should NOT be in the sparse checkout call + assert ".github" not in sparse_checkout_set_call + + @patch("rhiza.commands.materialize.subprocess.run") + @patch("rhiza.commands.materialize.shutil.rmtree") + @patch("rhiza.commands.materialize.shutil.copy2") + @patch("rhiza.commands.materialize.tempfile.mkdtemp") + def test_sync_only_with_empty_list_fails(self, mock_mkdtemp, mock_copy2, mock_rmtree, mock_subprocess, tmp_path): + """Test that --sync-only with empty list shows appropriate error.""" + # Setup git repo + git_dir = tmp_path / ".git" + git_dir.mkdir() + + # Create pyproject.toml for validation + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "test"\n') + + # Create template.yml with include paths + rhiza_dir = tmp_path / ".rhiza" + rhiza_dir.mkdir(parents=True, exist_ok=True) + template_file = rhiza_dir / "template.yml" + + with open(template_file, "w") as f: + yaml.dump( + { + "template-repository": "test/repo", + "template-branch": "main", + "include": [".github"], + }, + f, + ) + + # Mock tempfile + temp_dir = tmp_path / "temp" + temp_dir.mkdir() + mock_mkdtemp.return_value = str(temp_dir) + + mock_subprocess.return_value = Mock(returncode=0) + + # Run materialize with empty sync_only list - should fail with appropriate error + sync_only_list = [] + with pytest.raises(RuntimeError, match="No paths specified in --sync-only"): + materialize(tmp_path, "main", None, False, sync_only_list) +