[PATCH b4 v2 6/7] shazam: resolve --resolve conflicts inline via a subshell
Christian Brauner <[email protected]> Wed, 24 Jun 2026 10:51:58 +0200
| Newsgroups | org.kernel.linux.tools |
|---|---|
| Message-ID | <[email protected]> |
b4 shazam --resolve used a two-phase, git-rebase-style flow: on a git-am conflict it parked the in-progress am in a worktree, persisted b4-shazam-state.json, and exited, leaving the user to finish the am by hand and then run "b4 shazam --continue" (or "--abort"). Replace that with the subshell model the review TUI already uses: suspend into a shell inside the conflict worktree (a blocking subprocess.run, so b4 stays the parent) and finish inline when the shell exits -- fetch the fully-applied series into FETCH_HEAD and merge on success, tear the worktree down on "git am --abort" or an unfinished am. b4 only execvp's git-merge at the very end, once the worktree has already been dropped. This drops the persisted state file and the separate --continue/--abort subcommands. Factor the shell-and-finish logic into b4.resolve_am_conflict_in_shell() and have both shazam and the review TUI's three am-conflict call sites use it. Move _suspend_to_shell() out of b4.tui._common (which imports textual, an optional dependency) into b4 itself so the non-TUI shazam path can call it without pulling in textual; b4.tui._common re-exports it for the TUI callers. The shared helper detects "nothing applied" by comparing the worktree HEAD to the am's base commit, not to the pre-shell HEAD: after a partial multi-patch apply, "git am --abort" resets HEAD all the way back to base, which a before/after-HEAD compare (as the TUI helper previously used) would misread as success and silently merge a no-op. Passing the base from every call site hardens the TUI's abort detection too. Signed-off-by: Christian Brauner (Amutable) <[email protected]> --- docs/maintainer/am-shazam.rst | 39 ++----- src/b4/__init__.py | 145 +++++++++++++++++++++++- src/b4/command.py | 14 --- src/b4/mbox.py | 221 +++---------------------------------- src/b4/review_tui/_tracking_app.py | 60 +--------- src/b4/tui/_common.py | 58 +--------- 6 files changed, 182 insertions(+), 355 deletions(-) diff --git a/docs/maintainer/am-shazam.rst b/docs/maintainer/am-shazam.rst index 9671c97..3205f77 100644 --- a/docs/maintainer/am-shazam.rst +++ b/docs/maintainer/am-shazam.rst @@ -314,16 +314,6 @@ the merge commit. enables interactive conflict resolution instead of simply reporting the failure. See :ref:`shazam_conflict_resolution` below. -``--continue`` - Continue after a conflicted ``--resolve``. Run this once you have - finished the ``git am`` in the resolution worktree; b4 then fetches the - applied series and finishes exactly as a clean run would — with ``-M`` it - merges the series, with ``-H`` it leaves it in ``FETCH_HEAD`` for you. - -``--abort`` - Abort a conflicted shazam: remove the resolution worktree and clean up - any saved state. - Please also see the :ref:`shazam_settings` section for some configuration file options that affect some of ``b4 shazam`` behaviour. @@ -360,31 +350,26 @@ add the ``--resolve`` flag:: b4 shazam -H --resolve <msgid> With ``--resolve``, instead of giving up, b4 leaves the in-progress -``git am`` parked in a throwaway worktree and prints its path. The whole -series is applied there by git itself, so no patch is ever silently -dropped. Resolve the conflict the usual way and let ``git am`` work -through the rest of the series:: +``git am`` parked in a throwaway worktree and drops you into a sub-shell +whose working directory is that worktree. The whole series is applied +there by git itself, so no patch is ever silently dropped. Resolve the +conflict the usual way and let ``git am`` work through the rest of the +series:: - cd <worktree path printed by b4> # edit the conflicted files git am --continue # or: git am --skip -Repeat until ``git am`` reports that it is done, then come back to your -branch and run:: - - b4 shazam --continue +Repeat until ``git am`` reports that it is done, then leave the sub-shell +with ``Ctrl-d``. -B4 fetches the fully-applied series out of the worktree, removes the +B4 then fetches the fully-applied series out of the worktree, removes the worktree, and finishes exactly as a clean ``b4 shazam`` would: with ``-M`` it merges the series into your branch, while with ``-H`` (as in the example above) it leaves the series in ``FETCH_HEAD`` for you to merge or check out. If a ``-M`` merge itself conflicts, resolve it the normal way (git leaves the conflicted merge in your tree) and commit. -If you decide you don't want to proceed, run:: - - b4 shazam --abort - -This removes the resolution worktree (discarding the in-progress ``git -am``), backs out a half-finished merge, and clears the saved state, -leaving your branch as it was before. +If you decide you don't want to proceed, run ``git am --abort`` in the +sub-shell (or simply leave without finishing the ``git am``) and press +``Ctrl-d``. B4 sees that nothing was applied, removes the worktree, and +leaves your branch exactly as it was before. diff --git a/src/b4/__init__.py b/src/b4/__init__.py index 7a695ea..d9e3bff 100644 --- a/src/b4/__init__.py +++ b/src/b4/__init__.py @@ -80,9 +80,10 @@ PW_REST_API_VERSION = '1.2' class AmConflictError(RuntimeError): - def __init__(self, worktree_path: str, output: str): + def __init__(self, worktree_path: str, output: str, base_sha: str = ''): self.worktree_path = worktree_path self.output = output + self.base_sha = base_sha super().__init__(output) @@ -5753,6 +5754,75 @@ def _fetch_and_drop_am_worktree( return True +def resolve_am_conflict_in_shell( + topdir: str, + cex: 'AmConflictError', + *, + origin: Optional[str] = None, +) -> bool: + """Drop the user into a subshell to finish a conflicted ``git am`` inline. + + Shared by ``b4 shazam --resolve`` and the review TUI: the throwaway worktree + *cex.worktree_path* already holds the parked, full-checkout ``git am`` (rebuilt + by git_fetch_am_into_repo on conflict). Suspend into a shell there so the user + drives the am to completion natively, then act on the outcome: + + - finished (``git am --continue``): fetch the result into *topdir*'s + FETCH_HEAD, drop the worktree, return True. + - aborted (``git am --abort``) or left unfinished: drop the worktree, return + False. + + b4 stays the parent for the whole call (blocking subshell, no exec); the + caller must not exit or execvp until this returns -- and only then, once the + worktree is gone, hand off to git-merge. + + The am's starting commit is pinned on *cex* (``cex.base_sha``) when the + conflict is raised; an am that ends back there (everything aborted or skipped) + counts as "nothing applied" and returns False rather than merging a no-op that + would silently drop the series. *origin* annotates FETCH_HEAD with the series + origin instead of the worktree path (see _fetch_and_drop_am_worktree). + """ + gwt = cex.worktree_path + logger.critical('---') + logger.critical(cex.output) + logger.critical('---') + logger.critical('Patch series did not apply cleanly.') + + _suspend_to_shell( + hint='b4 conflict', + cwd=gwt, + guidance=[ + 'You are now in a shell in the conflict worktree.', + 'Resolve the conflict, then run "git am --continue"' + ' (or "git am --skip" to drop a patch).', + 'Run "git am --abort" to give up on the whole series.', + 'When done, Ctrl-d returns to b4.', + ], + ) + + # The am must be finished before we can fetch and merge the series. + if _worktree_rebase_apply_dir(gwt): + logger.warning('git-am is still in progress; conflict resolution incomplete.') + git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) + return False + + # The am is finished -- but did it apply anything? If the user ran + # "git am --abort" (or "--skip"ped every patch) the worktree is back at the + # am's starting point (cex.base_sha, pinned when the conflict was raised); + # fetching+merging that is a no-op that silently drops the whole series. + _e1, wt_head_after = git_run_command(gwt, ['rev-parse', 'HEAD'], rundir=gwt) + wt_head_after = wt_head_after.strip() + if not wt_head_after or wt_head_after == cex.base_sha: + logger.warning('No patches are applied; conflict resolution aborted.') + git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) + return False + + # am completed: fetch the fully-applied series into FETCH_HEAD and drop the + # worktree so the caller can merge it exactly like a clean apply would. + logger.info('Conflict resolved, fetching result...') + return _fetch_and_drop_am_worktree(topdir, gwt, origin=origin) + + def _replay_am_on_full_worktree( gwt: str, ambytes: bytes, amargs: List[str] ) -> Tuple[int, str]: @@ -5801,6 +5871,15 @@ def git_fetch_am_into_repo( if ecode > 0: raise RuntimeError('Failed to create worktree: %s' % out.strip()) + # Pin the commit the worktree (hence the am) starts on, while HEAD still + # points at it. A symbolic base like 'HEAD' re-resolved later would follow + # the worktree's own HEAD as git-am advances it past base. + ecode, base_sha = git_run_command(gwt, ['rev-parse', 'HEAD'], rundir=gwt) + base_sha = base_sha.strip() + if ecode > 0 or not base_sha: + git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) + raise RuntimeError('Unable to determine worktree base commit') + cleanup = True try: logger.info('Magic: Preparing a sparse worktree') @@ -5834,7 +5913,7 @@ def git_fetch_am_into_repo( if ecode > 0: # Genuine conflict: park the am for the user to resolve. cleanup = False - raise AmConflictError(gwt, out.strip()) + raise AmConflictError(gwt, out.strip(), base_sha) # else: only sparseness blocked it; the series applied cleanly -- # fall through to the normal fetch-into-FETCH_HEAD path. if check_only: @@ -5867,6 +5946,68 @@ def git_fetch_am_into_repo( _rewrite_fetch_head_origin(gitdir, gwt, origin) +def _suspend_to_shell( + hint: str = 'b4', + cwd: Optional[str] = None, + guidance: Optional[List[str]] = None, +) -> None: + """Spawn an interactive sub-shell with a PS1 hint. + + For bash and zsh, a temporary rc file is used so the user's normal + configuration is loaded first and then the prompt is prefixed with + a short marker. For other shells the B4_REVIEW environment variable + is set so the user can incorporate it into their own prompt. + + *guidance* overrides the default banner lines (the review-oriented "do not + rewrite commits" advice) with caller-specific instructions -- e.g. the + git-am conflict flow, where finishing the am does add a commit. + """ + logger.info('---') + if guidance is None: + logger.info( + 'You are now in shell mode. You can execute git commands or run checks.' + ) + logger.info('Cosmetic commit edits (reword subjects, fix trailers) are fine;') + logger.info('b4 will reconcile tracking data when you return.') + logger.info('Do NOT add, remove, squash, or reorder commits.') + logger.info('When done, Ctrl-d to return to review UI.') + else: + for line in guidance: + logger.info(line) + logger.info('---') + + shell = os.environ.get('SHELL', '/bin/sh') + shellname = os.path.basename(shell) + env = os.environ.copy() + env['B4_REVIEW'] = hint + + if shellname == 'bash': + bashrc = os.path.expanduser('~/.bashrc') + source = f'[ -f "{bashrc}" ] && . "{bashrc}"\n' + source += f'PS1="({hint}) $PS1"\n' + with tempfile.NamedTemporaryFile( + mode='w', prefix='b4-shell-', suffix='.sh', delete=False + ) as rcf: + rcf.write(source) + rcfile = rcf.name + try: + subprocess.run([shell, '--rcfile', rcfile], env=env, cwd=cwd) + finally: + os.unlink(rcfile) + elif shellname == 'zsh': + real_zdotdir = os.environ.get('ZDOTDIR', os.path.expanduser('~')) + with tempfile.TemporaryDirectory(prefix='b4-shell-') as tmpdir: + zshrc = os.path.join(tmpdir, '.zshrc') + with open(zshrc, 'w') as f: + f.write(f'ZDOTDIR="{real_zdotdir}"\n') + f.write('[ -f "$ZDOTDIR/.zshrc" ] && . "$ZDOTDIR/.zshrc"\n') + f.write(f'PS1="({hint}) $PS1"\n') + env['ZDOTDIR'] = tmpdir + subprocess.run([shell], env=env, cwd=cwd) + else: + subprocess.run([shell], env=env, cwd=cwd) + + def edit_in_editor(bdata: bytes, filehint: str = 'COMMIT_EDITMSG') -> bytes: # To avoid losing the cover letter, ensure that we are still on the same # branch as when the cover-letter was originally opened. diff --git a/src/b4/command.py b/src/b4/command.py index 19a34d7..3c3dd73 100644 --- a/src/b4/command.py +++ b/src/b4/command.py @@ -491,20 +491,6 @@ def setup_parser() -> argparse.ArgumentParser: default=False, help='(use with -H or -M) Enable conflict resolution if patches fail to apply', ) - sp_sh.add_argument( - '--continue', - dest='shazam_continue', - action='store_true', - default=False, - help='Continue after resolving merge conflicts from --resolve', - ) - sp_sh.add_argument( - '--abort', - dest='shazam_abort', - action='store_true', - default=False, - help='Abort a conflicted shazam and clean up', - ) sp_sh.set_defaults(func=cmd_shazam) # b4 review diff --git a/src/b4/mbox.py b/src/b4/mbox.py index 7852c1a..a2646ee 100644 --- a/src/b4/mbox.py +++ b/src/b4/mbox.py @@ -488,18 +488,6 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - sp.whitespace_split = True am_flags.extend(list(sp)) - # A parked --resolve owns the shared resolution worktree; a fresh shazam - # would force-remove it (git_fetch_am_into_repo) and discard the user's - # in-progress conflict edits. Refuse until they --continue or --abort. - common_dir = b4.git_get_common_dir(topdir) - if common_dir and os.path.exists( - os.path.join(common_dir, 'b4-shazam-state.json') - ): - logger.critical('A shazam conflict resolution is already in progress.') - logger.critical('Finish it with: b4 shazam --continue') - logger.critical('Or discard it: b4 shazam --abort') - sys.exit(1) - try: if cmdargs.mergebase: logger.info(' Base: %s', base_commit) @@ -525,23 +513,24 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - logger.critical('Use --resolve to enable conflict resolution') sys.exit(1) - common_dir = b4.git_get_common_dir(topdir) - if not common_dir: - logger.critical('Unable to determine git common dir') + # --resolve into a dirty tree can't finish: the user would resolve the + # whole series in the subshell only for the final git-merge to refuse. + # Fail fast before any of that resolution work is done. + status_lines = b4.git_get_repo_status(topdir) + if status_lines: + logger.critical('You have uncommitted changes in your working tree.') + logger.critical('Please commit or stash them before resolving.') b4.git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) sys.exit(1) - state = { - 'worktree': gwt, - 'base': base_commit, - 'origin': linkurl, - 'merge_template_values': tptvals, - 'merge_template': merge_template, - 'merge_flags': mergeflags, - 'no_interactive': cmdargs.no_interactive, - 'do_merge': cmdargs.merge, - } - _begin_shazam_resolve(cex, common_dir, state) + # --resolve: drop into a subshell in the worktree and finish the + # git-am inline. On success FETCH_HEAD holds the fully-applied series + # and the worktree is gone, so we fall through to the same merge the + # clean path runs. b4 stays the parent until then -- no exec/exit + # here -- so _run_shazam_merge can only execvp git-merge at the very + # end, once the worktree has already been dropped. + if not b4.resolve_am_conflict_in_shell(topdir, cex, origin=linkurl): + sys.exit(1) except RuntimeError: sys.exit(1) @@ -1002,8 +991,8 @@ def _run_shazam_merge( ) -> None: """Merge the series sitting in FETCH_HEAD into the current branch. - Shared by the clean ``b4 shazam`` path and ``b4 shazam --continue``: render - the cover letter as the merge message and either run ``git merge`` (handing + Shared by the clean ``b4 shazam`` path and the ``--resolve`` conflict path: + render the cover letter as the merge message and either run ``git merge`` (handing the terminal to git so it can open the editor and resolve any conflicts natively) or just point the user at FETCH_HEAD. """ @@ -1056,185 +1045,9 @@ def _run_shazam_merge( sys.exit(0) -def _begin_shazam_resolve( - cex: b4.AmConflictError, common_dir: str, state: Dict[str, Any] -) -> None: - """Hand a conflicted ``git am`` back to the user to finish in the worktree. - - Rather than replaying the not-yet-applied patches with ``git apply``, keep - the in-progress ``git am`` (already preserved by git_fetch_am_into_repo) so - the user drives it to completion natively and nothing is silently dropped. - The fully-applied series is merged once, by ``b4 shazam --continue``. - """ - # TODO: switch --resolve to a subshell model like the review TUI's - # _resolve_worktree_am_conflict -- suspend into a shell in the worktree - # (blocking subprocess.run; b4 stays the parent) and finish inline when it - # exits: fetch+merge on success, tear the worktree down on "git am --abort" - # or an unfinished am. That drops the persisted b4-shazam-state.json and the - # separate --continue/--abort commands, at the cost of the git-rebase-style - # two-phase UX. Two constraints make it robust: - # - b4 must stay alive while the shell runs (no sys.exit/os.execvp until - # cleanup has run); _run_shazam_merge may only execvp git-merge at the - # very end, once the worktree is already dropped. - # - guard the worktree with try/finally (or atexit + a SIGINT/SIGTERM - # handler) so it is reclaimed even if b4 is killed while the shell is up -- - # a shell "trap ... EXIT" analogue. Keep it outcome-aware (finished vs - # aborted), never an unconditional remove, or a half-done resolution dies. - gwt = cex.worktree_path - logger.critical('---') - logger.critical(cex.output) - logger.critical('---') - logger.critical('Patch series did not apply cleanly.') - - # git_fetch_am_into_repo already rebuilt a full (non-sparse) worktree on the - # conflict, so every conflicted file is present for the user to edit. - state_file = os.path.join(common_dir, 'b4-shazam-state.json') - with open(state_file, 'w') as sfh: - json.dump(state, sfh, indent=2) - - logger.critical('Resolve the conflict in the worktree and finish the git-am:') - logger.critical(' cd %s', gwt) - logger.critical(' git am --continue (or "git am --skip" to drop a patch)') - logger.critical('Once git-am is done, come back and run:') - logger.critical(' b4 shazam --continue') - logger.critical('To give up and clean everything up:') - logger.critical(' b4 shazam --abort') - sys.exit(1) - - -def _load_shazam_state( - require_state: bool = True, -) -> Tuple[str, str, str, Optional[Dict[str, Any]]]: - topdir = b4.git_get_toplevel() - if not topdir: - logger.critical('Could not figure out where your git dir is.') - sys.exit(1) - common_dir = b4.git_get_common_dir(topdir) - if not common_dir: - logger.critical('Unable to determine git common dir.') - sys.exit(1) - - state_file = os.path.join(common_dir, 'b4-shazam-state.json') - state = None - if os.path.exists(state_file): - try: - with open(state_file, 'r') as fh: - state = json.load(fh) - except (json.JSONDecodeError, OSError) as ex: - # A truncated/corrupt state file (e.g. an interrupted write) must not - # crash the recovery command: --abort still cleans it up (state=None - # falls back to the default worktree path), --continue can't proceed. - if require_state: - logger.critical('Shazam state file is corrupt: %s', ex) - logger.critical('Run: b4 shazam --abort') - sys.exit(1) - elif require_state: - logger.critical('No shazam state found. Nothing to continue.') - sys.exit(1) - - return topdir, common_dir, state_file, state - - -def shazam_continue(cmdargs: argparse.Namespace) -> None: - topdir, _common_dir, state_file, state = _load_shazam_state(require_state=True) - assert state is not None - - gwt = state.get('worktree') - if not gwt or not os.path.isdir(gwt): - logger.critical('Resolution worktree is gone. Run: b4 shazam --abort') - sys.exit(1) - - # The git-am has to be finished before we can fetch and merge the series. - if b4._worktree_rebase_apply_dir(gwt): - logger.critical('git-am is still in progress in the worktree:') - logger.critical(' %s', gwt) - logger.critical( - 'Finish it there with "git am --continue" (or "git am --skip"),' - ) - logger.critical('then run: b4 shazam --continue') - sys.exit(1) - - # git-am is finished -- but did it apply anything? If the user ran - # "git am --abort" (or "--skip"ped every patch) the worktree is back at its - # base; fetching+merging that is a no-op ("Already up to date") that would - # silently drop the whole series. Refuse instead of reporting false success. - base = state.get('base') - if base: - _e1, head = b4.git_run_command(gwt, ['rev-parse', 'HEAD'], rundir=gwt) - _e2, base_sha = b4.git_run_command( - gwt, ['rev-parse', '%s^{commit}' % base], rundir=gwt - ) - if head.strip() and head.strip() == base_sha.strip(): - logger.critical('No patches are applied in the resolution worktree.') - logger.critical('(git-am was aborted, or every patch was skipped.)') - logger.critical('Nothing to merge -- run: b4 shazam --abort') - sys.exit(1) - - # A dirty working tree can make the final merge refuse or fail. Only the - # merge cares (do_merge), so check up front and keep the saved state intact - # so --continue stays re-runnable once the tree is clean. - if state.get('do_merge', True) and b4.git_get_repo_status(topdir): - logger.critical('You have uncommitted changes in your working tree.') - logger.critical('Commit or stash them, then run: b4 shazam --continue') - sys.exit(1) - - # git-am completed: fetch the fully-applied series into FETCH_HEAD, drop the - # worktree, and merge it exactly like a clean shazam would. - logger.info('Fetching resolved series into FETCH_HEAD') - if not b4._fetch_and_drop_am_worktree(topdir, gwt, origin=state.get('origin')): - sys.exit(1) - - # Clean up state before merging -- an interactive merge hands off via execvp - # and never returns here. - if os.path.exists(state_file): - os.unlink(state_file) - - _run_shazam_merge( - topdir, - merge_template=state.get('merge_template', DEFAULT_MERGE_TEMPLATE), - tptvals=state.get('merge_template_values', {}), - merge_flags=str(state.get('merge_flags', '')), - no_interactive=state.get('no_interactive', False), - do_merge=state.get('do_merge', True), - ) - - -def shazam_abort(cmdargs: argparse.Namespace) -> None: - topdir, common_dir, state_file, state = _load_shazam_state(require_state=False) - found = False - - # Back out the FETCH_HEAD merge if --continue started one and it conflicted. - ecode, _out = b4.git_run_command( - topdir, ['merge', '--abort'], logstderr=True, rundir=topdir - ) - if ecode == 0: - found = True - - # Remove the resolution worktree, discarding its in-progress git-am. - gwt = state.get('worktree') if state else None - if not gwt: - gwt = os.path.join(common_dir, 'b4-shazam-worktree') - if os.path.exists(gwt): - b4.git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) - found = True - - if os.path.exists(state_file): - os.unlink(state_file) - found = True - - if found: - logger.info('Shazam aborted and cleaned up.') - else: - logger.info('No shazam in progress.') - - def main(cmdargs: argparse.Namespace) -> None: # We force some settings if cmdargs.subcmd == 'shazam': - if getattr(cmdargs, 'shazam_continue', False): - return shazam_continue(cmdargs) - if getattr(cmdargs, 'shazam_abort', False): - return shazam_abort(cmdargs) cmdargs.checknewer = True cmdargs.threeway = False cmdargs.nopartialreroll = False diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py index 34f5ad5..0c00917 100644 --- a/src/b4/review_tui/_tracking_app.py +++ b/src/b4/review_tui/_tracking_app.py @@ -253,56 +253,6 @@ def _take_worktree( b4.git_run_command(topdir, ['worktree', 'remove', '--force', temp_wt]) -def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> bool: - """Handle an AmConflictError by dropping the user into a shell. - - Suspends to an interactive shell for conflict resolution (the worktree is - already a full checkout -- git_fetch_am_into_repo rebuilds it on conflict), - then checks the outcome: - - - If the user completed ``git am --continue``, fetches the result - into FETCH_HEAD and removes the worktree. Returns True. - - If the user aborted (``git am --abort``) or exited without - finishing, cleans up the worktree and returns False. - """ - logger.critical('---') - logger.critical(cex.output) - logger.critical('---') - logger.critical('Patch did not apply cleanly.') - # Save worktree HEAD before shell so we can detect abort - _ecode, wt_head_before = b4.git_run_command( - cex.worktree_path, - ['rev-parse', 'HEAD'], - logstderr=True, - rundir=cex.worktree_path, - ) - wt_head_before = wt_head_before.strip() - logger.info('You can resolve the conflict in the worktree.') - logger.info( - 'Use "git am --continue" after resolving, or "git am --abort" to give up.' - ) - _suspend_to_shell(hint='b4 conflict', cwd=cex.worktree_path) - # Check if am is still in progress (user exited without finishing) - 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 - # Check if am was aborted (HEAD unchanged from before shell) - _ecode, wt_head_after = b4.git_run_command( - cex.worktree_path, - ['rev-parse', 'HEAD'], - logstderr=True, - rundir=cex.worktree_path, - ) - if wt_head_after.strip() == wt_head_before: - 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 and drop the worktree - logger.info('Conflict resolved, fetching result...') - return b4._fetch_and_drop_am_worktree(topdir, cex.worktree_path) - - def _resolve_worktree_take_conflict( wt: '_TakeWorktree', op: str, @@ -1884,10 +1834,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): logger.info('Review branch created: %s', branch_name) checkout_success = True except b4.AmConflictError as cex: - if not _resolve_worktree_am_conflict(topdir, cex): + if not b4.resolve_am_conflict_in_shell(topdir, cex, origin=linkurl): _wait_for_enter() return - b4._rewrite_fetch_head_origin(topdir, cex.worktree_path, linkurl) # Create the review branch from resolved result b4.review.create_review_branch( topdir, @@ -2802,7 +2751,9 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): resolve=True, ) except b4.AmConflictError as cex: - if not _resolve_worktree_am_conflict(merge_dir, cex): + if not b4.resolve_am_conflict_in_shell( + merge_dir, cex, origin=t_series.get('link', '') + ): _wait_for_enter() return except RuntimeError: @@ -4321,14 +4272,13 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): ) logger.info('Upgrade branch created: %s', upgrade_branch) except b4.AmConflictError as cex: - if not _resolve_worktree_am_conflict(topdir, cex): + if not b4.resolve_am_conflict_in_shell(topdir, cex, origin=linkurl): # User aborted — clean up upgrade branch if it was # partially created before the conflict if b4.git_branch_exists(topdir, upgrade_branch): b4.git_run_command(topdir, ['branch', '-D', upgrade_branch]) _wait_for_enter() return - b4._rewrite_fetch_head_origin(topdir, cex.worktree_path, linkurl) b4.review.create_review_branch( topdir, upgrade_branch, diff --git a/src/b4/tui/_common.py b/src/b4/tui/_common.py index c3312a8..52f6fdd 100644 --- a/src/b4/tui/_common.py +++ b/src/b4/tui/_common.py @@ -8,9 +8,6 @@ __author__ = 'Konstantin Ryabitsev <[email protected]>' import email.utils -import os -import subprocess -import tempfile import unicodedata from collections import defaultdict from typing import Any, Dict, List, Optional, Protocol @@ -23,6 +20,11 @@ from textual.worker import NoActiveWorker, get_current_worker import b4 +# _suspend_to_shell now lives in b4 itself (textual-free, so the non-TUI shazam +# conflict flow can reuse it). Re-export it so this stays the import home for the +# TUI callers (and b4.review_tui._common's re-export of it). +from b4 import _suspend_to_shell as _suspend_to_shell + logger = b4.logger @@ -206,56 +208,6 @@ def _wait_for_enter() -> None: pass -def _suspend_to_shell(hint: str = 'b4', cwd: Optional[str] = None) -> None: - """Spawn an interactive sub-shell with a PS1 hint. - - For bash and zsh, a temporary rc file is used so the user's normal - configuration is loaded first and then the prompt is prefixed with - a short marker. For other shells the B4_REVIEW environment variable - is set so the user can incorporate it into their own prompt. - """ - logger.info('---') - logger.info( - 'You are now in shell mode. You can execute git commands or run checks.' - ) - logger.info('Cosmetic commit edits (reword subjects, fix trailers) are fine;') - logger.info('b4 will reconcile tracking data when you return.') - logger.info('Do NOT add, remove, squash, or reorder commits.') - logger.info('When done, Ctrl-d to return to review UI.') - logger.info('---') - - shell = os.environ.get('SHELL', '/bin/sh') - shellname = os.path.basename(shell) - env = os.environ.copy() - env['B4_REVIEW'] = hint - - if shellname == 'bash': - bashrc = os.path.expanduser('~/.bashrc') - source = f'[ -f {bashrc} ] && . {bashrc}\n' - source += f'PS1="({hint}) $PS1"\n' - with tempfile.NamedTemporaryFile( - mode='w', prefix='b4-shell-', suffix='.sh', delete=False - ) as rcf: - rcf.write(source) - rcfile = rcf.name - try: - subprocess.run([shell, '--rcfile', rcfile], env=env, cwd=cwd) - finally: - os.unlink(rcfile) - elif shellname == 'zsh': - real_zdotdir = os.environ.get('ZDOTDIR', os.path.expanduser('~')) - with tempfile.TemporaryDirectory(prefix='b4-shell-') as tmpdir: - zshrc = os.path.join(tmpdir, '.zshrc') - with open(zshrc, 'w') as f: - f.write(f'ZDOTDIR="{real_zdotdir}"\n') - f.write('[ -f "$ZDOTDIR/.zshrc" ] && . "$ZDOTDIR/.zshrc"\n') - f.write(f'PS1="({hint}) $PS1"\n') - env['ZDOTDIR'] = tmpdir - subprocess.run([shell], env=env, cwd=cwd) - else: - subprocess.run([shell], env=env, cwd=cwd) - - def _addrs_to_lines(header_str: str) -> str: """Parse a comma-separated address header into one-per-line display.""" if not header_str: -- 2.53.0