From d918132b40d8aee808bcb5246181618762290ba2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:13:33 +0000 Subject: [PATCH 1/5] Initial plan From 46a0ca82f6468a5b615aa0d6cb3e3ac66bfd2175 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:18:19 +0000 Subject: [PATCH 2/5] Add --sync-only feature to materialize command Co-authored-by: HarryCampion <40582604+HarryCampion@users.noreply.github.com> --- src/rhiza/cli.py | 11 ++++- src/rhiza/commands/materialize.py | 18 +++++-- tests/test_cli_commands.py | 21 +++++++++ tests/test_commands/test_materialize.py | 62 +++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/rhiza/cli.py b/src/rhiza/cli.py index 1062ef0..6fc37ca 100644 --- a/src/rhiza/cli.py +++ b/src/rhiza/cli.py @@ -157,6 +157,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 +180,12 @@ 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 + sync_only_list = sync_only.split() 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..136b9a5 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,7 +118,14 @@ 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: + 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 @@ -464,7 +472,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 +485,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 +502,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..12fc938 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -254,3 +254,24 @@ 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.""" + import re + + 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..e26ae82 100644 --- a/tests/test_commands/test_materialize.py +++ b/tests/test_commands/test_materialize.py @@ -1871,3 +1871,65 @@ 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, not template include paths + sparse_checkout_set_calls = [ + call for call in mock_subprocess.call_args_list + if len(call[0]) > 0 and isinstance(call[0][0], list) and + "sparse-checkout" in call[0][0] and "set" in call[0][0] + ] + + # Should have one call to set sparse-checkout paths + assert len(sparse_checkout_set_calls) == 1 + call_args = sparse_checkout_set_calls[0][0][0] # Get the command list + assert "ruff.toml" in call_args + assert "docker/" in call_args + # .github should NOT be in the sparse checkout call + assert ".github" not in call_args + From 708ba35e5d451af5d8604ac0a6e5091349f0622a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:21:07 +0000 Subject: [PATCH 3/5] Fix import placement in test file Co-authored-by: HarryCampion <40582604+HarryCampion@users.noreply.github.com> --- tests/test_cli_commands.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 12fc938..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 @@ -261,17 +262,15 @@ class TestMaterializeCommand: def test_materialize_help_includes_sync_only(self): """Test that materialize command help includes --sync-only option.""" - import re - 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 From d3dedef88186832e2f2d98c076b677bbd41bacec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:24:03 +0000 Subject: [PATCH 4/5] Address code review feedback - improve error messages and test logic Co-authored-by: HarryCampion <40582604+HarryCampion@users.noreply.github.com> --- src/rhiza/commands/materialize.py | 13 +++-- tests/test_commands/test_materialize.py | 71 ++++++++++++++++++++----- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/rhiza/commands/materialize.py b/src/rhiza/commands/materialize.py index 136b9a5..dba3ba2 100644 --- a/src/rhiza/commands/materialize.py +++ b/src/rhiza/commands/materialize.py @@ -120,7 +120,7 @@ def _validate_and_load_template(target: Path, branch: str, sync_only: list[str] rhiza_branch = template.template_branch or branch # Use sync_only if provided, otherwise use include from template - if sync_only: + if sync_only is not None: include_paths = sync_only logger.info("Using --sync-only paths (overriding template.yml include)") else: @@ -130,9 +130,14 @@ def _validate_and_load_template(target: Path, branch: str, sync_only: list[str] # 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:") diff --git a/tests/test_commands/test_materialize.py b/tests/test_commands/test_materialize.py index e26ae82..1cdb1f8 100644 --- a/tests/test_commands/test_materialize.py +++ b/tests/test_commands/test_materialize.py @@ -1918,18 +1918,63 @@ def test_sync_only_overrides_include_paths(self, mock_mkdtemp, mock_copy2, mock_ 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, not template include paths - sparse_checkout_set_calls = [ - call for call in mock_subprocess.call_args_list - if len(call[0]) > 0 and isinstance(call[0][0], list) and - "sparse-checkout" in call[0][0] and "set" in call[0][0] - ] - - # Should have one call to set sparse-checkout paths - assert len(sparse_checkout_set_calls) == 1 - call_args = sparse_checkout_set_calls[0][0][0] # Get the command list - assert "ruff.toml" in call_args - assert "docker/" in call_args + # 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 call_args + 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) From f95798b663af1a9dd5e18a61d708ddc4f1fd0a7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:25:40 +0000 Subject: [PATCH 5/5] Use shlex.split for robust path parsing in --sync-only Co-authored-by: HarryCampion <40582604+HarryCampion@users.noreply.github.com> --- src/rhiza/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rhiza/cli.py b/src/rhiza/cli.py index 6fc37ca..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 @@ -184,7 +185,8 @@ def materialize( rhiza materialize --force --sync-only "ruff.toml docker/" . """ # Parse sync_only into a list if provided - sync_only_list = sync_only.split() if sync_only else None + # 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)