[PATCH b4 v3 1/9] review-tui: lift worktree am-conflict helpers into b4 core

Christian Brauner <[email protected]> Thu, 25 Jun 2026 14:09:21 +0200
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
The review TUI's take->am/->merge conflict resolution carries a few small
pieces of git-worktree plumbing: detecting an in-progress git am
(_worktree_rebase_apply_dir) or merge (_worktree_merge_in_progress), and
fetching a finished git am out of a throwaway worktree before dropping it.

Move the two detection helpers into b4 core (next to git_fetch_am_into_repo
and _rewrite_fetch_head_origin) and add _fetch_and_drop_am_worktree(),
which fetches the worktree HEAD into the destination's FETCH_HEAD --
anchored via rundir, optionally rewriting the origin -- and removes the
worktree. _resolve_worktree_am_conflict() now uses it instead of an inline
fetch/remove; that also anchors the fetch's FETCH_HEAD via rundir (matching
git_fetch_am_into_repo and the clean path) rather than relying on the
process cwd.

No behavioral change intended. This is groundwork: the next commit teaches
b4 shazam --resolve to resolve conflicts the same way, reusing these
helpers instead of growing its own copies.

Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 src/b4/__init__.py                 | 52 ++++++++++++++++++++++++++++++++++++++
 src/b4/review_tui/_tracking_app.py | 46 +++++----------------------------
 src/tests/test_tui_tracking.py     |  2 +-
 3 files changed, 59 insertions(+), 41 deletions(-)

diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index 2806656..be41da1 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -5699,6 +5699,58 @@ def _rewrite_fetch_head_origin(topdir: str, old_origin: str, new_origin: str) ->
             fhh.write(new_contents)
 
 
+def _worktree_rebase_apply_dir(worktree: str) -> Optional[str]:
+    """Return *worktree*'s in-progress ``git am`` state dir, or ``None``.
+
+    ``rebase-apply`` lives under the per-worktree git dir, not the shared
+    ``.git``, so resolve it via ``--absolute-git-dir`` to handle linked and
+    throwaway worktrees too.
+    """
+    ecode, gitdir = git_run_command(worktree, ['rev-parse', '--absolute-git-dir'])
+    if ecode != 0:
+        return None
+    rebase_apply = os.path.join(gitdir.strip(), 'rebase-apply')
+    return rebase_apply if os.path.isdir(rebase_apply) else None
+
+
+def _worktree_merge_in_progress(worktree: str) -> bool:
+    """Return whether *worktree* has a conflicted ``git merge`` in progress.
+
+    ``MERGE_HEAD`` lives under the per-worktree git dir, not the shared
+    ``.git``, so resolve it via ``--absolute-git-dir`` to handle linked and
+    throwaway worktrees too.
+    """
+    ecode, gitdir = git_run_command(worktree, ['rev-parse', '--absolute-git-dir'])
+    if ecode != 0:
+        return False
+    return os.path.exists(os.path.join(gitdir.strip(), 'MERGE_HEAD'))
+
+
+def _fetch_and_drop_am_worktree(
+    dest: str, gwt: str, origin: Optional[str] = None
+) -> bool:
+    """Fetch *gwt*'s HEAD into *dest*'s FETCH_HEAD, then remove the worktree.
+
+    Shared by the conflict-resolution paths in ``b4 shazam`` and the review
+    TUI: once the user has finished the ``git am`` in the throwaway worktree
+    *gwt*, pull the result into *dest*'s FETCH_HEAD so it can be merged, then
+    tear the worktree down. The fetch is anchored to *dest* via ``rundir`` so
+    FETCH_HEAD lands in the worktree the caller merges in (see
+    git_fetch_am_into_repo). When *origin* is given, rewrite FETCH_HEAD so the
+    merge message names the series origin instead of the worktree path. Returns
+    False (after still removing the worktree) if the fetch failed.
+    """
+    ecode, out = git_run_command(dest, ['fetch', gwt], logstderr=True, rundir=dest)
+    if ecode == 0 and origin:
+        _rewrite_fetch_head_origin(dest, gwt, origin)
+    git_run_command(dest, ['worktree', 'remove', '--force', gwt])
+    if ecode > 0:
+        logger.critical('Unable to fetch from the worktree')
+        logger.critical(out.strip())
+        return False
+    return True
+
+
 def git_fetch_am_into_repo(
     gitdir: Optional[str],
     ambytes: bytes,
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index d184680..9b3ad65 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -253,33 +253,6 @@ def _take_worktree(
             b4.git_run_command(topdir, ['worktree', 'remove', '--force', temp_wt])
 
 
-def _worktree_rebase_apply_dir(worktree: str) -> Optional[str]:
-    """Return *worktree*'s in-progress ``git am`` state dir, or ``None``.
-
-    ``rebase-apply`` lives under the per-worktree git dir, not the shared
-    ``.git``, so resolve it via ``--absolute-git-dir`` to handle linked and
-    throwaway worktrees too.
-    """
-    ecode, gitdir = b4.git_run_command(worktree, ['rev-parse', '--absolute-git-dir'])
-    if ecode != 0:
-        return None
-    rebase_apply = os.path.join(gitdir.strip(), 'rebase-apply')
-    return rebase_apply if os.path.isdir(rebase_apply) else None
-
-
-def _worktree_merge_in_progress(worktree: str) -> bool:
-    """Return whether *worktree* has a conflicted ``git merge`` in progress.
-
-    ``MERGE_HEAD`` lives under the per-worktree git dir, not the shared
-    ``.git``, so resolve it via ``--absolute-git-dir`` to handle linked and
-    throwaway worktrees too.
-    """
-    ecode, gitdir = b4.git_run_command(worktree, ['rev-parse', '--absolute-git-dir'])
-    if ecode != 0:
-        return False
-    return os.path.exists(os.path.join(gitdir.strip(), 'MERGE_HEAD'))
-
-
 def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> bool:
     """Handle an AmConflictError by dropping the user into a shell.
 
@@ -316,7 +289,7 @@ def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> boo
     )
     _suspend_to_shell(hint='b4 conflict', cwd=cex.worktree_path)
     # Check if am is still in progress (user exited without finishing)
-    if _worktree_rebase_apply_dir(cex.worktree_path):
+    if b4._worktree_rebase_apply_dir(cex.worktree_path):
         logger.warning('Conflict resolution incomplete, aborting')
         b4.git_run_command(topdir, ['worktree', 'remove', '--force', cex.worktree_path])
         return False
@@ -331,16 +304,9 @@ def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> boo
         logger.warning('Conflict resolution aborted')
         b4.git_run_command(topdir, ['worktree', 'remove', '--force', cex.worktree_path])
         return False
-    # am completed -- fetch result into FETCH_HEAD
+    # am completed -- fetch result into FETCH_HEAD and drop the worktree
     logger.info('Conflict resolved, fetching result...')
-    ecode, _out = b4.git_run_command(
-        topdir, ['fetch', cex.worktree_path], logstderr=True
-    )
-    b4.git_run_command(topdir, ['worktree', 'remove', '--force', cex.worktree_path])
-    if ecode > 0:
-        logger.critical('Unable to fetch from resolved worktree')
-        return False
-    return True
+    return b4._fetch_and_drop_am_worktree(topdir, cex.worktree_path)
 
 
 def _resolve_worktree_take_conflict(
@@ -2909,7 +2875,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 # (mirrors the git-am path above and "b4 shazam"). Anything
                 # else (e.g. a dirty worktree) leaves no merge to resolve, so
                 # abort and bail as before.
-                if not _worktree_merge_in_progress(merge_dir):
+                if not b4._worktree_merge_in_progress(merge_dir):
                     logger.critical(
                         'Merge failed%s', f': {out.strip()}' if out.strip() else ''
                     )
@@ -2933,7 +2899,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 )
                 pre_merge_head = pre_merge_head.strip() if ecode == 0 else ''
                 if not _resolve_worktree_take_conflict(
-                    wt, 'merge', pre_merge_head, _worktree_merge_in_progress
+                    wt, 'merge', pre_merge_head, b4._worktree_merge_in_progress
                 ):
                     _wait_for_enter()
                     return
@@ -3202,7 +3168,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                 logger.critical('git-am failed:')
                 logger.critical(out.strip())
                 if not _resolve_worktree_take_conflict(
-                    wt, 'am', pre_am_head, _worktree_rebase_apply_dir
+                    wt, 'am', pre_am_head, b4._worktree_rebase_apply_dir
                 ):
                     _wait_for_enter()
                     return
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index b7d543b..0c716b7 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -24,6 +24,7 @@ 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.review_tui._modals import (
     ActionItem,
     ActionScreen,
@@ -43,7 +44,6 @@ from b4.review_tui._tracking_app import (
     _take_worktree,
     _TakeWorktree,
     _worktree_for_branch,
-    _worktree_merge_in_progress,
 )
 
 # ---------------------------------------------------------------------------

-- 
2.53.0