@@ -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
------------
@@ -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
@@ -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."""