[PATCH b4 v3 9/9] review-tui: test take-worktree conflict abort and dirty-target guard

Christian Brauner <[email protected]> Thu, 25 Jun 2026 14:09:29 +0200
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
Cover the two failure modes the previous commit fixes, plus the shared
helpers underneath:

  - _worktree_inprogress_op detects an in-progress merge/cherry-pick (and a
    clean worktree returns nothing); _abort_worktree_op aborts a conflicted
    merge and falls back to git reset --merge for a bare unmerged index.
  - A real checkout left mid-conflict is restored (counterpart to the
    throwaway-kept case), and a take refuses up front on a target worktree
    that is already mid-op or carries a conflicted index.

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

diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index caacd3e..c2b027e 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -24,7 +24,12 @@ from textual.widgets import Input, ListView, Static
 import b4
 import b4.review
 import b4.review.tracking as tracking
-from b4 import _worktree_merge_in_progress
+from b4 import (
+    _abort_worktree_op,
+    _worktree_has_unmerged,
+    _worktree_inprogress_op,
+    _worktree_merge_in_progress,
+)
 from b4.review_tui._modals import (
     ActionItem,
     ActionScreen,
@@ -3923,6 +3928,141 @@ class TestMergeConflictResolution:
         assert handle._keep is True
         assert _worktree_merge_in_progress(wt) is True
 
+    def test_real_worktree_incomplete_auto_aborts(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        """A real checkout left mid-op is aborted, never poisoned.
+
+        Counterpart to test_incomplete_keeps_throwaway: a throwaway worktree is
+        kept for the user, but a real checkout must be restored -- otherwise its
+        unmerged index breaks the user's git work and makes the next take fail
+        cryptically on top of it.
+        """
+        wt, pre = _conflicted_merge_worktree(gitdir, tmp_path)
+        handle = _TakeWorktree(wt, is_temp=False)
+        # User exits the shell WITHOUT finishing or aborting the merge.
+        with patch('b4.review_tui._tracking_app._suspend_to_shell'):
+            ok = _resolve_worktree_take_conflict(
+                handle, 'merge', pre, _worktree_merge_in_progress
+            )
+        assert ok is False
+        assert _worktree_merge_in_progress(wt) is False
+        assert _worktree_has_unmerged(wt) is False
+        ecode, head = b4.git_run_command(wt, ['rev-parse', 'HEAD'])
+        assert ecode == 0 and head.strip() == pre
+
+
+def _strip_merge_pseudo_refs(wt: str) -> None:
+    """Leave *wt*'s index unmerged but remove its in-progress-merge markers.
+
+    Reproduces the resting state that breaks the next take: stage>0 entries with
+    no MERGE_HEAD/etc, which git refuses to start a fresh op on top of yet no
+    ``--abort`` can clear.
+    """
+    ecode, gd = b4.git_run_command(wt, ['rev-parse', '--absolute-git-dir'])
+    assert ecode == 0
+    for fname in ('MERGE_HEAD', 'MERGE_MSG', 'AUTO_MERGE'):
+        try:
+            os.unlink(os.path.join(gd.strip(), fname))
+        except OSError:
+            pass
+
+
+class TestWorktreeOpDetection:
+    """_worktree_inprogress_op / _worktree_has_unmerged / _abort_worktree_op."""
+
+    def test_detects_merge_and_clean(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        wt, _pre = _conflicted_merge_worktree(gitdir, tmp_path)
+        assert _worktree_inprogress_op(wt) == 'merge'
+        assert _worktree_has_unmerged(wt) is True
+        b4.git_run_command(wt, ['merge', '--abort'])
+        assert _worktree_inprogress_op(wt) is None
+        assert _worktree_has_unmerged(wt) is False
+
+    def test_detects_cherry_pick(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        wt, _pre = _conflicted_merge_worktree(gitdir, tmp_path)
+        b4.git_run_command(wt, ['merge', '--abort'])
+        # The same divergent edit to shared.txt now conflicts as a cherry-pick.
+        ecode, _ = b4.git_run_command(wt, ['cherry-pick', 'theirs'], logstderr=True)
+        assert ecode != 0
+        assert _worktree_inprogress_op(wt) == 'cherry-pick'
+        b4.git_run_command(wt, ['cherry-pick', '--abort'])
+        assert _worktree_inprogress_op(wt) is None
+
+    def test_abort_op_aborts_merge(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        wt, pre = _conflicted_merge_worktree(gitdir, tmp_path)
+        assert _abort_worktree_op(wt) == 'merge'
+        ecode, head = b4.git_run_command(wt, ['rev-parse', 'HEAD'])
+        assert ecode == 0 and head.strip() == pre
+        assert _worktree_inprogress_op(wt) is None
+        assert _worktree_has_unmerged(wt) is False
+
+    def test_abort_op_resets_bare_unmerged_index(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        wt, _pre = _conflicted_merge_worktree(gitdir, tmp_path)
+        _strip_merge_pseudo_refs(wt)
+        assert _worktree_inprogress_op(wt) is None
+        assert _worktree_has_unmerged(wt) is True
+        assert _abort_worktree_op(wt) == 'reset'
+        assert _worktree_has_unmerged(wt) is False
+
+    def test_abort_op_noop_on_clean(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        b4.git_run_command(gitdir, ['branch', 'target', 'master'])
+        linked = str(tmp_path / 'clean-wt')
+        ecode, _ = b4.git_run_command(gitdir, ['worktree', 'add', linked, 'target'])
+        assert ecode == 0
+        assert _abort_worktree_op(linked) is None
+
+
+class TestTakeWorktreeRefusesDirtyTarget:
+    """_take_worktree refuses a target worktree that is mid-op or conflicted.
+
+    A take's git-am/git-merge would otherwise fail cryptically on top of the
+    leftover state; refuse up front instead, without discarding state that may
+    be the user's own.
+    """
+
+    def test_refuses_inprogress_merge(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        wt, _pre = _conflicted_merge_worktree(gitdir, tmp_path)  # 'ours' is in wt
+        with patch('b4.review_tui._tracking_app._wait_for_enter'):
+            with _take_worktree(gitdir, 'ours') as handle:
+                assert handle is None
+        # Refusing must leave the conflicted state untouched for the user.
+        assert _worktree_merge_in_progress(wt) is True
+
+    def test_refuses_bare_unmerged_index(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        wt, _pre = _conflicted_merge_worktree(gitdir, tmp_path)
+        _strip_merge_pseudo_refs(wt)
+        assert _worktree_inprogress_op(wt) is None
+        assert _worktree_has_unmerged(wt) is True
+        with patch('b4.review_tui._tracking_app._wait_for_enter'):
+            with _take_worktree(gitdir, 'ours') as handle:
+                assert handle is None
+
+    def test_allows_clean_target(
+        self, gitdir: str, tmp_path: pathlib.Path
+    ) -> None:
+        b4.git_run_command(gitdir, ['branch', 'target', 'master'])
+        linked = str(tmp_path / 'clean-target')
+        ecode, _ = b4.git_run_command(gitdir, ['worktree', 'add', linked, 'target'])
+        assert ecode == 0
+        with _take_worktree(gitdir, 'target') as handle:
+            assert handle is not None
+            assert not handle.is_temp
+
 
 def _series_mbox_add_and_edit(gitdir: str, tmp_path: pathlib.Path, base: str) -> bytes:
     """Two patches at *base*: add newfile.txt, then edit shared.txt line 2."""

-- 
2.53.0