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
14 changes: 12 additions & 2 deletions .github/workflows/rhiza_sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ jobs:
RHIZA_VERSION="${{ steps.rhiza-version.outputs.version }}"

echo "Running rhiza sync with version >=${RHIZA_VERSION}"
uvx "rhiza>=${RHIZA_VERSION}" sync .
if ! uvx "rhiza>=${RHIZA_VERSION}" sync .; then
echo "::error::Rhiza sync failed with conflicts"
echo "::error::Conflict markers or .rej files have been created"
echo "::error::Please resolve conflicts manually in your local repository"
exit 1
fi

if git diff --quiet; then
echo "No changes detected after template sync"
Expand Down Expand Up @@ -146,7 +151,12 @@ jobs:

RHIZA_VERSION="${{ steps.rhiza-version.outputs.version }}"

uvx "rhiza>=${RHIZA_VERSION}" sync .
if ! uvx "rhiza>=${RHIZA_VERSION}" sync .; then
echo "::error::Rhiza sync failed with conflicts"
echo "::error::Conflict markers or .rej files have been created"
echo "::error::Please resolve conflicts manually in your local repository"
exit 1
fi

git add -A

Expand Down
8 changes: 7 additions & 1 deletion src/rhiza/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def sync(
upstream_snapshot=upstream_snapshot,
)
else:
git_ctx.sync_merge(
clean = git_ctx.sync_merge(
target=target,
upstream_snapshot=upstream_snapshot,
upstream_sha=upstream_sha,
Expand All @@ -250,6 +250,12 @@ def sync(
lock=lock,
lock_file=lock_file,
)
if not clean:
logger.error("Sync completed with conflicts")
logger.error("Conflict markers or .rej files have been created")
logger.error("Please resolve conflicts manually before committing")
msg = "Sync completed with merge conflicts"
raise RuntimeError(msg)
finally:
if upstream_snapshot.exists():
shutil.rmtree(upstream_snapshot)
Expand Down
19 changes: 15 additions & 4 deletions src/rhiza/models/_git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ def sync_merge(
excludes: set[str],
lock: "TemplateLock",
lock_file: "Path | None" = None,
) -> None:
) -> bool:
"""Execute the merge strategy (cruft-style 3-way merge).

When a base SHA exists, computes the diff between base and upstream
Expand All @@ -440,6 +440,9 @@ def sync_merge(
lock: Pre-built :class:`~rhiza.models.TemplateLock` for this sync.
lock_file: Optional explicit path for the lock file. When ``None``
the default ``<target>/.rhiza/template.lock`` is used.

Returns:
True if all changes applied cleanly, False if any conflicts remain.
"""
from rhiza.commands._sync_helpers import (
_clean_orphaned_files,
Expand All @@ -455,9 +458,10 @@ def sync_merge(
old_tracked_files = _read_previously_tracked_files(target, lock_file=lock_file)

base_snapshot = Path(tempfile.mkdtemp())
clean = True
try:
if base_sha:
self._merge_with_base(
clean = self._merge_with_base(
target,
upstream_snapshot,
upstream_sha,
Expand Down Expand Up @@ -497,6 +501,8 @@ def sync_merge(
if base_snapshot.exists():
shutil.rmtree(base_snapshot)

return clean

def update_sparse_checkout(
self,
tmp_dir: Path,
Expand Down Expand Up @@ -714,7 +720,7 @@ def _merge_with_base(
excludes: set[str],
lock: "TemplateLock",
lock_file: "Path | None" = None,
) -> None:
) -> bool:
"""Compute and apply the diff between base and upstream snapshots.

Args:
Expand All @@ -728,6 +734,9 @@ def _merge_with_base(
lock: Pre-built :class:`~rhiza.models.TemplateLock` for this sync.
lock_file: Optional explicit path for the lock file. When ``None``
the default ``<target>/.rhiza/template.lock`` is used.

Returns:
True if all changes applied cleanly, False if any conflicts remain.
"""
from rhiza.commands._sync_helpers import _write_lock

Expand All @@ -747,7 +756,7 @@ def _merge_with_base(
if not diff.strip():
logger.success("Template unchanged since last sync — nothing to apply")
_write_lock(target, lock, lock_file=lock_file)
return
return True

logger.info("Applying template changes via 3-way merge (cruft)...")
clean = self._apply_diff(diff, target, base_snapshot=base_snapshot, upstream_snapshot=upstream_snapshot)
Expand All @@ -757,6 +766,8 @@ def _merge_with_base(
else:
logger.warning("Some changes had conflicts. Check for *.rej files and resolve manually.")

return clean


def _normalize_to_list(value: Any | list[Any] | None) -> list[Any]:
r"""Convert a value to a list of strings.
Expand Down
46 changes: 45 additions & 1 deletion tests/test_commands/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,17 @@ def test_sync_cli_calls_sync_function(self, mock_sync, tmp_path):
assert result.exit_code == 0
mock_sync.assert_called_once()

@patch("rhiza.cli.sync_cmd")
def test_sync_cli_exits_with_error_on_conflict(self, mock_sync, tmp_path):
"""CLI should exit with code 1 when sync raises RuntimeError due to conflicts."""
mock_sync.side_effect = RuntimeError("Sync completed with merge conflicts")
result = self.runner.invoke(
cli.app,
["sync", str(tmp_path)],
)
assert result.exit_code == 1
mock_sync.assert_called_once()


class TestApplyDiffConflict:
"""Tests for conflict-handling branch in _apply_diff."""
Expand Down Expand Up @@ -587,7 +598,39 @@ def test_merge_with_base_clean_apply(
base_snapshot = tmp_path / "base"
base_snapshot.mkdir()

git_ctx._merge_with_base(
result = git_ctx._merge_with_base(
git_project,
upstream_snapshot,
"newsha",
"oldsha",
base_snapshot,
RhizaTemplate(template_repository="example/repo", include=["file.txt"]),
set(),
TemplateLock(sha="newsha"),
)

assert result is True
mock_apply.assert_called_once()

@patch("rhiza.models._git_utils.GitContext._apply_diff")
@patch("rhiza.models._git_utils.GitContext.get_diff")
@patch("rhiza.models._git_utils.GitContext.clone_at_sha")
@patch("rhiza.models._git_utils._prepare_snapshot")
def test_merge_with_base_conflict_returns_false(
self, mock_prepare, mock_clone, mock_get_diff, mock_apply, tmp_path, git_project, git_ctx
):
"""When diff has conflicts, _merge_with_base returns False."""
mock_get_diff.return_value = (
"diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n"
)
mock_apply.return_value = False # conflict

upstream_snapshot = tmp_path / "upstream"
upstream_snapshot.mkdir()
base_snapshot = tmp_path / "base"
base_snapshot.mkdir()

result = git_ctx._merge_with_base(
git_project,
upstream_snapshot,
"newsha",
Expand All @@ -598,6 +641,7 @@ def test_merge_with_base_clean_apply(
TemplateLock(sha="newsha"),
)

assert result is False
mock_apply.assert_called_once()


Expand Down
10 changes: 9 additions & 1 deletion tests/test_commands/test_sync_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,23 @@ def test_diff_strategy_does_not_modify_files_or_write_lock(
assert (tmp_path / "test.txt").read_text() == "local content"
assert not (tmp_path / ".rhiza" / "template.lock").exists()

@patch("rhiza.models._git_utils.GitContext._apply_diff")
@patch("rhiza.models._git_utils.GitContext.get_diff")
@patch("rhiza.commands.sync.shutil.rmtree")
@patch("rhiza.models._git_utils.GitContext.clone_repository")
@patch("rhiza.commands.sync.tempfile.mkdtemp")
@patch("rhiza.models._git_utils.GitContext.get_head_sha")
def test_subsequent_merge_updates_lock_sha(self, mock_sha, mock_mkdtemp, mock_clone, mock_rmtree, tmp_path):
def test_subsequent_merge_updates_lock_sha(
self, mock_sha, mock_mkdtemp, mock_clone, mock_rmtree, mock_get_diff, mock_apply, tmp_path
):
"""When upstream has a newer SHA, merge updates the lock to the new SHA."""
_setup_project(tmp_path)
_write_lock(tmp_path, TemplateLock(sha="old111"))
mock_sha.return_value = "new222"
mock_get_diff.return_value = (
"diff --git a/test.txt b/test.txt\n--- a/test.txt\n+++ b/test.txt\n@@ -1 +1 @@\n-old\n+new\n"
)
mock_apply.return_value = True # Clean merge, no conflicts

clone_dir = _make_clone_dir(tmp_path, "upstream_clone", {"test.txt": "updated content\n"})
snapshot_dir = _make_clone_dir(tmp_path, "upstream_snapshot", {})
Expand Down
Loading