From patchwork Sun Feb 22 15:42:48 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 1929 Return-Path: X-Original-To: u-boot-concept@u-boot.org Delivered-To: u-boot-concept@u-boot.org DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775048; bh=5twfDMogAzsMZ4MVyD9NAsNTpxo+y0ekLrfJ2H6FDNY=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=wNncA43xGyIM+m2XeD38MTHFxq6K8tQEeWLGVnzmRtjyLO5haYhIzmqNUA/jO63rS 3Huek9AqIzoLoiAWofngiUnTJx2cG+MJMzT11NtBiaWZ2/C/Mx/XEtVzDe01HH9MAS 5IGAJRusfriw8unwYMXJhWny/lBnwzW0MLYocNdYiGp3dbI5+Xza10XO1UpSGBraob S9CLxZY9wh6uuR+yfpqRhgKo0RJJnlZIjXX/OxOMGkCoo5REVx/SHbOoGx9drTEeD4 Rb1BfalfB9vSFqc/3a9+R51mFIRgv8dszNm0u6ZPrw7yi9zKsVrdDJ8VEjJHa6uiK6 wAZDsJLJ1hxvQ== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id AB28569D48 for ; Sun, 22 Feb 2026 08:44:08 -0700 (MST) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10024) with ESMTP id 3LvZP_Y5cP6g for ; Sun, 22 Feb 2026 08:44:08 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775048; bh=5twfDMogAzsMZ4MVyD9NAsNTpxo+y0ekLrfJ2H6FDNY=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=wNncA43xGyIM+m2XeD38MTHFxq6K8tQEeWLGVnzmRtjyLO5haYhIzmqNUA/jO63rS 3Huek9AqIzoLoiAWofngiUnTJx2cG+MJMzT11NtBiaWZ2/C/Mx/XEtVzDe01HH9MAS 5IGAJRusfriw8unwYMXJhWny/lBnwzW0MLYocNdYiGp3dbI5+Xza10XO1UpSGBraob S9CLxZY9wh6uuR+yfpqRhgKo0RJJnlZIjXX/OxOMGkCoo5REVx/SHbOoGx9drTEeD4 Rb1BfalfB9vSFqc/3a9+R51mFIRgv8dszNm0u6ZPrw7yi9zKsVrdDJ8VEjJHa6uiK6 wAZDsJLJ1hxvQ== Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 7D71C69C5E for ; Sun, 22 Feb 2026 08:44:08 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775044; bh=IY/DU0xgenh8ZE2Uj2HQcYdhL5jNSqjuTII4Icexj6c=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=s7wz35uXwKMxbvnP4gpv8w42lxtM13XDGg/WlAgVK3KrBu5Xw+Ofj2XudxD+5fH35 DbKT5jZLjsYwtSJpF3KggYeH1qENpR6KZRWxHRyxTRTscH0KWRxEbkUc7T9Ek4cImJ 5h6k8gEkv6mSgpXcayYAUyKHftBSgXq5HUmjGfRkEUdBbagGqUSZS526LlFG/Aga9H I8kWIqdEVwWJGu9ZqbvTvSMnBvv1XIOiPzoH9HALKk7Xx8G0IeQCthp5tjHyNb0rt3 AhHHchCIvovWPT0DaxfVSK/amU4FL8atN6JMlqzRjme7N9CvEFUbuIBzsWSa0uZvm0 CrIFQ9mE8F06Q== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 0D5CE69C5E; Sun, 22 Feb 2026 08:44:04 -0700 (MST) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10026) with ESMTP id fFhArok07lJz; Sun, 22 Feb 2026 08:44:03 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775036; bh=GWhejId7rd3J+nDdgs+Yh/986CacSSEXDRMF6vbvarU=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=E/jOi9QaTN2hVmEnJKNjGS4hRk71lx6JmYh9m528+eFFH8LjfaLnG1XFQFgTvK2jp L+DetPh1okqwiA1RvVfczBqUNwqD902LSYMPt8iZ7WUPuLH9ri9CxZOIk0yTKDc4Ti xOuA6WyjRoxkEhrqVLe1cWLaqBecyrn2MnD+j4AHjg2mh5crxksiViiYnWeDzp0Buf FtESHtBfDtJ3adj88Fqs4qVgY8KMJ5uqJ6pXsIBVJcZPve0X7xbPduxIiPz8ci19T6 V4EnwWSm3QWOdvXs8whpd4eKPp38pDtYlg0A2rT4BgshcqSPix5pT5gkDhXyd/hSL9 PDUzBJT1Um88g== Received: from u-boot.org (unknown [73.34.74.121]) by mail.u-boot.org (Postfix) with ESMTPSA id EDBAD69D36; Sun, 22 Feb 2026 08:43:55 -0700 (MST) From: Simon Glass To: U-Boot Concept Date: Sun, 22 Feb 2026 08:42:48 -0700 Message-ID: <20260222154303.2851319-9-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260222154303.2851319-1-sjg@u-boot.org> References: <20260222154303.2851319-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: ZBWOWW5RXHHKSP2J4PYUBYIJKFDPOZGU X-Message-ID-Hash: ZBWOWW5RXHHKSP2J4PYUBYIJKFDPOZGU X-MailFrom: sjg@u-boot.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Simon Glass , "Claude Opus 4 . 6" X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 08/16] pickman: Handle dts/upstream subtree merges automatically List-Id: Discussion and patches related to U-Boot Concept Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Simon Glass 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 . Add subtree-merge detection to find_unprocessed_commits(). When a merge subject matches "Subtree merge tag '' of ... into ", 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 Signed-off-by: Simon Glass --- tools/pickman/README.rst | 33 ++ tools/pickman/control.py | 207 +++++++++++-- tools/pickman/ftest.py | 640 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 844 insertions(+), 36 deletions(-) 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 ..`` (the actual file + changes) +2. ``Subtree merge tag '' of 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 '' of ... into ``. When a subtree merge is found, +pickman: + +1. Checks out the target branch (e.g. ``ci/master``) +2. Runs ``./tools/update-subtree.sh pull `` 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."""