[PATCH b4 v2 42/44] tests: cover a failed checkout leaving the tracking list standing

Christian Brauner <[email protected]> Fri, 31 Jul 2026 23:59:23 +0200
Newsgroups org.kernel.linux.tools
Message-ID <20260731-work-b4-editor-branch-guard-v2-42-243fd19d322d@kernel.org>
Both routes into create_review_branch() report a failure and leave
the app standing. Backing out of the conflict shell must still create
nothing.

Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 src/tests/test_tui_tracking.py | 104 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 104 insertions(+)

diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 25bf13a..5a8b914 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -4405,6 +4405,110 @@ class TestUpdateRevisionWorkflow:
         assert b4.git_branch_exists(gitdir, upgrade_branch)
 
 
+class TestCheckoutFailure:
+    """A series that will not apply costs the series, not the session."""
+
+    @staticmethod
+    def _checkout(app: TrackingApp, dies: Any) -> None:
+        """Run _do_checkout with a create_review_branch that raises *dies*."""
+        series = {'change_id': 'checkout-fail', 'revision': 1, 'message_id': '[email protected]'}
+        with (
+            patch.object(
+                app, 'suspend', return_value=__import__('contextlib').nullcontext()
+            ),
+            patch('b4.review_tui._tracking_app._wait_for_enter'),
+            patch('b4.git_fetch_am_into_repo'),
+            patch('b4.review.create_review_branch', side_effect=dies),
+        ):
+            app._do_checkout(_make_mock_lser(revision=1), series, 'HEAD', b'mbox')
+
+    @pytest.mark.asyncio
+    async def test_the_exit_a_failed_create_reports_with_is_caught(
+        self, gitdir: str
+    ) -> None:
+        """create_review_branch() exits rather than raising, and SystemExit is
+        not an Exception, so it used to unwind through the suspend and out of
+        the key handler -- taking the session down over one series."""
+        app = TrackingApp('test-checkout-fail')
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            self._checkout(app, SystemExit(1))
+            await pilot.pause()
+            assert app.is_running
+
+    @pytest.mark.asyncio
+    async def test_a_create_after_a_resolved_conflict_is_caught_too(
+        self, gitdir: str
+    ) -> None:
+        """The branch used to be created a second time from inside the
+        conflict handler, where the handler beside it cannot reach: an except
+        block is not covered by its own siblings."""
+        app = TrackingApp('test-checkout-conflict')
+        created: List[str] = []
+
+        def _die(topdir: str, branch: str, *a: Any, **kw: Any) -> None:
+            created.append(branch)
+            raise SystemExit(1)
+
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            with (
+                patch.object(
+                    app, 'suspend', return_value=__import__('contextlib').nullcontext()
+                ),
+                patch('b4.review_tui._tracking_app._wait_for_enter'),
+                patch(
+                    'b4.git_fetch_am_into_repo',
+                    side_effect=b4.AmConflictError('/tmp/wt', 'conflict'),
+                ),
+                patch('b4.resolve_am_conflict_in_shell', return_value=True),
+                patch('b4.review.create_review_branch', side_effect=_die),
+            ):
+                app._do_checkout(
+                    _make_mock_lser(revision=1),
+                    {'change_id': 'checkout-conflict', 'revision': 1},
+                    'HEAD',
+                    b'mbox',
+                )
+            await pilot.pause()
+            assert app.is_running
+        # The resolve leads into the one create, not a second copy of it.
+        assert created == ['b4/review/checkout-conflict']
+
+    @pytest.mark.asyncio
+    async def test_an_abandoned_conflict_creates_nothing(self, gitdir: str) -> None:
+        """Backing out of the conflict shell leaves the series untouched."""
+        app = TrackingApp('test-checkout-abandon')
+        created: List[str] = []
+
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            with (
+                patch.object(
+                    app, 'suspend', return_value=__import__('contextlib').nullcontext()
+                ),
+                patch('b4.review_tui._tracking_app._wait_for_enter'),
+                patch(
+                    'b4.git_fetch_am_into_repo',
+                    side_effect=b4.AmConflictError('/tmp/wt', 'conflict'),
+                ),
+                patch('b4.resolve_am_conflict_in_shell', return_value=False),
+                patch(
+                    'b4.review.create_review_branch',
+                    side_effect=lambda _t, b, *a, **k: created.append(b),
+                ),
+            ):
+                app._do_checkout(
+                    _make_mock_lser(revision=1),
+                    {'change_id': 'checkout-abandon', 'revision': 1},
+                    'HEAD',
+                    b'mbox',
+                )
+            await pilot.pause()
+            assert app.is_running
+        assert not created
+
+
 class TestLoadSeriesCaching:
     """Tests for _load_series batching and caching."""
 

-- 
2.53.0