[Concept,08/16] pickman: Handle dts/upstream subtree merges automatically

Message ID 20260222154303.2851319-9-sjg@u-boot.org
State New
Headers
Series pickman: Support monitoring and fixing pipeline failures |

Commit Message

Simon Glass Feb. 22, 2026, 3:42 p.m. UTC
  From: Simon Glass <simon.glass@canonical.com>

When pickman cherry-picks a series from us/next, it may encounter
commits that depend on a dts/upstream subtree update that is not yet
in ci/master. Subtree merges cannot be cherry-picked; they must be
applied with ./tools/update-subtree.sh pull dts <tag>.

Add subtree-merge detection to find_unprocessed_commits(). When a
merge subject matches "Subtree merge tag '<tag>' of ... into <path>",
pickman checks out the target branch, runs update-subtree.sh, marks
both the squash and merge commits as applied, advances the source
position, and retries to get the next batch.

This handles dts/upstream, lib/mbedtls/external/mbedtls and
lib/lwip/lwip subtrees.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
---

 tools/pickman/README.rst |  33 ++
 tools/pickman/control.py | 207 +++++++++++--
 tools/pickman/ftest.py   | 640 ++++++++++++++++++++++++++++++++++++++-
 3 files changed, 844 insertions(+), 36 deletions(-)
  

Patch

diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst
index 5c6121610be..70309032838 100644
--- a/tools/pickman/README.rst
+++ b/tools/pickman/README.rst
@@ -111,6 +111,39 @@  If there are no merge commits between the last processed commit and the branch
 tip, pickman includes all remaining commits in a single set. This is noted in
 the output as "no merge found".
 
+Subtree Merge Handling
+----------------------
+
+The source branch may contain subtree merges that update vendored trees such as
+``dts/upstream``, ``lib/mbedtls/external/mbedtls`` or ``lib/lwip/lwip``. These
+appear as a pair of commits on the first-parent chain:
+
+1. ``Squashed 'dts/upstream/' changes from <old>..<new>`` (the actual file
+   changes)
+2. ``Subtree merge tag '<tag>' of <repo> into dts/upstream`` (the merge commit
+   joining histories)
+
+These commits cannot be cherry-picked. Pickman detects them automatically by
+matching the merge subject against the pattern
+``Subtree merge tag '<tag>' of ... into <path>``. When a subtree merge is found,
+pickman:
+
+1. Checks out the target branch (e.g. ``ci/master``)
+2. Runs ``./tools/update-subtree.sh pull <name> <tag>`` to apply the update
+3. Pushes the target branch (if ``--push`` is active)
+4. Records both the squash and merge commits as 'applied' in the database
+5. Advances the source position past the merge and continues with the next batch
+
+This is works without manual intervention. The currently supported subtrees are:
+
+=================================  ===========
+Path                               Name
+=================================  ===========
+``dts/upstream``                   ``dts``
+``lib/mbedtls/external/mbedtls``   ``mbedtls``
+``lib/lwip/lwip``                  ``lwip``
+=================================  ===========
+
 Skipping MRs
 ------------
 
diff --git a/tools/pickman/control.py b/tools/pickman/control.py
index 3e69cba7a9a..48f7ced1617 100644
--- a/tools/pickman/control.py
+++ b/tools/pickman/control.py
@@ -46,6 +46,17 @@  RE_GIT_STAT_FILE = re.compile(r'^([^|]+)\s*\|')
 # Extract hash from line like "(cherry picked from commit abc123def)"
 RE_CHERRY_PICK = re.compile(r'cherry picked from commit ([a-f0-9]+)')
 
+# Detect subtree merge commits on the first-parent chain
+RE_SUBTREE_MERGE = re.compile(
+    r"Subtree merge tag '([^']+)' of .* into (.*)")
+
+# Map from subtree path to update-subtree.sh name
+SUBTREE_NAMES = {
+    'dts/upstream': 'dts',
+    'lib/mbedtls/external/mbedtls': 'mbedtls',
+    'lib/lwip/lwip': 'lwip',
+}
+
 # Named tuple for commit info
 Commit = namedtuple('Commit', ['hash', 'chash', 'subject', 'date'])
 
@@ -92,8 +103,11 @@  AgentCommit = namedtuple('AgentCommit',
 # commits: list of CommitInfo to cherry-pick
 # merge_found: True if these commits came from a merge on the source branch
 # advance_to: hash to advance the source position to, or None to stay put
+# subtree_update: (name, tag) tuple if a subtree update is needed, else None
 NextCommitsInfo = namedtuple('NextCommitsInfo',
-                             ['commits', 'merge_found', 'advance_to'])
+                             ['commits', 'merge_found', 'advance_to',
+                              'subtree_update'],
+                             defaults=[None])
 
 # Named tuple for prepare_apply() result
 #
@@ -747,6 +761,22 @@  def do_check_gitlab(args, dbs):  # pylint: disable=unused-argument
     return 0
 
 
+def _check_subtree_merge(merge_hash):
+    """Check if a merge commit is a subtree merge.
+
+    Returns:
+        tuple: (name, tag) where name is the subtree name (or None for
+            unknown paths), or None if not a subtree merge
+    """
+    subject = run_git(['log', '-1', '--format=%s', merge_hash])
+    match = RE_SUBTREE_MERGE.match(subject)
+    if not match:
+        return None
+    tag = match.group(1)
+    path = match.group(2)
+    return SUBTREE_NAMES.get(path), tag
+
+
 def find_unprocessed_commits(dbs, last_commit, source, merge_hashes):
     """Find the first merge with unprocessed commits
 
@@ -766,6 +796,18 @@  def find_unprocessed_commits(dbs, last_commit, source, merge_hashes):
     prev_commit = last_commit
     skipped_merges = False
     for merge_hash in merge_hashes:
+        # Check for subtree merge (e.g. dts/upstream update)
+        result = _check_subtree_merge(merge_hash)
+        if result is not None:
+            name, tag = result
+            if name:
+                return NextCommitsInfo([], True, merge_hash,
+                                       (name, tag))
+            # Unknown subtree path - skip past it
+            prev_commit = merge_hash
+            skipped_merges = True
+            continue
+
         # Check for mega-merge (contains sub-merges)
         sub_merges = detect_sub_merges(merge_hash)
         if sub_merges:
@@ -1473,54 +1515,175 @@  def push_mr(args, branch_name, title, description):
     return bool(mr_url)
 
 
-def _prepare_get_commits(dbs, source):
-    """Get the next commits to apply, handling skips.
+def _subtree_run_update(name, tag):
+    """Run update-subtree.sh to pull a subtree update.
+
+    On failure, aborts any in-progress merge to clean up the working
+    tree.
+
+    Returns:
+        int: 0 on success, 1 on failure
+    """
+    try:
+        result = command.run(
+            './tools/update-subtree.sh', 'pull', name, tag,
+            capture=True, raise_on_error=False)
+        if result.stdout:
+            tout.info(result.stdout)
+        if result.return_code:
+            tout.error(f'Subtree update failed (exit code '
+                        f'{result.return_code})')
+            if result.stderr:
+                tout.error(result.stderr)
+            try:
+                run_git(['merge', '--abort'])
+            except Exception:  # pylint: disable=broad-except
+                pass
+            return 1
+    except Exception as exc:  # pylint: disable=broad-except
+        tout.error(f'Subtree update failed: {exc}')
+        return 1
+
+    return 0
+
 
-    Fetches the next batch of commits from the source.
+def _subtree_record(dbs, source, squash_hash, merge_hash):
+    """Mark subtree commits as applied and advance the source position."""
+    source_id = dbs.source_get_id(source)
+    for commit_hash in [squash_hash, merge_hash]:
+        if not dbs.commit_get(commit_hash):
+            info = run_git(['log', '-1', '--format=%s|%an', commit_hash])
+            parts = info.split('|', 1)
+            subj = parts[0]
+            auth = parts[1] if len(parts) > 1 else ''
+            dbs.commit_add(commit_hash, source_id, subj, auth,
+                           status='applied')
+    dbs.commit()
+
+    dbs.source_set(source, merge_hash)
+    dbs.commit()
+    tout.info(f"Advanced source '{source}' past subtree merge "
+              f'{merge_hash[:12]}')
+
+
+def apply_subtree_update(dbs, source, name, tag, merge_hash, args):  # pylint: disable=too-many-arguments
+    """Apply a subtree update on the target branch
+
+    Runs tools/update-subtree.sh to pull the subtree update, then
+    optionally pushes the result to the remote target branch.
 
     Args:
         dbs (Database): Database instance
         source (str): Source branch name
+        name (str): Subtree name ('dts', 'mbedtls', 'lwip')
+        tag (str): Tag to pull (e.g. 'v6.15-dts')
+        merge_hash (str): Hash of the subtree merge commit to advance past
+        args (Namespace): Parsed arguments with 'push', 'remote', 'target'
+
+    Returns:
+        int: 0 on success, 1 on failure
+    """
+    target = args.target
+
+    tout.info(f'Applying subtree update: {name} -> {tag}')
+
+    # Get the squash commit (second parent of the merge)
+    parents = run_git(['rev-parse', f'{merge_hash}^@']).split()
+    if len(parents) < 2:
+        tout.error(f'Subtree merge {merge_hash[:12]} has no second parent')
+        return 1
+    squash_hash = parents[1]
+
+    try:
+        run_git(['checkout', target])
+    except Exception:  # pylint: disable=broad-except
+        tout.error(f'Could not checkout {target}')
+        return 1
+
+    ret = _subtree_run_update(name, tag)
+    if ret:
+        return ret
+
+    if args.push:
+        try:
+            gitlab_api.push_branch(args.remote, target, skip_ci=True)
+            tout.info(f'Pushed {target} to {args.remote}')
+        except Exception as exc:  # pylint: disable=broad-except
+            tout.error(f'Failed to push {target}: {exc}')
+            return 1
+
+    _subtree_record(dbs, source, squash_hash, merge_hash)
+
+    return 0
+
+
+def _prepare_get_commits(dbs, source, args):
+    """Get the next commits to apply, handling subtrees and skips.
+
+    Fetch the next batch of commits from the source. If a subtree
+    update is encountered, apply it and retry. If all commits in a
+    merge are already processed, advance the source and retry.
+
+    Args:
+        dbs (Database): Database instance
+        source (str): Source branch name
+        args (Namespace): Parsed arguments (needed for subtree updates)
 
     Returns:
         tuple: (NextCommitsInfo, return_code) where return_code is None
             on success, or an int (0 or 1) if there is nothing to do
     """
-    info, err = get_next_commits(dbs, source)
-    if err:
-        tout.error(err)
-        return None, 1
+    while True:
+        info, err = get_next_commits(dbs, source)
+        if err:
+            tout.error(err)
+            return None, 1
+
+        if info.subtree_update:
+            name, tag = info.subtree_update
+            tout.info(f'Subtree update needed: {name} -> {tag}')
+            if not args:
+                tout.error('Cannot apply subtree update without args')
+                return None, 1
+            ret = apply_subtree_update(dbs, source, name, tag,
+                                       info.advance_to, args)
+            if ret:
+                return None, ret
+            continue
 
-    if not info.commits:
-        if info.advance_to:
-            dbs.source_set(source, info.advance_to)
-            dbs.commit()
-            tout.info(f"Advanced source '{source}' to "
-                      f'{info.advance_to[:12]}')
-        else:
+        if not info.commits:
+            if info.advance_to:
+                dbs.source_set(source, info.advance_to)
+                dbs.commit()
+                tout.info(f"Advanced source '{source}' to "
+                          f'{info.advance_to[:12]}')
+                continue
             tout.info('No new commits to cherry-pick')
-        return None, 0
+            return None, 0
 
-    return info, None
+        return info, None
 
 
-def prepare_apply(dbs, source, branch):
+def prepare_apply(dbs, source, branch, args=None):
     """Prepare for applying commits from a source branch
 
-    Gets the next commits, sets up the branch name, and prints info about
-    what will be applied.
+    Get the next commits, set up the branch name and prints info about
+    what will be applied. When a subtree update is encountered, apply it
+    automatically and retry.
 
     Args:
         dbs (Database): Database instance
         source (str): Source branch name
         branch (str): Branch name to use, or None to auto-generate
+        args (Namespace): Parsed arguments with 'push', 'remote', 'target'
+            (needed for subtree updates)
 
     Returns:
         tuple: (ApplyInfo, return_code) where ApplyInfo is set if there are
             commits to apply, or None with return_code indicating the result
             (0 for no commits, 1 for error)
     """
-    info, ret = _prepare_get_commits(dbs, source)
+    info, ret = _prepare_get_commits(dbs, source, args)
     if ret is not None:
         return None, ret
 
@@ -1738,7 +1901,7 @@  def do_apply(args, dbs):
         int: 0 on success, 1 on failure
     """
     source = args.source
-    info, ret = prepare_apply(dbs, source, args.branch)
+    info, ret = prepare_apply(dbs, source, args.branch, args)
     if not info:
         return ret
 
diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py
index a2c19a1159e..de6bce40614 100644
--- a/tools/pickman/ftest.py
+++ b/tools/pickman/ftest.py
@@ -2706,6 +2706,7 @@  class TestPrepareApply(unittest.TestCase):
 
             self.assertIsNotNone(info)
             self.assertEqual(ret, 0)
+            # Check that branch -D was called
             self.assertTrue(
                 any('branch' in c and '-D' in c for c in git_cmds))
             dbs.close()
@@ -3463,24 +3464,28 @@  class TestGetNextCommitsMegaMerge(unittest.TestCase):
                         stdout='mega|mega1|A|Merge branch next|'
                                'base second_parent\n')
                 if call_count[0] == 2:
+                    # Subtree check: log -1 --format=%s
+                    return command.CommandResult(
+                        stdout='Merge branch next')
+                if call_count[0] == 3:
                     # detect_sub_merges: rev-parse ^@
                     return command.CommandResult(
                         stdout='base\nsecond_parent\n')
-                if call_count[0] == 3:
+                if call_count[0] == 4:
                     # detect_sub_merges: log --merges (found sub-merges)
                     return command.CommandResult(stdout='sub1\n')
-                if call_count[0] == 4:
+                if call_count[0] == 5:
                     # decompose: rev-parse ^@ for mega-merge
                     return command.CommandResult(
                         stdout='base\nsecond_parent\n')
-                if call_count[0] == 5:
+                if call_count[0] == 6:
                     # decompose: log -1 for mega-merge info
                     return command.CommandResult(
                         stdout='Mega merge|Author\n')
-                if call_count[0] == 6:
+                if call_count[0] == 7:
                     # decompose: mainline commits (empty)
                     return command.CommandResult(stdout='')
-                if call_count[0] == 7:
+                if call_count[0] == 8:
                     # decompose: sub-merge 1 commits
                     return command.CommandResult(
                         stdout='aaa|aaa1|A|Sub commit|base\n')
@@ -3522,31 +3527,35 @@  class TestGetNextCommitsMegaMerge(unittest.TestCase):
                         stdout='mega|mega1|A|Merge branch next|'
                                'base second_parent\n')
                 if call_count[0] == 2:
+                    # Subtree check: log -1 --format=%s
+                    return command.CommandResult(
+                        stdout='Merge branch next')
+                if call_count[0] == 3:
                     # detect_sub_merges: rev-parse ^@
                     return command.CommandResult(
                         stdout='base\nsecond_parent\n')
-                if call_count[0] == 3:
+                if call_count[0] == 4:
                     # detect_sub_merges: log --merges
                     return command.CommandResult(stdout='sub1\n')
-                if call_count[0] == 4:
+                if call_count[0] == 5:
                     # decompose: rev-parse ^@
                     return command.CommandResult(
                         stdout='base\nsecond_parent\n')
-                if call_count[0] == 5:
+                if call_count[0] == 6:
                     # decompose: log -1 for mega-merge info
                     return command.CommandResult(
                         stdout='Mega merge|Author\n')
-                if call_count[0] == 6:
+                if call_count[0] == 7:
                     # decompose: mainline (empty)
                     return command.CommandResult(stdout='')
-                if call_count[0] == 7:
+                if call_count[0] == 8:
                     # decompose: sub-merge 1 (in DB)
                     return command.CommandResult(
                         stdout='aaa|aaa1|A|Sub commit|base\n')
-                if call_count[0] == 8:
+                if call_count[0] == 9:
                     # decompose: remainder (empty)
                     return command.CommandResult(stdout='')
-                if call_count[0] == 9:
+                if call_count[0] == 10:
                     # Remaining commits after mega-merge (empty)
                     return command.CommandResult(stdout='')
                 return command.CommandResult(stdout='')
@@ -3580,13 +3589,17 @@  class TestGetNextCommitsMegaMerge(unittest.TestCase):
                         stdout='merge1|m1|A|Merge branch feat|'
                                'base side1\n')
                 if call_count[0] == 2:
+                    # Subtree check: log -1 --format=%s
+                    return command.CommandResult(
+                        stdout='Merge branch feat')
+                if call_count[0] == 3:
                     # detect_sub_merges: rev-parse ^@
                     return command.CommandResult(
                         stdout='base\nside1\n')
-                if call_count[0] == 3:
+                if call_count[0] == 4:
                     # detect_sub_merges: log --merges (no sub-merges)
                     return command.CommandResult(stdout='')
-                if call_count[0] == 4:
+                if call_count[0] == 5:
                     # Commits for this merge
                     return command.CommandResult(
                         stdout='aaa|aaa1|A|Commit 1|base\n'
@@ -3606,6 +3619,605 @@  class TestGetNextCommitsMegaMerge(unittest.TestCase):
             dbs.close()
 
 
+class TestSubtreeMergeDetection(unittest.TestCase):
+    """Tests for subtree merge detection in find_unprocessed_commits."""
+
+    def setUp(self):
+        """Set up test fixtures."""
+        fd, self.db_path = tempfile.mkstemp(suffix='.db')
+        os.close(fd)
+        os.unlink(self.db_path)
+        database.Database.instances.clear()
+
+    def tearDown(self):
+        """Clean up test fixtures."""
+        if os.path.exists(self.db_path):
+            os.unlink(self.db_path)
+        database.Database.instances.clear()
+        command.TEST_RESULT = None
+
+    def test_detects_dts_subtree_merge(self):
+        """Test find_unprocessed_commits detects dts/upstream subtree merge."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            command.TEST_RESULT = command.CommandResult(
+                stdout="Subtree merge tag 'v6.15-dts' of "
+                       "https://example.com/dts.git into dts/upstream")
+
+            info = control.find_unprocessed_commits(
+                dbs, 'base', 'us/next', ['merge1'])
+
+            self.assertTrue(info.merge_found)
+            self.assertEqual(info.commits, [])
+            self.assertEqual(info.advance_to, 'merge1')
+            self.assertEqual(info.subtree_update, ('dts', 'v6.15-dts'))
+            dbs.close()
+
+    def test_detects_mbedtls_subtree_merge(self):
+        """Test find_unprocessed_commits detects mbedtls subtree merge."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            command.TEST_RESULT = command.CommandResult(
+                stdout="Subtree merge tag 'v3.6.2' of "
+                       "https://example.com/mbedtls.git into "
+                       "lib/mbedtls/external/mbedtls")
+
+            info = control.find_unprocessed_commits(
+                dbs, 'base', 'us/next', ['merge1'])
+
+            self.assertEqual(info.subtree_update,
+                             ('mbedtls', 'v3.6.2'))
+            dbs.close()
+
+    def test_detects_lwip_subtree_merge(self):
+        """Test find_unprocessed_commits detects lwip subtree merge."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            command.TEST_RESULT = command.CommandResult(
+                stdout="Subtree merge tag 'STABLE-2_2_0' of "
+                       "https://example.com/lwip.git into lib/lwip/lwip")
+
+            info = control.find_unprocessed_commits(
+                dbs, 'base', 'us/next', ['merge1'])
+
+            self.assertEqual(info.subtree_update,
+                             ('lwip', 'STABLE-2_2_0'))
+            dbs.close()
+
+    def test_skips_unknown_subtree_path(self):
+        """Test find_unprocessed_commits skips unknown subtree paths."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            call_count = [0]
+
+            def mock_git(pipe_list):  # pylint: disable=unused-argument
+                call_count[0] += 1
+                if call_count[0] == 1:
+                    # Subject for merge1: unknown subtree
+                    return command.CommandResult(
+                        stdout="Subtree merge tag 'v1.0' of "
+                               "https://x.com/x.git into lib/unknown")
+                if call_count[0] == 2:
+                    # Subject for merge2: not a subtree merge
+                    return command.CommandResult(
+                        stdout='Normal merge commit')
+                if call_count[0] == 3:
+                    # detect_sub_merges: rev-parse ^@
+                    return command.CommandResult(
+                        stdout='merge1\nside1\n')
+                if call_count[0] == 4:
+                    # detect_sub_merges: log --merges (no sub-merges)
+                    return command.CommandResult(stdout='')
+                if call_count[0] == 5:
+                    # Commits for merge2
+                    return command.CommandResult(
+                        stdout='aaa|aaa1|A|Commit 1|merge1\n')
+                return command.CommandResult(stdout='')
+
+            command.TEST_RESULT = mock_git
+
+            info = control.find_unprocessed_commits(
+                dbs, 'base', 'us/next', ['merge1', 'merge2'])
+
+            # Should have skipped merge1 and found commits in merge2
+            self.assertIsNone(info.subtree_update)
+            self.assertTrue(info.merge_found)
+            self.assertEqual(len(info.commits), 1)
+            self.assertEqual(info.commits[0].chash, 'aaa1')
+            dbs.close()
+
+    def test_subtree_merge_via_get_next_commits(self):
+        """Test get_next_commits returns subtree_update for subtree merge."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            call_count = [0]
+
+            def mock_git(pipe_list):  # pylint: disable=unused-argument
+                call_count[0] += 1
+                if call_count[0] == 1:
+                    # First-parent log shows one merge
+                    return command.CommandResult(
+                        stdout='merge1|m1|A|Subtree merge tag '
+                               "'v6.15-dts' of https://x.com/dts.git"
+                               ' into dts/upstream|base second\n')
+                if call_count[0] == 2:
+                    # find_unprocessed: log -1 --format=%s for merge1
+                    return command.CommandResult(
+                        stdout="Subtree merge tag 'v6.15-dts' of "
+                               "https://x.com/dts.git into "
+                               "dts/upstream")
+                return command.CommandResult(stdout='')
+
+            command.TEST_RESULT = mock_git
+
+            info, err = control.get_next_commits(dbs, 'us/next')
+
+            self.assertIsNone(err)
+            self.assertEqual(info.subtree_update, ('dts', 'v6.15-dts'))
+            self.assertEqual(info.advance_to, 'merge1')
+            self.assertEqual(info.commits, [])
+            dbs.close()
+
+    def test_non_subtree_merge_has_no_subtree_update(self):
+        """Test normal merges have subtree_update=None."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            call_count = [0]
+
+            def mock_git(pipe_list):  # pylint: disable=unused-argument
+                call_count[0] += 1
+                if call_count[0] == 1:
+                    # Subject: not a subtree merge
+                    return command.CommandResult(
+                        stdout='Merge branch some-feature')
+                if call_count[0] == 2:
+                    # detect_sub_merges: rev-parse ^@
+                    return command.CommandResult(
+                        stdout='base\nside1\n')
+                if call_count[0] == 3:
+                    # detect_sub_merges: log --merges (no sub-merges)
+                    return command.CommandResult(stdout='')
+                if call_count[0] == 4:
+                    # Commits in merge
+                    return command.CommandResult(
+                        stdout='aaa|aaa1|A|Commit 1|base\n')
+                return command.CommandResult(stdout='')
+
+            command.TEST_RESULT = mock_git
+
+            info = control.find_unprocessed_commits(
+                dbs, 'base', 'us/next', ['merge1'])
+
+            self.assertIsNone(info.subtree_update)
+            self.assertTrue(info.merge_found)
+            self.assertEqual(len(info.commits), 1)
+            dbs.close()
+
+
+class TestApplySubtreeUpdate(unittest.TestCase):
+    """Tests for apply_subtree_update function."""
+
+    def setUp(self):
+        """Set up test fixtures."""
+        fd, self.db_path = tempfile.mkstemp(suffix='.db')
+        os.close(fd)
+        os.unlink(self.db_path)
+        database.Database.instances.clear()
+
+    def tearDown(self):
+        """Clean up test fixtures."""
+        if os.path.exists(self.db_path):
+            os.unlink(self.db_path)
+        database.Database.instances.clear()
+        command.TEST_RESULT = None
+
+    def test_apply_success(self):
+        """Test apply_subtree_update succeeds and updates database."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+
+            def run_git_handler(git_args):
+                if 'rev-parse' in git_args:
+                    # Parents of merge: first_parent squash_hash
+                    return 'first_parent\nsquash_hash'
+                if 'checkout' in git_args:
+                    return ''
+                if '--format=%s|%an' in git_args:
+                    if 'squash_hash' in git_args:
+                        return "Squashed 'dts/upstream/' changes|Author"
+                    return "Subtree merge tag 'v6.15-dts'|Author"
+                return ''
+
+            mock_result = command.CommandResult(
+                'Subtree updated', '', '', 0)
+            with mock.patch.object(control, 'run_git',
+                                   side_effect=run_git_handler):
+                with mock.patch.object(
+                        control.command, 'run',
+                        return_value=mock_result):
+                    ret = control.apply_subtree_update(
+                        dbs, 'us/next', 'dts', 'v6.15-dts',
+                        'merge_hash', args)
+
+            self.assertEqual(ret, 0)
+
+            # Source should be advanced past the merge
+            self.assertEqual(dbs.source_get('us/next'), 'merge_hash')
+
+            # Both commits should be in the database
+            squash = dbs.commit_get('squash_hash')
+            self.assertIsNotNone(squash)
+            merge = dbs.commit_get('merge_hash')
+            self.assertIsNotNone(merge)
+            dbs.close()
+
+    def test_apply_with_push(self):
+        """Test apply_subtree_update pushes when args.push is True."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=True, remote='ci',
+                                      target='master')
+
+            def run_git_handler(git_args):
+                if 'rev-parse' in git_args:
+                    return 'first_parent\nsquash_hash'
+                if 'checkout' in git_args:
+                    return ''
+                if '--format=%s|%an' in git_args:
+                    return 'Commit subject|Author'
+                return ''
+
+            pushed = [False]
+
+            def mock_push(remote, branch, skip_ci=False):
+                pushed[0] = True
+                return True
+
+            mock_result = command.CommandResult('ok', '', '', 0)
+            with mock.patch.object(control, 'run_git',
+                                   side_effect=run_git_handler):
+                with mock.patch.object(
+                        control.command, 'run',
+                        return_value=mock_result):
+                    with mock.patch.object(
+                            control.gitlab_api, 'push_branch',
+                            side_effect=mock_push):
+                        ret = control.apply_subtree_update(
+                            dbs, 'us/next', 'dts', 'v6.15-dts',
+                            'merge_hash', args)
+
+            self.assertEqual(ret, 0)
+            self.assertTrue(pushed[0])
+            dbs.close()
+
+    def test_apply_checkout_failure(self):
+        """Test apply_subtree_update returns 1 on checkout failure."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+
+            def run_git_handler(git_args):
+                if 'rev-parse' in git_args:
+                    return 'first_parent\nsquash_hash'
+                if 'checkout' in git_args:
+                    raise Exception('checkout failed')
+                return ''
+
+            with mock.patch.object(control, 'run_git',
+                                   side_effect=run_git_handler):
+                ret = control.apply_subtree_update(
+                    dbs, 'us/next', 'dts', 'v6.15-dts',
+                    'merge_hash', args)
+
+            self.assertEqual(ret, 1)
+            # Source should not be advanced
+            self.assertEqual(dbs.source_get('us/next'), 'base')
+            dbs.close()
+
+    def test_apply_no_second_parent(self):
+        """Test apply_subtree_update returns 1 when merge has no 2nd parent."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+
+            # Only one parent
+            with mock.patch.object(control, 'run_git',
+                                   return_value='single_parent'):
+                ret = control.apply_subtree_update(
+                    dbs, 'us/next', 'dts', 'v6.15-dts',
+                    'merge_hash', args)
+
+            self.assertEqual(ret, 1)
+            dbs.close()
+
+    def test_apply_script_exception(self):
+        """Test apply_subtree_update returns 1 on script exception."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+
+            def run_git_handler(git_args):
+                if 'rev-parse' in git_args:
+                    return 'first_parent\nsquash_hash'
+                if 'checkout' in git_args:
+                    return ''
+                return ''
+
+            with mock.patch.object(control, 'run_git',
+                                   side_effect=run_git_handler):
+                with mock.patch.object(
+                        control.command, 'run',
+                        side_effect=Exception('script failed')):
+                    ret = control.apply_subtree_update(
+                        dbs, 'us/next', 'dts', 'v6.15-dts',
+                        'merge_hash', args)
+
+            self.assertEqual(ret, 1)
+            # Source should not be advanced
+            self.assertEqual(dbs.source_get('us/next'), 'base')
+            dbs.close()
+
+    def test_apply_merge_conflict(self):
+        """Test apply_subtree_update aborts merge on non-zero exit."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+
+            merge_aborted = [False]
+
+            def run_git_handler(git_args):
+                if 'rev-parse' in git_args:
+                    return 'first_parent\nsquash_hash'
+                if 'checkout' in git_args:
+                    return ''
+                if 'merge' in git_args and '--abort' in git_args:
+                    merge_aborted[0] = True
+                    return ''
+                return ''
+
+            mock_result = command.CommandResult(
+                '', 'CONFLICT (content): Merge conflict', '', 1)
+            with mock.patch.object(control, 'run_git',
+                                   side_effect=run_git_handler):
+                with mock.patch.object(
+                        control.command, 'run',
+                        return_value=mock_result):
+                    ret = control.apply_subtree_update(
+                        dbs, 'us/next', 'dts', 'v6.15-dts',
+                        'merge_hash', args)
+
+            self.assertEqual(ret, 1)
+            self.assertTrue(merge_aborted[0])
+            # Source should not be advanced
+            self.assertEqual(dbs.source_get('us/next'), 'base')
+            dbs.close()
+
+
+class TestPrepareApplySubtreeUpdate(unittest.TestCase):
+    """Tests for prepare_apply handling of subtree updates."""
+
+    def setUp(self):
+        """Set up test fixtures."""
+        fd, self.db_path = tempfile.mkstemp(suffix='.db')
+        os.close(fd)
+        os.unlink(self.db_path)
+        self.old_db_fname = control.DB_FNAME
+        control.DB_FNAME = self.db_path
+        database.Database.instances.clear()
+
+    def tearDown(self):
+        """Clean up test fixtures."""
+        control.DB_FNAME = self.old_db_fname
+        if os.path.exists(self.db_path):
+            os.unlink(self.db_path)
+        database.Database.instances.clear()
+        command.TEST_RESULT = None
+
+    def test_prepare_apply_calls_subtree_update(self):
+        """Test prepare_apply applies subtree update and retries."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+            subtree_info = control.NextCommitsInfo(
+                [], True, 'merge1', ('dts', 'v6.15-dts'))
+            normal_info = control.NextCommitsInfo([], False, None)
+
+            call_count = [0]
+
+            def mock_get_next(dbs_arg, source):
+                call_count[0] += 1
+                if call_count[0] == 1:
+                    return subtree_info, None
+                return normal_info, None
+
+            with mock.patch.object(control, 'get_next_commits',
+                                   side_effect=mock_get_next):
+                with mock.patch.object(
+                        control, 'apply_subtree_update',
+                        return_value=0) as mock_apply:
+                    info, ret = control.prepare_apply(
+                        dbs, 'us/next', None, args)
+
+            # Should have called apply_subtree_update
+            mock_apply.assert_called_once_with(
+                dbs, 'us/next', 'dts', 'v6.15-dts', 'merge1', args)
+            # No commits after retry, so returns None/0
+            self.assertIsNone(info)
+            self.assertEqual(ret, 0)
+            dbs.close()
+
+    def test_prepare_apply_subtree_update_failure(self):
+        """Test prepare_apply returns error when subtree update fails."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            args = argparse.Namespace(push=False, remote='ci',
+                                      target='master')
+            subtree_info = control.NextCommitsInfo(
+                [], True, 'merge1', ('dts', 'v6.15-dts'))
+
+            with mock.patch.object(control, 'get_next_commits',
+                                   return_value=(subtree_info, None)):
+                with mock.patch.object(
+                        control, 'apply_subtree_update',
+                        return_value=1):
+                    info, ret = control.prepare_apply(
+                        dbs, 'us/next', None, args)
+
+            self.assertIsNone(info)
+            self.assertEqual(ret, 1)
+            dbs.close()
+
+    def test_prepare_apply_subtree_without_args(self):
+        """Test prepare_apply returns error when subtree needs args=None."""
+        with terminal.capture():
+            dbs = database.Database(self.db_path)
+            dbs.start()
+            dbs.source_set('us/next', 'base')
+            dbs.commit()
+
+            subtree_info = control.NextCommitsInfo(
+                [], True, 'merge1', ('dts', 'v6.15-dts'))
+
+            with mock.patch.object(control, 'get_next_commits',
+                                   return_value=(subtree_info, None)):
+                info, ret = control.prepare_apply(
+                    dbs, 'us/next', None)
+
+            self.assertIsNone(info)
+            self.assertEqual(ret, 1)
+            dbs.close()
+
+
+class TestNextCommitsInfoDefault(unittest.TestCase):
+    """Tests for NextCommitsInfo subtree_update default value."""
+
+    def test_default_subtree_update_is_none(self):
+        """Test NextCommitsInfo defaults subtree_update to None."""
+        info = control.NextCommitsInfo([], False, None)
+        self.assertIsNone(info.subtree_update)
+
+    def test_explicit_subtree_update(self):
+        """Test NextCommitsInfo accepts explicit subtree_update."""
+        info = control.NextCommitsInfo([], True, 'hash1',
+                                       ('dts', 'v6.15-dts'))
+        self.assertEqual(info.subtree_update, ('dts', 'v6.15-dts'))
+
+    def test_explicit_none_subtree_update(self):
+        """Test NextCommitsInfo accepts explicit None subtree_update."""
+        info = control.NextCommitsInfo([], False, None, None)
+        self.assertIsNone(info.subtree_update)
+
+
+class TestSubtreeMergeRegex(unittest.TestCase):
+    """Tests for RE_SUBTREE_MERGE regex pattern."""
+
+    def test_matches_dts_merge(self):
+        """Test regex matches dts subtree merge subject."""
+        subject = ("Subtree merge tag 'v6.15-dts' of "
+                   "https://git.kernel.org/pub/scm/linux/kernel/git/"
+                   "devicetree/devicetree-rebasing.git into dts/upstream")
+        match = control.RE_SUBTREE_MERGE.match(subject)
+        self.assertIsNotNone(match)
+        self.assertEqual(match.group(1), 'v6.15-dts')
+        self.assertEqual(match.group(2), 'dts/upstream')
+
+    def test_matches_mbedtls_merge(self):
+        """Test regex matches mbedtls subtree merge subject."""
+        subject = ("Subtree merge tag 'v3.6.2' of "
+                   "https://github.com/Mbed-TLS/mbedtls.git into "
+                   "lib/mbedtls/external/mbedtls")
+        match = control.RE_SUBTREE_MERGE.match(subject)
+        self.assertIsNotNone(match)
+        self.assertEqual(match.group(1), 'v3.6.2')
+        self.assertEqual(match.group(2), 'lib/mbedtls/external/mbedtls')
+
+    def test_matches_lwip_merge(self):
+        """Test regex matches lwip subtree merge subject."""
+        subject = ("Subtree merge tag 'STABLE-2_2_0' of "
+                   "https://git.savannah.gnu.org/git/lwip.git into "
+                   "lib/lwip/lwip")
+        match = control.RE_SUBTREE_MERGE.match(subject)
+        self.assertIsNotNone(match)
+        self.assertEqual(match.group(1), 'STABLE-2_2_0')
+        self.assertEqual(match.group(2), 'lib/lwip/lwip')
+
+    def test_no_match_normal_merge(self):
+        """Test regex does not match normal merge subjects."""
+        subject = "Merge branch 'feature-xyz' into main"
+        match = control.RE_SUBTREE_MERGE.match(subject)
+        self.assertIsNone(match)
+
+    def test_no_match_squash_commit(self):
+        """Test regex does not match subtree squash commits."""
+        subject = ("Squashed 'dts/upstream/' changes from "
+                   "v6.14-dts..v6.15-dts")
+        match = control.RE_SUBTREE_MERGE.match(subject)
+        self.assertIsNone(match)
+
+
 class TestDoCommitSourceResolveError(unittest.TestCase):
     """Tests for do_commit_source error handling."""