Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/poly/cli_commands/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
_BRANCH_MERGE_LONG_LINE_THRESHOLD = 800


def _is_sequence_mismatch(errors: list[dict[str, Any]]) -> bool:
messages = (str(err.get("message", "")).lower() for err in errors)
return any("sequence mismatch" in m or "sequence_mismatch" in m for m in messages)


def _branch_merge_conflict_file_key(path: list[str]) -> str:
"""Group field-level API conflicts by parent path (resource-ish key)."""
if not path:
Expand Down Expand Up @@ -1182,6 +1187,11 @@ def _report_merge_success() -> None:
plain("\n[red]Errors:[/red]")
for err in errors:
error(f"- {err['path']}: {err['message']}")
if _is_sequence_mismatch(errors):
warning(
"The branch changed while merging (e.g. a draft deployment finished). "
"Re-run the merge."
)

enriched = enrich_branch_merge_conflicts(conflicts) if conflicts else []
display_conflict = [
Expand Down Expand Up @@ -1503,6 +1513,11 @@ def branch_sync(
plain("\n[red]Errors:[/red]")
for err in errors:
error(f"- {err['path']}: {err['message']}")
if _is_sequence_mismatch(errors):
warning(
"The branch changed while syncing (e.g. a draft deployment finished). "
"Re-run the sync."
)

enriched = enrich_branch_merge_conflicts(conflicts) if conflicts else []
display_conflict = [
Expand Down
7 changes: 5 additions & 2 deletions src/poly/handlers/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,10 @@ def merge_branch(
)
"""
try:
# Fetch fresh; the cached sequence can lag after a write.
self._last_known_sequence = self.fetch_last_known_sequence_number()
payload = {
"expectedBranchLastKnownSequence": self.get_last_known_sequence() or 0,
"expectedBranchLastKnownSequence": self._last_known_sequence,
}

if deployment_message:
Expand Down Expand Up @@ -412,8 +414,9 @@ def sync_branch(
)
"""
try:
self._last_known_sequence = self.fetch_last_known_sequence_number()
payload = {
"expectedBranchSequence": self.get_last_known_sequence() or 0,
"expectedBranchSequence": self._last_known_sequence,
}

if conflict_resolutions is not None:
Expand Down
4 changes: 2 additions & 2 deletions src/poly/handlers/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ def merge_branch(
logger.error(
f"Failed to merge branch '{self.sdk.branch_id}' into its parent branch: {e}"
)
return False, [], []
return False, [], [{"path": [], "message": str(e)}]

if result.get("hasConflicts", False) or result.get("errors", []):
logger.info(
Expand Down Expand Up @@ -390,7 +390,7 @@ def sync_branch(
)
except SourcererAPIError as e:
logger.error(f"Failed to sync branch '{self.sdk.branch_id}': {e}")
return False, [], []
return False, [], [{"path": [], "message": str(e)}]

if result.get("hasConflicts", False) or result.get("errors", []):
logger.info(
Expand Down
55 changes: 55 additions & 0 deletions src/poly/tests/api/sdk_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,5 +231,60 @@ def test_request_failure_raises_sourcerer_error(self, mock_post, _mock_key):
sdk.send_command_batch()


class MergeBranch(unittest.TestCase):
"""Tests for SourcererSDK.merge_branch sequence handling."""

def _sdk_with_sequence(self, server_sequence: int):
sdk = build_sdk()
session = MagicMock()
session.get.return_value = make_mock_response(
200, json_body={"lastKnownSequence": str(server_sequence)}
)
session.post.return_value = make_mock_response(
200, json_body={"sequence": str(server_sequence + 1), "message": "ok"}
)
sdk._session = session
return sdk, session

def test_merge_fetches_fresh_sequence_over_stale_cache(self):
"""The merge payload carries the server's sequence, not the cached one."""
sdk, session = self._sdk_with_sequence(server_sequence=1751)
# Stale cache, e.g. from a projection read that lagged the event store.
sdk._last_known_sequence = 1750

sdk.merge_branch(deployment_message="msg")

session.get.assert_called_once_with(
"https://sourcerer.test/accounts/acc-1/projects/proj-1/branches/branch-1/sequence"
)
payload = session.post.call_args.kwargs["json"]
self.assertEqual(payload["expectedBranchLastKnownSequence"], 1751)
self.assertEqual(sdk._last_known_sequence, 1751)

def test_sync_fetches_fresh_sequence_over_stale_cache(self):
"""sync_branch refreshes the sequence the same way."""
sdk, session = self._sdk_with_sequence(server_sequence=1751)
sdk._last_known_sequence = 1750

sdk.sync_branch()

payload = session.post.call_args.kwargs["json"]
self.assertEqual(payload["expectedBranchSequence"], 1751)

def test_sequence_mismatch_error_raises_sourcerer_error(self):
"""A non-conflict 400 (e.g. SEQUENCE_MISMATCH) raises SourcererAPIError."""
sdk, session = self._sdk_with_sequence(server_sequence=1750)
session.post.return_value = make_mock_response(
400,
json_body={
"error": "sequence mismatch, received 1750 but expected 1751",
"error_code": "SEQUENCE_MISMATCH",
},
)

with self.assertRaises(SourcererAPIError):
sdk.merge_branch(deployment_message="msg")


if __name__ == "__main__":
unittest.main()
36 changes: 36 additions & 0 deletions src/poly/tests/api/sync_client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,5 +215,41 @@ def test_api_error_returns_false(self):
self.assertFalse(result)


class MergeBranch(unittest.TestCase):
"""Tests for SyncClientHandler.merge_branch / sync_branch error reporting."""

def _handler_on_branch(self):
handler = build_handler()
handler._sdk.branch_id = "branch-1"
handler._sdk.fetch_branches.return_value = {"branches": [{"branchId": "branch-1"}]}
return handler

def test_merge_api_error_is_returned_in_errors(self):
"""A SourcererAPIError (e.g. SEQUENCE_MISMATCH) is surfaced to the caller."""
handler = self._handler_on_branch()
handler._sdk.merge_branch.side_effect = SourcererAPIError(
"API Error 400: sequence mismatch, received 1750 but expected 1751"
)

success, conflicts, errors = handler.merge_branch(message="msg")

self.assertFalse(success)
self.assertEqual(conflicts, [])
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0]["path"], [])
self.assertIn("sequence mismatch", errors[0]["message"])

def test_sync_api_error_is_returned_in_errors(self):
"""sync_branch reports API errors the same way as merge_branch."""
handler = self._handler_on_branch()
handler._sdk.sync_branch.side_effect = SourcererAPIError("API Error 400: boom")

success, conflicts, errors = handler.sync_branch()

self.assertFalse(success)
self.assertEqual(conflicts, [])
self.assertEqual(errors, [{"path": [], "message": "API Error 400: boom"}])


if __name__ == "__main__":
unittest.main()
Loading