Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/rhiza/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Commands are thin wrappers around implementations in `rhiza.commands.*`.
"""

import shlex
from pathlib import Path
from typing import Annotated

Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand Down
29 changes: 22 additions & 7 deletions src/rhiza/commands/materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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:")
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- The inject/materialize commands
"""

import re
import shutil
import subprocess
import sys
Expand Down Expand Up @@ -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
107 changes: 107 additions & 0 deletions tests/test_commands/test_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Loading