[PATCH b4 2/3] review-tui: run take->merge in the target branch's worktree
Christian Brauner <[email protected]> Mon, 22 Jun 2026 14:20:44 +0200
| Newsgroups | org.kernel.linux.tools |
|---|---|
| Message-ID | <[email protected]> |
The take->merge applied the series by checking out the target branch in the current worktree. That disturbed the current checkout and, worse, failed outright with "'<branch>' is already used by worktree at ..." whenever the target was checked out in another worktree -- a common setup when applying a series from a different worktree than the one the branch lives in. Resolve the worktree that holds the target branch with a new _worktree_for_branch() helper (git for-each-ref %(worktreepath)) and run the whole fetch+merge there instead. If the branch is not checked out anywhere, use a throwaway worktree. The current checkout is never touched, so a series can be taken onto a branch that lives in any worktree. FETCH_HEAD is per-worktree, so the fetch is pointed at the merge worktree too. Signed-off-by: Christian Brauner (Amutable) <[email protected]> --- src/b4/__init__.py | 16 +- src/b4/review_tui/_modals.py | 6 +- src/b4/review_tui/_tracking_app.py | 298 ++++++++++++++++++++++--------------- 3 files changed, 192 insertions(+), 128 deletions(-) diff --git a/src/b4/__init__.py b/src/b4/__init__.py index adb33cb..4cb6e72 100644 --- a/src/b4/__init__.py +++ b/src/b4/__init__.py @@ -5620,8 +5620,16 @@ def git_fetch_am_into_repo( logger.info(out.strip()) logger.info('---') logger.info('Fetching into FETCH_HEAD') + # FETCH_HEAD is per-worktree, and which worktree git writes it into is + # decided by the process cwd for a primary worktree (git_run_command + # uses --git-dir there, not -C, so the cwd leaks in). The caller may be + # driving this from a different worktree than `gitdir` -- e.g. the + # review TUI merging into a branch checked out elsewhere -- so anchor + # the fetch to `gitdir` via rundir. Otherwise the commits land in the + # caller's FETCH_HEAD and its later `git -C gitdir merge FETCH_HEAD` + # reads a stale or missing one, silently merging the wrong commits. gitargs = ['fetch', gwt] - ecode, out = git_run_command(topdir, gitargs, logstderr=True) + ecode, out = git_run_command(gitdir, gitargs, logstderr=True, rundir=gitdir) if ecode > 0: logger.critical('Unable to fetch from the worktree') logger.critical(out.strip()) @@ -5630,8 +5638,10 @@ def git_fetch_am_into_repo( if cleanup: git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) - if origin and topdir: - _rewrite_fetch_head_origin(topdir, gwt, origin) + if origin and gitdir: + # Rewrite the same FETCH_HEAD the fetch above wrote (gitdir's), not the + # cwd worktree's. + _rewrite_fetch_head_origin(gitdir, gwt, origin) def edit_in_editor(bdata: bytes, filehint: str = 'COMMIT_EDITMSG') -> bytes: diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py index a708dbc..d383fdc 100644 --- a/src/b4/review_tui/_modals.py +++ b/src/b4/review_tui/_modals.py @@ -726,6 +726,7 @@ class TakeScreen(ModalScreen[bool]): default_method: Optional[str] = None, recent_branches: Optional[List[str]] = None, subject: str = '', + default_signoff: bool = True, ) -> None: """Initialize take screen. @@ -736,6 +737,7 @@ class TakeScreen(ModalScreen[bool]): default_method: Override the default take method selection recent_branches: Recently used branch names for auto-suggest subject: Series subject to display for context + default_signoff: Initial state of the "add Signed-off-by" checkbox """ super().__init__() self._target_branch = target_branch @@ -749,7 +751,7 @@ class TakeScreen(ModalScreen[bool]): self.target_result: str = '' self.method_result: str = self._default_method self.add_link: bool = True - self.add_signoff: bool = True + self.add_signoff: bool = default_signoff self.accept_series: bool = True def compose(self) -> ComposeResult: @@ -784,7 +786,7 @@ class TakeScreen(ModalScreen[bool]): ) yield Checkbox( 'add Signed-off-by:', - value=True, + value=self.add_signoff, id='take-add-signoff', classes='take-checkbox', ) diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py index 959670a..3293786 100644 --- a/src/b4/review_tui/_tracking_app.py +++ b/src/b4/review_tui/_tracking_app.py @@ -16,6 +16,7 @@ import os import pathlib import re import shlex +import shutil import sqlite3 import subprocess import sys @@ -137,25 +138,44 @@ _ACTIONABLE_STATUSES: frozenset[str] = frozenset( ) -def _shazam_merge_flags(config: Dict[str, Any]) -> List[str]: - """Extra ``git merge`` flags for the take->merge path from config. +def _shazam_merge_flags(config: Dict[str, Any], add_signoff: bool) -> List[str]: + """``git merge`` flags for the take->merge path. - Mirrors the ``b4 shazam`` CLI's handling of ``b4.shazam-merge-flags`` - (e.g. ``--log``, ``--stat``, ``--gpg-sign``) so a merge taken from the - review TUI carries the same options. + ``b4.shazam-merge-flags`` is passed through verbatim, exactly like the + ``b4 shazam`` CLI, so ``--log``/``--stat``/``--gpg-sign`` and any strategy + (e.g. ``-s ours``) take effect unchanged -- the config is authoritative. - Signed-off-by is deliberately dropped here: in the review TUI the merge - commit's SoB is controlled by the take dialog's "add Signed-off-by" - checkbox, which edits the merge message body directly. ``git merge - --signoff`` does not dedup against an existing trailer, so leaving it in - would append a second Signed-off-by line. + Signed-off-by is then reconciled with the take dialog's checkbox, which is + the per-take override: any ``--signoff``/``--no-signoff`` already in the + config is dropped and a single, deduped flag is appended from + *add_signoff*. The checkbox itself defaults from the config's signoff + intent (see ``_show_take_screen``), so an unset config (whose default is + ``--signoff``) still signs off, while ``--no-signoff`` in the config + defaults the box off. Signoff rides on the git flag rather than being baked + into the merge message body, so it can never double up (``git merge + --signoff`` does not dedup against a trailer already in the message). """ raw = str(config.get('shazam-merge-flags', '--signoff')) - if not raw: - return [] sp = shlex.shlex(raw, posix=True) sp.whitespace_split = True - return [f for f in sp if f not in ('-s', '--signoff', '--no-signoff')] + flags = [f for f in sp if f not in ('--signoff', '--no-signoff')] + flags.append('--signoff' if add_signoff else '--no-signoff') + return flags + + +def _worktree_for_branch(topdir: str, branch: str) -> Optional[str]: + """Return the path of the worktree that has *branch* checked out, if any. + + The review TUI may be driven from a different worktree than the one the + series is being applied to, so the merge has to run wherever the target + branch lives rather than in the current checkout. + """ + ecode, out = b4.git_run_command( + topdir, ['for-each-ref', '--format=%(worktreepath)', f'refs/heads/{branch}'] + ) + if ecode != 0: + return None + return out.strip() or None def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> bool: @@ -2311,6 +2331,12 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): if target_branch and target_branch not in all_suggestions: all_suggestions.append(target_branch) recent_branches = all_suggestions or None + # Default the Signed-off-by checkbox from the configured signoff intent + # (shazam-merge-flags defaults to --signoff), so config drives the + # default while the user can still override it per-take. + default_signoff = '--signoff' in shlex.split( + str(b4cfg.get('shazam-merge-flags', '--signoff')) + ) take_screen = TakeScreen( target_branch, review_branch, @@ -2318,6 +2344,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): default_method=default_method, recent_branches=recent_branches, subject=series.get('subject', ''), + default_signoff=default_signoff, ) self.push_screen( take_screen, @@ -2516,9 +2543,11 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): series['taken'] = take_info series.setdefault('takes', []).append(take_info) - # Record the branch tip commit for CI lookups (e.g. KernelCI). - # HEAD is still on target_branch at this point. - ecode, tip_out = b4.git_run_command(topdir, ['rev-parse', 'HEAD']) + # Record the resulting target-branch tip for CI lookups (e.g. KernelCI). + # Resolve the branch ref, not HEAD: the merge-take path advances + # target_branch in a separate worktree and never moves the current + # checkout, so HEAD here is the launch branch, not the merge commit. + ecode, tip_out = b4.git_run_command(topdir, ['rev-parse', target_branch]) if ecode == 0 and tip_out.strip(): tip_entry = { 'date': take_info['date'], @@ -2641,21 +2670,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): if not take_screen.add_link: body = re.sub(r'^Link:.*\n?', '', body, flags=re.MULTILINE) - # Append Signed-off-by if requested - if take_screen.add_signoff: - usercfg = b4.get_config_from_git('user\\..*') - uname = usercfg.get('name', '') - uemail = usercfg.get('email', '') - if uname and uemail: - sob = f'Signed-off-by: {uname} <{uemail}>' - stripped = body.rstrip('\n') - # If the body already ends with a trailer (e.g. Link:), - # keep them in the same block without a blank line. - last_line = stripped.rsplit('\n', 1)[-1] - if re.match(r'^[A-Za-z-]+:\s', last_line): - body = stripped + '\n' + sob + '\n' - else: - body = stripped + '\n\n' + sob + '\n' + # Signed-off-by is not baked into the body: it rides on git-merge's + # --signoff flag (see _shazam_merge_flags), reconciled with the take + # dialog's checkbox, so it can never double up with shazam-merge-flags. # Apply trailer-amended patches in a sparse worktree and fetch # into FETCH_HEAD, so individual commits carry their trailers. @@ -2672,110 +2689,145 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): return base_commit = out.strip() - try: - b4.git_fetch_am_into_repo( - topdir, - ambytes, - at_base=base_commit, - origin=t_series.get('link', ''), - am_flags=['-3'], + # A throwaway worktree from a previously interrupted take may still be + # registered -- possibly even holding target_branch, in which case the + # resolution below would latch onto it and then never clean it up + # (temp_wt stays None, so the finally skips removal). Remove any such + # leftover up front. This also clears a bare leftover directory that a + # plain `worktree remove` would choke on. + common_dir = b4.git_get_common_dir(topdir) + temp_wt: Optional[str] = None + if common_dir: + leftover = os.path.join(common_dir, 'b4-take-worktree') + if os.path.exists(leftover): + b4.git_run_command(topdir, ['worktree', 'remove', '--force', leftover]) + b4.git_run_command(topdir, ['worktree', 'prune']) + if os.path.isdir(leftover): + shutil.rmtree(leftover, ignore_errors=True) + + # Run the merge in whichever worktree holds the target branch -- the + # review TUI may be driven from a different worktree than the one the + # series is being applied to, and the target may even be checked out + # elsewhere. If it is not checked out anywhere, use a throwaway + # worktree, so the current checkout is never disturbed. + merge_dir = _worktree_for_branch(topdir, target_branch) + if merge_dir is None: + if not common_dir: + logger.critical('Unable to determine git common dir') + _wait_for_enter() + return + temp_wt = os.path.join(common_dir, 'b4-take-worktree') + ecode, out = b4.git_run_command( + topdir, ['worktree', 'add', temp_wt, target_branch], logstderr=True ) - except b4.AmConflictError as cex: - if not _resolve_worktree_am_conflict(topdir, cex): + if ecode != 0: + logger.critical( + 'Could not create a worktree for %s: %s', + target_branch, + out.strip(), + ) _wait_for_enter() return - except RuntimeError: - _wait_for_enter() - return - - # Save current branch so we can restore on failure - prev_branch = b4.git_get_current_branch(topdir) - if prev_branch is None: - prev_branch = b4.git_revparse_obj('HEAD', gitdir=topdir) + merge_dir = temp_wt - # Checkout target branch - ecode, out = b4.git_run_command( - topdir, ['checkout', target_branch], logstderr=True - ) - if ecode != 0: - logger.critical('Could not checkout %s: %s', target_branch, out.strip()) - _wait_for_enter() - return + try: + # Apply trailer-amended patches in a sparse worktree and fetch into + # the target worktree's FETCH_HEAD (which is per-worktree), so the + # merge below sees them and each commit carries its trailers. + try: + b4.git_fetch_am_into_repo( + merge_dir, + ambytes, + at_base=base_commit, + origin=t_series.get('link', ''), + am_flags=['-3'], + ) + except b4.AmConflictError as cex: + if not _resolve_worktree_am_conflict(merge_dir, cex): + _wait_for_enter() + return + except RuntimeError: + _wait_for_enter() + return - # Write merge message to git dir - ecode, gitdir = b4.git_run_command(topdir, ['rev-parse', '--git-dir']) - if ecode != 0: - logger.critical('Unable to find git directory') - b4.git_run_command(topdir, ['checkout', prev_branch], logstderr=True) - _wait_for_enter() - return - mmf = os.path.join(gitdir.strip(), 'b4-merge-msg') - with open(mmf, 'w') as fh: - fh.write(body) + # Write merge message to the target worktree's git dir + ecode, gitdir = b4.git_run_command(merge_dir, ['rev-parse', '--git-dir']) + if ecode != 0: + logger.critical('Unable to find git directory') + _wait_for_enter() + return + mmf = os.path.join(gitdir.strip(), 'b4-merge-msg') + with open(mmf, 'w') as fh: + fh.write(body) - # Merge FETCH_HEAD (trailer-amended patches) instead of the review - # branch directly, so each commit carries its trailers. Mirror - # "b4 shazam": git-merge builds the final message from -F + the flags - # and opens the editor (--edit), so b4.shazam-merge-flags such as - # --log/--stat/--gpg-sign take effect and the appended shortlog is - # visible. Signed-off-by is left out of the flags -- it is already in - # the body via the take dialog's checkbox (see _shazam_merge_flags). - mergeflags = _shazam_merge_flags(config) - out = '' - if hasattr(sys, '_running_in_pytest'): - # Tests have no tty for an interactive editor; run the merge - # non-interactively through the captured runner, like the CLI does. - mergeargs = ( - ['merge', '--no-ff', '-F', mmf, '--no-edit', 'FETCH_HEAD'] - + mergeflags - ) - ecode, out = b4.git_run_command(topdir, mergeargs, logstderr=True) - else: - # Run git directly with an inherited tty (under the caller's - # suspend()) so git can open the editor, like _suspend_to_shell. - mergeargs = ( - ['git', '-C', topdir, 'merge', '--no-ff', '-F', mmf, '--edit', - 'FETCH_HEAD'] - + mergeflags - ) - ecode = subprocess.run(mergeargs).returncode + # Merge FETCH_HEAD (trailer-amended patches) instead of the review + # branch directly, so each commit carries its trailers. Mirror + # "b4 shazam": git-merge builds the final message from -F + the + # flags and opens the editor (--edit), so b4.shazam-merge-flags + # such as --log/--stat/--gpg-sign take effect and the appended + # shortlog is visible. Signed-off-by rides on --signoff in the + # flags, reconciled with the take dialog's checkbox (see + # _shazam_merge_flags). + mergeflags = _shazam_merge_flags(config, take_screen.add_signoff) + out = '' + if hasattr(sys, '_running_in_pytest'): + # Tests have no tty for an interactive editor; run the merge + # non-interactively through the captured runner, like the CLI. + mergeargs = ( + ['merge', '--no-ff', '-F', mmf, '--no-edit', 'FETCH_HEAD'] + + mergeflags + ) + ecode, out = b4.git_run_command(merge_dir, mergeargs, logstderr=True) + else: + # Run git directly with an inherited tty (under the caller's + # suspend()) so git can open the editor, like _suspend_to_shell. + mergeargs = ( + ['git', '-C', merge_dir, 'merge', '--no-ff', '-F', mmf, + '--edit', 'FETCH_HEAD'] + + mergeflags + ) + ecode = subprocess.run(mergeargs).returncode - # Clean up message file - try: - os.unlink(mmf) - except OSError: - pass + # Clean up message file + try: + os.unlink(mmf) + except OSError: + pass - if ecode != 0: - logger.critical('Merge failed%s', f': {out.strip()}' if out.strip() else '') - logger.critical('Aborting merge...') - b4.git_run_command(topdir, ['merge', '--abort'], logstderr=True) - b4.git_run_command(topdir, ['checkout', prev_branch], logstderr=True) - _wait_for_enter() - return + if ecode != 0: + logger.critical( + 'Merge failed%s', f': {out.strip()}' if out.strip() else '' + ) + logger.critical('Aborting merge...') + b4.git_run_command(merge_dir, ['merge', '--abort'], logstderr=True) + _wait_for_enter() + return - logger.info('Merged %s into %s', review_branch, target_branch) + logger.info('Merged %s into %s', review_branch, target_branch) - # Record per-patch commit IDs from the merged branch. - # After --no-ff merge, HEAD^2 is the tip of the merged side; - # the individual patch commits are base_commit..HEAD^2. - ecode, out = b4.git_run_command( - topdir, ['rev-list', '--reverse', f'{base_commit}..HEAD^2'] - ) - new_status: Optional[str] = None - if ecode == 0 and out.strip(): - commit_ids = out.strip().splitlines() - new_status = self._record_take_metadata( - topdir, - review_branch, - target_branch, - commit_ids, - cherrypick=cherrypick, - accepted=take_screen.accept_series, + # Record per-patch commit IDs from the merged branch. + # After --no-ff merge, HEAD^2 is the tip of the merged side; + # the individual patch commits are base_commit..HEAD^2. + ecode, out = b4.git_run_command( + merge_dir, ['rev-list', '--reverse', f'{base_commit}..HEAD^2'] ) + new_status: Optional[str] = None + if ecode == 0 and out.strip(): + commit_ids = out.strip().splitlines() + new_status = self._record_take_metadata( + topdir, + review_branch, + target_branch, + commit_ids, + cherrypick=cherrypick, + accepted=take_screen.accept_series, + ) - self._finalize_take(topdir, target_branch, change_id, t_series, new_status) - _wait_for_enter() + self._finalize_take(topdir, target_branch, change_id, t_series, new_status) + _wait_for_enter() + finally: + if temp_wt: + b4.git_run_command(topdir, ['worktree', 'remove', '--force', temp_wt]) def _finalize_take( self, -- 2.53.0