[PATCH b4 v3 4/9] shazam: resolve conflicts in subdirectory files, not just the repo root

Christian Brauner <[email protected]> Thu, 25 Jun 2026 14:09:24 +0200
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
The native-am --resolve flow applies the series in a sparse worktree
(only root-level files materialized). git's 3-way merge refuses to touch
skip-worktree paths, so a conflict in a subdirectory file -- the common
case for real series -- makes `git am -3` abort with a clean index: no
conflict markers, no unmerged entries. b4 then points the user at a
conflict that isn't there; `git am --continue` dead-ends and
`git am --skip` silently drops the patch -- the very failure this series
set out to prevent. Only root-file conflicts worked, which is exactly
what the tests covered, so it slipped through.

Fix it at the shared chokepoint. git_fetch_am_into_repo grows a `resolve`
flag; on a conflict it rebuilds a full (non-sparse) worktree -- abort the
partial am, disable sparse-checkout, replay -- so the conflict is recorded
with real markers for the user (or `git am --continue`) to resolve. Both
`b4 shazam --resolve` and the review TUI apply through this one function,
so both are fixed at once, and the now-redundant sparse-checkout disables
in the two resolve handlers go away. A regression test drives a
subdirectory conflict end to end (and fails without the rebuild).

While here, harden the surrounding flow:

  - b4 shazam --abort no longer crashes on a corrupt/truncated state file
    (_load_shazam_state only guarded the json parse for --continue); abort
    now treats it as no state and cleans it up.
  - shazam --continue refuses up front when the working tree is dirty,
    keeping its saved state so it stays re-runnable, instead of deleting
    state and then letting the final merge fail with nothing to retry.
  - _fetch_and_drop_am_worktree leaves the worktree in place on a fetch
    failure so the resolved git-am can be retried rather than discarded.
  - docs: b4 shazam --continue merges only with -M; with -H it leaves the
    series in FETCH_HEAD, exactly as a clean run would. Say so.

Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 docs/maintainer/am-shazam.rst      |  12 +-
 src/b4/__init__.py                 |  46 ++++++-
 src/b4/mbox.py                     |  56 +++++++-
 src/b4/review_tui/_tracking_app.py |  15 +--
 src/tests/test_three_way_merge.py  | 264 ++++++++++++++++++++++++++++++++++++-
 5 files changed, 366 insertions(+), 27 deletions(-)

diff --git a/docs/maintainer/am-shazam.rst b/docs/maintainer/am-shazam.rst
index 37dc80f..9671c97 100644
--- a/docs/maintainer/am-shazam.rst
+++ b/docs/maintainer/am-shazam.rst
@@ -317,7 +317,8 @@ the merge commit.
 ``--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 merges it.
+  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
@@ -374,10 +375,11 @@ branch and run::
     b4 shazam --continue
 
 B4 fetches the fully-applied series out of the worktree, removes the
-worktree, and merges the series into your branch — exactly the merge a
-clean ``b4 shazam`` would have made. If that final merge itself
-conflicts, resolve it the normal way (git leaves the conflicted merge in
-your tree) and commit.
+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::
 
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index be41da1..7a695ea 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -5737,20 +5737,41 @@ def _fetch_and_drop_am_worktree(
     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.
+    merge message names the series origin instead of the worktree path. On fetch
+    failure the worktree is left in place (so the resolved git-am can be retried)
+    and False is returned.
     """
     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:
+        # Leave the worktree in place so the resolved git-am can be retried.
         logger.critical('Unable to fetch from the worktree')
         logger.critical(out.strip())
         return False
+    if origin:
+        _rewrite_fetch_head_origin(dest, gwt, origin)
+    git_run_command(dest, ['worktree', 'remove', '--force', gwt])
     return True
 
 
+def _replay_am_on_full_worktree(
+    gwt: str, ambytes: bytes, amargs: List[str]
+) -> Tuple[int, str]:
+    """Replay a sparse-blocked ``git am`` on a full (non-sparse) checkout.
+
+    git_fetch_am_into_repo applies into a sparse worktree (only root-level files
+    materialized). git's 3-way merge will not write ``skip-worktree`` paths, so a
+    subdirectory file makes the sparse ``git am`` stop even when the 3-way is
+    clean -- and a real conflict there is recorded with an empty index (no markers
+    to resolve). Abort the partial am, drop the sparse restriction so every path
+    is present, and replay. Returns the replay's (exit code, output): non-zero is
+    a genuine conflict the user resolves; zero means only sparseness had blocked
+    it and the series actually applies cleanly.
+    """
+    git_run_command(gwt, ['am', '--abort'], logstderr=True, rundir=gwt)
+    git_run_command(gwt, ['sparse-checkout', 'disable'], logstderr=True, rundir=gwt)
+    return git_run_command(gwt, amargs, stdin=ambytes, logstderr=True, rundir=gwt)
+
+
 def git_fetch_am_into_repo(
     gitdir: Optional[str],
     ambytes: bytes,
@@ -5758,6 +5779,7 @@ def git_fetch_am_into_repo(
     origin: Optional[str] = None,
     check_only: bool = False,
     am_flags: Optional[List[str]] = None,
+    resolve: bool = False,
 ) -> None:
     if gitdir is None:
         gitdir = os.getcwd()
@@ -5803,8 +5825,18 @@ def git_fetch_am_into_repo(
             gwt, amargs, stdin=ambytes, logstderr=True, rundir=gwt
         )
         if ecode > 0:
-            cleanup = False
-            raise AmConflictError(gwt, out.strip())
+            if resolve:
+                # The sparse worktree can't write skip-worktree paths, so git-am
+                # stops on any subdirectory file -- a real conflict (recorded
+                # with an empty index, no markers) or even a clean 3-way. Replay
+                # on a full worktree to tell them apart.
+                ecode, out = _replay_am_on_full_worktree(gwt, ambytes, amargs)
+            if ecode > 0:
+                # Genuine conflict: park the am for the user to resolve.
+                cleanup = False
+                raise AmConflictError(gwt, out.strip())
+            # else: only sparseness blocked it; the series applied cleanly --
+            # fall through to the normal fetch-into-FETCH_HEAD path.
         if check_only:
             return
         logger.info('---')
diff --git a/src/b4/mbox.py b/src/b4/mbox.py
index 97ef51f..374328a 100644
--- a/src/b4/mbox.py
+++ b/src/b4/mbox.py
@@ -488,6 +488,18 @@ 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)
@@ -499,6 +511,7 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) -
                 at_base=base_commit,
                 origin=linkurl,
                 am_flags=am_flags,
+                resolve=cmdargs.shazam_resolve,
             )
         except b4.AmConflictError as cex:
             gwt = cex.worktree_path
@@ -520,6 +533,7 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) -
 
             state = {
                 'worktree': gwt,
+                'base': base_commit,
                 'origin': linkurl,
                 'merge_template_values': tptvals,
                 'merge_template': merge_template,
@@ -1058,9 +1072,8 @@ def _begin_shazam_resolve(
     logger.critical('---')
     logger.critical('Patch series did not apply cleanly.')
 
-    # Drop sparse-checkout so the user can see and edit every conflicted file.
-    b4.git_run_command(gwt, ['sparse-checkout', 'disable'], logstderr=True, rundir=gwt)
-
+    # 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)
@@ -1090,8 +1103,17 @@ def _load_shazam_state(
     state_file = os.path.join(common_dir, 'b4-shazam-state.json')
     state = None
     if os.path.exists(state_file):
-        with open(state_file, 'r') as fh:
-            state = json.load(fh)
+        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)
@@ -1118,6 +1140,30 @@ def shazam_continue(cmdargs: argparse.Namespace) -> None:
         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')
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 9b3ad65..34f5ad5 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -256,8 +256,9 @@ def _take_worktree(
 def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> bool:
     """Handle an AmConflictError by dropping the user into a shell.
 
-    Disables sparse checkout in the worktree, suspends to an interactive
-    shell for conflict resolution, then checks the outcome:
+    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.
@@ -268,13 +269,6 @@ def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> boo
     logger.critical(cex.output)
     logger.critical('---')
     logger.critical('Patch did not apply cleanly.')
-    # Disable sparse checkout so user can see and edit files
-    b4.git_run_command(
-        cex.worktree_path,
-        ['sparse-checkout', 'disable'],
-        logstderr=True,
-        rundir=cex.worktree_path,
-    )
     # Save worktree HEAD before shell so we can detect abort
     _ecode, wt_head_before = b4.git_run_command(
         cex.worktree_path,
@@ -1871,6 +1865,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     at_base=base_commit,
                     origin=linkurl,
                     am_flags=['-3'],
+                    resolve=True,
                 )
 
                 # Create the review branch
@@ -2804,6 +2799,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     at_base=base_commit,
                     origin=t_series.get('link', ''),
                     am_flags=['-3'],
+                    resolve=True,
                 )
             except b4.AmConflictError as cex:
                 if not _resolve_worktree_am_conflict(merge_dir, cex):
@@ -4309,6 +4305,7 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]):
                     at_base=base_sha,
                     origin=linkurl,
                     am_flags=['-3'],
+                    resolve=True,
                 )
                 b4.review.create_review_branch(
                     topdir,
diff --git a/src/tests/test_three_way_merge.py b/src/tests/test_three_way_merge.py
index 090aa66..4db592e 100644
--- a/src/tests/test_three_way_merge.py
+++ b/src/tests/test_three_way_merge.py
@@ -526,6 +526,83 @@ def _build_multi_patch_conflict(gitdir: str) -> Tuple[bytes, str]:
     return mbox.encode(), base
 
 
+def _build_subdir_conflict(gitdir: str) -> bytes:
+    """2-patch mbox whose conflicting patch touches a file in a SUBDIRECTORY.
+
+    The shazam worktree is a cone-mode sparse checkout (only root-level files
+    materialized), and git's 3-way merge refuses to touch skip-worktree paths,
+    so a conflict in a subdirectory file used to abort ``git am`` with a clean
+    index (no markers) -- and ``git am --skip`` would silently drop the patch.
+    With ``resolve=True``, git_fetch_am_into_repo rebuilds a full worktree and
+    replays so the conflict is recorded. Patch 1 changes a root file cleanly;
+    patch 2 changes ``drivers/foo.txt`` and conflicts with master.
+    """
+    # Seed a subdirectory file on master so it is part of the base tree.
+    os.makedirs(os.path.join(gitdir, 'drivers'), exist_ok=True)
+    with open(os.path.join(gitdir, 'drivers', 'foo.txt'), 'w') as fh:
+        fh.write('1\n2\n3\n')
+    b4.git_run_command(gitdir, ['add', 'drivers/foo.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Seed drivers/foo.txt'])
+
+    b4.git_run_command(gitdir, ['checkout', '-b', 'subdir-patch'])
+    with open(os.path.join(gitdir, 'file2.txt'), 'a') as fh:
+        fh.write('Added by patch 1.\n')
+    b4.git_run_command(gitdir, ['add', 'file2.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Patch 1: modify file2'])
+    with open(os.path.join(gitdir, 'drivers', 'foo.txt'), 'w') as fh:
+        fh.write('1\nPATCH\n3\n')
+    b4.git_run_command(gitdir, ['add', 'drivers/foo.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Patch 2: change drivers/foo'])
+
+    ecode, mbox = b4.git_run_command(gitdir, ['format-patch', '-2', '--stdout'])
+    assert ecode == 0
+
+    b4.git_run_command(gitdir, ['checkout', 'master'])
+    b4.git_run_command(gitdir, ['branch', '-D', 'subdir-patch'])
+    with open(os.path.join(gitdir, 'drivers', 'foo.txt'), 'w') as fh:
+        fh.write('1\nMASTER\n3\n')
+    b4.git_run_command(gitdir, ['add', 'drivers/foo.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Master: change drivers/foo'])
+    return mbox.encode()
+
+
+def _build_subdir_clean_3way(gitdir: str) -> bytes:
+    """1-patch mbox: a subdir change that 3-way merges CLEANLY against master.
+
+    The patch edits ``drivers/foo.txt`` near (but clear of) a line master also
+    changed, so the direct ``git apply`` misses on context and falls back to a
+    3-way merge that is clean. In the sparse shazam worktree git-am still stops
+    (it can't write the skip-worktree subdir file), but the full replay applies
+    cleanly -- so ``git_fetch_am_into_repo(resolve=True)`` must NOT report a
+    conflict.
+    """
+    lines = ''.join('%d\n' % n for n in range(1, 21))
+    os.makedirs(os.path.join(gitdir, 'drivers'), exist_ok=True)
+    with open(os.path.join(gitdir, 'drivers', 'foo.txt'), 'w') as fh:
+        fh.write(lines)
+    b4.git_run_command(gitdir, ['add', 'drivers/foo.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Seed drivers/foo.txt'])
+
+    b4.git_run_command(gitdir, ['checkout', '-b', 'clean3'])
+    with open(os.path.join(gitdir, 'drivers', 'foo.txt'), 'w') as fh:
+        fh.write(lines.replace('10\n', 'TEN-from-patch\n'))
+    b4.git_run_command(gitdir, ['add', 'drivers/foo.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Patch: drivers/foo line 10'])
+
+    ecode, mbox = b4.git_run_command(gitdir, ['format-patch', '-1', '--stdout'])
+    assert ecode == 0
+
+    b4.git_run_command(gitdir, ['checkout', 'master'])
+    b4.git_run_command(gitdir, ['branch', '-D', 'clean3'])
+    # Line 13 is inside the patch's context window for line 10 (forces 3-way)
+    # but two lines clear of it, so the merge is clean rather than a conflict.
+    with open(os.path.join(gitdir, 'drivers', 'foo.txt'), 'w') as fh:
+        fh.write(lines.replace('13\n', 'THIRTEEN-master\n'))
+    b4.git_run_command(gitdir, ['add', 'drivers/foo.txt'])
+    b4.git_run_command(gitdir, ['commit', '-m', 'Master: drivers/foo line 13'])
+    return mbox.encode()
+
+
 def _make_shazam_state(common_dir: str, state: Optional[Dict[str, Any]] = None) -> str:
     """Write a shazam state file; return its path."""
     state_file = os.path.join(common_dir, 'b4-shazam-state.json')
@@ -575,10 +652,14 @@ def _trigger_am_conflict(
     _begin_shazam_resolve).
     """
     ambytes, _base = _build_multi_patch_conflict(gitdir)
+    _ecode, head = b4.git_run_command(gitdir, ['rev-parse', 'HEAD'])
     with pytest.raises(b4.AmConflictError) as exc_info:
-        b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
+        b4.git_fetch_am_into_repo(
+            gitdir, ambytes, at_base='HEAD', am_flags=['-3'], resolve=True
+        )
     state = {
         'worktree': exc_info.value.worktree_path,
+        'base': head.strip(),
         'origin': 'https://example.com',
         'merge_template_values': {},
         'merge_template': 'Merge test series\n\nResolved conflict.\n',
@@ -682,6 +763,38 @@ class TestShazamResolveContinue:
         b4.git_run_command(gitdir, ['worktree', 'remove', '--force', wt])
         os.unlink(os.path.join(common_dir, 'b4-shazam-state.json'))
 
+    def test_continue_refuses_after_am_abort(self, gitdir: str) -> None:
+        # Regression: if the user runs "git am --abort" instead of finishing,
+        # the worktree is back at base with no rebase-apply. --continue must
+        # refuse rather than merge the bare base -- a no-op ("Already up to
+        # date") that would silently drop the whole series and report success.
+        common_dir = b4.git_get_common_dir(gitdir)
+        assert common_dir is not None
+        cex, state = _trigger_am_conflict(gitdir)
+        wt = cex.worktree_path
+
+        with pytest.raises(SystemExit):
+            b4.mbox._begin_shazam_resolve(cex, common_dir, state)
+
+        # User gives up with "git am --abort" in the worktree.
+        ecode, _out = b4.git_run_command(wt, ['am', '--abort'], rundir=wt)
+        assert ecode == 0
+        assert b4._worktree_rebase_apply_dir(wt) is None
+
+        # --continue refuses (nothing applied), keeping state + worktree, and
+        # makes NO commit on the branch.
+        _e, head_before = b4.git_run_command(gitdir, ['rev-parse', 'HEAD'])
+        with pytest.raises(SystemExit) as exit_info:
+            b4.mbox.shazam_continue(argparse.Namespace())
+        assert exit_info.value.code == 1
+        assert os.path.exists(os.path.join(common_dir, 'b4-shazam-state.json'))
+        assert os.path.isdir(wt)
+        _e, head_after = b4.git_run_command(gitdir, ['rev-parse', 'HEAD'])
+        assert head_after.strip() == head_before.strip()
+
+        b4.git_run_command(gitdir, ['worktree', 'remove', '--force', wt])
+        os.unlink(os.path.join(common_dir, 'b4-shazam-state.json'))
+
 
 class TestShazamAbort:
     """Tests for shazam_abort cleanup."""
@@ -704,3 +817,152 @@ class TestShazamAbort:
     def test_noop_when_nothing_to_clean(self, gitdir: str) -> None:
         # Should not raise.
         b4.mbox.shazam_abort(argparse.Namespace())
+
+
+class TestSubdirConflictResolve:
+    """Regression: a conflict in a subdirectory file must be resolvable.
+
+    The sparse shazam worktree can't record conflicts in subdirectory files,
+    so ``git am`` aborted with a clean index and the patch was silently
+    dropped. ``resolve=True`` must rebuild a full worktree so the conflict is
+    materialized and every patch survives.
+    """
+
+    def test_subdir_conflict_records_markers_and_keeps_patch(self, gitdir: str) -> None:
+        common_dir = b4.git_get_common_dir(gitdir)
+        assert common_dir is not None
+        ambytes = _build_subdir_conflict(gitdir)
+
+        with pytest.raises(b4.AmConflictError) as exc_info:
+            b4.git_fetch_am_into_repo(
+                gitdir, ambytes, at_base='HEAD', am_flags=['-3'], resolve=True
+            )
+        wt = exc_info.value.worktree_path
+
+        # The subdir file is materialized and recorded as an unmerged conflict
+        # (without the fix it would be absent / clean and the patch lost).
+        assert os.path.exists(os.path.join(wt, 'drivers', 'foo.txt'))
+        _ecode, unmerged = b4.git_run_command(
+            wt, ['diff', '--name-only', '--diff-filter=U'], rundir=wt
+        )
+        assert 'drivers/foo.txt' in unmerged
+
+        state = {
+            'worktree': wt,
+            'origin': 'https://example.com',
+            'merge_template_values': {},
+            'merge_template': 'Merge series\n\nResolved.\n',
+            'merge_flags': '--signoff',
+            'no_interactive': True,
+            'do_merge': True,
+        }
+        with pytest.raises(SystemExit):
+            b4.mbox._begin_shazam_resolve(exc_info.value, common_dir, state)
+
+        # User resolves the subdir conflict natively and finishes the git-am.
+        with open(os.path.join(wt, 'drivers', 'foo.txt'), 'w') as fh:
+            fh.write('1\nRESOLVED\n3\n')
+        b4.git_run_command(wt, ['add', 'drivers/foo.txt'], rundir=wt)
+        ecode, _out = b4.git_run_command(wt, ['am', '--continue'], rundir=wt)
+        assert ecode == 0
+
+        with pytest.raises(SystemExit) as exit_info:
+            b4.mbox.shazam_continue(argparse.Namespace())
+        assert exit_info.value.code == 0
+
+        # Both patches survived: patch 1 (file2) and patch 2 (drivers/foo).
+        ecode, file2 = b4.git_run_command(gitdir, ['show', 'HEAD:file2.txt'])
+        assert ecode == 0 and 'Added by patch 1.' in file2
+        ecode, foo = b4.git_run_command(gitdir, ['show', 'HEAD:drivers/foo.txt'])
+        assert ecode == 0 and 'RESOLVED' in foo
+
+        assert not os.path.exists(os.path.join(common_dir, 'b4-shazam-state.json'))
+        assert not os.path.exists(wt)
+
+
+class TestSubdirCleanThreeWay:
+    """Regression: a clean 3-way in a subdir file must not be a phantom conflict.
+
+    The sparse worktree can't write skip-worktree paths, so git-am stops on the
+    subdir file even though the 3-way is clean. The full replay applies cleanly,
+    so ``resolve=True`` must complete normally -- not raise AmConflictError and
+    send the user off to resolve a conflict that does not exist.
+    """
+
+    def test_clean_subdir_3way_does_not_raise(self, gitdir: str) -> None:
+        common_dir = b4.git_get_common_dir(gitdir)
+        assert common_dir is not None
+        ambytes = _build_subdir_clean_3way(gitdir)
+
+        # Must NOT raise: only sparseness blocked the sparse am; the replay is clean.
+        b4.git_fetch_am_into_repo(
+            gitdir, ambytes, at_base='HEAD', am_flags=['-3'], resolve=True
+        )
+
+        # The series landed in FETCH_HEAD with BOTH edits 3-way merged.
+        ecode, foo = b4.git_run_command(gitdir, ['show', 'FETCH_HEAD:drivers/foo.txt'])
+        assert ecode == 0
+        assert 'TEN-from-patch' in foo and 'THIRTEEN-master' in foo
+
+        # Worktree torn down, nothing parked for resolution.
+        assert not os.path.exists(os.path.join(common_dir, 'b4-shazam-worktree'))
+        assert not os.path.exists(os.path.join(common_dir, 'b4-shazam-state.json'))
+
+
+class TestCorruptShazamState:
+    """A corrupt state file must not crash the recovery command."""
+
+    def test_abort_survives_corrupt_state(self, gitdir: str) -> None:
+        common_dir = b4.git_get_common_dir(gitdir)
+        assert common_dir is not None
+        state_file = os.path.join(common_dir, 'b4-shazam-state.json')
+        with open(state_file, 'w') as fh:
+            fh.write('{ this is not valid json')
+        # Cleans up the corrupt file instead of raising JSONDecodeError.
+        b4.mbox.shazam_abort(argparse.Namespace())
+        assert not os.path.exists(state_file)
+
+    def test_continue_refuses_corrupt_state(self, gitdir: str) -> None:
+        common_dir = b4.git_get_common_dir(gitdir)
+        assert common_dir is not None
+        state_file = os.path.join(common_dir, 'b4-shazam-state.json')
+        with open(state_file, 'w') as fh:
+            fh.write('{ this is not valid json')
+        with pytest.raises(SystemExit) as exit_info:
+            b4.mbox.shazam_continue(argparse.Namespace())
+        assert exit_info.value.code == 1
+        os.unlink(state_file)
+
+
+class TestContinueDirtyTree:
+    """shazam --continue refuses (keeping state) when the main tree is dirty."""
+
+    def test_continue_refuses_dirty_tree(self, gitdir: str) -> None:
+        common_dir = b4.git_get_common_dir(gitdir)
+        assert common_dir is not None
+        cex, state = _trigger_am_conflict(gitdir)
+        wt = cex.worktree_path
+        with pytest.raises(SystemExit):
+            b4.mbox._begin_shazam_resolve(cex, common_dir, state)
+
+        # Finish the git-am in the worktree.
+        with open(os.path.join(wt, 'file1.txt'), 'w') as fh:
+            fh.write('Resolved file1.\n')
+        b4.git_run_command(wt, ['add', 'file1.txt'], rundir=wt)
+        ecode, _out = b4.git_run_command(wt, ['am', '--continue'], rundir=wt)
+        assert ecode == 0
+
+        # Dirty the main tree, then --continue must refuse and keep state/worktree
+        # so it stays re-runnable.
+        with open(os.path.join(gitdir, 'file2.txt'), 'a') as fh:
+            fh.write('uncommitted local edit\n')
+        state_file = os.path.join(common_dir, 'b4-shazam-state.json')
+        with pytest.raises(SystemExit) as exit_info:
+            b4.mbox.shazam_continue(argparse.Namespace())
+        assert exit_info.value.code == 1
+        assert os.path.exists(state_file)
+        assert os.path.isdir(wt)
+
+        b4.git_run_command(gitdir, ['checkout', '--', 'file2.txt'])
+        b4.git_run_command(gitdir, ['worktree', 'remove', '--force', wt])
+        os.unlink(state_file)

-- 
2.53.0