From patchwork Thu Mar 5 14:54:45 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 1971 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=1772722525; bh=sJWoV1d+Izp92jrH6J8pLbjhIM/pWYAOq48R3Bwu2Zk=; 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=rBknQfJQpOZVNa1GrhzIDqe9eOy6jmRiLWJmoy0e17bMI9+42BE4Wp2q5m58CPiEs p6miV9xNsXQ6MB+GqluZyZVjLklnlq7oIgCgfWbAsZUwq2NSILoxd+CN4qYOjjDBKA bfmpAct+ImsFdKzO+hEv9UYlskm1zd28gEgFHwLIOcKCVrOlXKtNxr1TbXycPSmqaB 4CLpkGDrV4GPClQGVFsCHhJg6R4F267gHhcY8koUYMJG7Rqay3m//+lfarkYp1UFcH KNfhF1X6FtOc54LoTo0A7+fJDO2YfMU85KF0vt2MHKRLDGYIyX7mc4OLPbtoakkjLz cTRfbFx8yY3OQ== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 9079269D92 for ; Thu, 5 Mar 2026 07:55:25 -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 x5mKvWu1Ov4c for ; Thu, 5 Mar 2026 07:55:25 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1772722525; bh=sJWoV1d+Izp92jrH6J8pLbjhIM/pWYAOq48R3Bwu2Zk=; 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=rBknQfJQpOZVNa1GrhzIDqe9eOy6jmRiLWJmoy0e17bMI9+42BE4Wp2q5m58CPiEs p6miV9xNsXQ6MB+GqluZyZVjLklnlq7oIgCgfWbAsZUwq2NSILoxd+CN4qYOjjDBKA bfmpAct+ImsFdKzO+hEv9UYlskm1zd28gEgFHwLIOcKCVrOlXKtNxr1TbXycPSmqaB 4CLpkGDrV4GPClQGVFsCHhJg6R4F267gHhcY8koUYMJG7Rqay3m//+lfarkYp1UFcH KNfhF1X6FtOc54LoTo0A7+fJDO2YfMU85KF0vt2MHKRLDGYIyX7mc4OLPbtoakkjLz cTRfbFx8yY3OQ== Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 7F16C69F0F for ; Thu, 5 Mar 2026 07:55:25 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1772722523; bh=6YXaH3iZ37uBM/cLYaKCESPuT/I/9qEzhWZH02NqPY0=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=HJfML1jZnWHzjIQfJHVpgi40WZEraoFq3qvU7PIs2/DYR2YiFQQoLRaJKcH4Tjel/ 5+PDgdajd3JFUXcHCgREUTesPGkPpxcgOZM8sf3VogtUdqDBjeGpt793uOkRK80wk2 jMcMStz+tS9AovP4Flw3/0nNmftO8vQlsIKGBBUFvkzAWro6YWRsTarhOLC/VSHBpK 9NN05XiJ3cvMWKIK9ozA3+MHjn6d+/dUJORV/zlZbMejcpXVdDtUEHxGSDtLMcUVh3 hyJWSQHS9EeVRkrlCQtrWwRPYugT801f+TpiJLRnFYASG6S1WB+Rsle0+LcIAp98Eg 1SVDxCUnHtprA== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 6054169F0F; Thu, 5 Mar 2026 07:55:23 -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 QxRMFVb0e7ob; Thu, 5 Mar 2026 07:55:23 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1772722518; bh=r37XD0qbgQUWc3fgRDqCBxal/CgPkslg6m7HonZvzao=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=nyLwDdTaTpPiSET5NLJsuew9lGa2Tu9UYmZAF0BeIYRcg2UslRrT8Nn86po9PMz1W WD7att5FE+OdywzQ/m+tS3sDt+Mz2tx1wU10f2eck1YjiUN9AOUDpnlkQcAExD43+q xah2l13uWHnPryGdD+vvop9o7BDmT/I8GfsqVzTQB8CP6N5/q67OMkFiynXNopTq4j zt8kJxe26nPXpoTGhsrbJULFfQEcEGzW5AvTFxXBgsj3f3dZYa+rjBtV3wIWwFjkrT 9qzFLVo4wPRUmL6a3bOIN1XSjKLlZ8+LONRLJxV05pr43Qb2PpTwSMFxOvcWeNtzBl 10gpRdz2HGN1A== Received: from u-boot.org (unknown [73.34.74.121]) by mail.u-boot.org (Postfix) with ESMTPSA id 5B4C169D92; Thu, 5 Mar 2026 07:55:18 -0700 (MST) From: Simon Glass To: U-Boot Concept Date: Thu, 5 Mar 2026 07:54:45 -0700 Message-ID: <20260305145452.909661-3-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260305145452.909661-1-sjg@u-boot.org> References: <20260305145452.909661-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: 7HTFQKBPGK55APSNRWSVTODF3HGMXSQA X-Message-ID-Hash: 7HTFQKBPGK55APSNRWSVTODF3HGMXSQA 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 X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 2/4] pickman: Add agent-based subtree merge conflict resolution 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 processes a subtree update via update-subtree.sh, merge conflicts are common for large DTS updates. Previously, any failure caused an immediate merge abort and error return. Detect whether a failed subtree pull left a merge in progress by checking for MERGE_HEAD. When conflicts exist, invoke the Claude agent to resolve them (preferring the upstream version), following the same pattern used for cherry-pick conflict resolution. If the agent cannot resolve the conflicts, abort the merge and return failure as before. Add SUBTREE_OK/FAIL/CONFLICT return codes to _subtree_run_update() so the caller can distinguish between merge conflicts and other failures. Add build_subtree_conflict_prompt(), run_subtree_conflict_agent(), and resolve_subtree_conflicts() to agent.py for the actual resolution. Signed-off-by: Simon Glass --- tools/pickman/agent.py | 93 ++++++++++++ tools/pickman/control.py | 100 +++++++++---- tools/pickman/ftest.py | 298 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 444 insertions(+), 47 deletions(-) diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index ec5c1b0df75..023b65b5d36 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -713,3 +713,96 @@ def fix_pipeline(mr_iid, branch_name, failed_jobs, remote, target='master', return asyncio.run(run_pipeline_fix_agent( mr_iid, branch_name, failed_jobs, remote, target, mr_description, attempt, repo_path)) + + +def build_subtree_conflict_prompt(name, tag, subtree_path): + """Build a prompt for resolving subtree merge conflicts + + Args: + name (str): Subtree name ('dts', 'mbedtls', 'lwip') + tag (str): Tag being pulled (e.g. 'v6.15-dts') + subtree_path (str): Path to the subtree (e.g. 'dts/upstream') + + Returns: + str: The prompt for the agent + """ + return f"""Resolve merge conflicts from a subtree pull. + +Context: A 'git subtree pull --prefix {subtree_path}' for the '{name}' \ +subtree (tag {tag}) has produced merge conflicts. Git is currently in a \ +merge state with MERGE_HEAD set. + +Resolution strategy - prefer the upstream version: +1. Run 'git status' to see conflicted files +2. For each conflicted file: + - If the file exists in MERGE_HEAD (content conflict or add/add): + git checkout MERGE_HEAD -- + - If the file is a modify/delete conflict (deleted upstream): + git rm +3. After resolving all conflicts, stage everything: + git add -A +4. Complete the merge: + git commit --no-edit +5. Verify with 'git status' that the working tree is clean + +If you cannot resolve the conflicts, abort with: + git merge --abort + +Important: +- Always prefer the upstream (MERGE_HEAD) version of conflicted files +- The subtree path is '{subtree_path}' - most conflicts will be there +- Do NOT modify files outside the subtree path +- Do NOT use 'git merge --continue'; use 'git commit --no-edit' +""" + + +async def run_subtree_conflict_agent(name, tag, subtree_path, + repo_path=None): + """Run the Claude agent to resolve subtree merge conflicts + + Args: + name (str): Subtree name ('dts', 'mbedtls', 'lwip') + tag (str): Tag being pulled (e.g. 'v6.15-dts') + subtree_path (str): Path to the subtree (e.g. 'dts/upstream') + repo_path (str): Path to repository (defaults to current directory) + + Returns: + tuple: (success, conversation_log) where success is bool and + conversation_log is the agent's output text + """ + if not check_available(): + return False, '' + + if repo_path is None: + repo_path = os.getcwd() + + prompt = build_subtree_conflict_prompt(name, tag, subtree_path) + + options = ClaudeAgentOptions( + allowed_tools=['Bash', 'Read', 'Grep'], + cwd=repo_path, + max_buffer_size=MAX_BUFFER_SIZE, + ) + + tout.info(f'Starting Claude agent to resolve {name} subtree ' + f'conflicts...') + tout.info('') + + return await run_agent_collect(prompt, options) + + +def resolve_subtree_conflicts(name, tag, subtree_path, repo_path=None): + """Synchronous wrapper for running the subtree conflict agent + + Args: + name (str): Subtree name ('dts', 'mbedtls', 'lwip') + tag (str): Tag being pulled (e.g. 'v6.15-dts') + subtree_path (str): Path to the subtree (e.g. 'dts/upstream') + repo_path (str): Path to repository (defaults to current directory) + + Returns: + tuple: (success, conversation_log) where success is bool and + conversation_log is the agent's output text + """ + return asyncio.run( + run_subtree_conflict_agent(name, tag, subtree_path, repo_path)) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 815ac574093..ed6825c2333 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -58,6 +58,11 @@ SUBTREE_NAMES = { 'lib/lwip/lwip': 'lwip', } +# Return codes for _subtree_run_update() +SUBTREE_OK = 0 +SUBTREE_FAIL = 1 +SUBTREE_CONFLICT = 2 + # Named tuple for commit info Commit = namedtuple('Commit', ['hash', 'chash', 'subject', 'date']) @@ -1514,14 +1519,30 @@ def push_mr(args, branch_name, title, description): return bool(mr_url) +def _is_merge_in_progress(): + """Check if a git merge is currently in progress. + + Returns: + bool: True if MERGE_HEAD exists (merge in progress), False otherwise + """ + try: + run_git(['rev-parse', '--verify', 'MERGE_HEAD']) + return True + except Exception: # pylint: disable=broad-except + return False + + 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. + On failure, checks whether a merge is in progress (indicating + conflicts). If so, returns SUBTREE_CONFLICT with the merge state + intact so the caller can invoke the agent. Otherwise aborts any + in-progress merge and returns SUBTREE_FAIL. Returns: - int: 0 on success, 1 on failure + int: SUBTREE_OK on success, SUBTREE_CONFLICT on merge conflicts, + SUBTREE_FAIL on other failures """ try: result = command.run_one( @@ -1534,16 +1555,18 @@ def _subtree_run_update(name, tag): f'{result.return_code})') if result.stderr: tout.error(result.stderr) + if _is_merge_in_progress(): + return SUBTREE_CONFLICT try: run_git(['merge', '--abort']) except Exception: # pylint: disable=broad-except pass - return 1 + return SUBTREE_FAIL except Exception as exc: # pylint: disable=broad-except tout.error(f'Subtree update failed: {exc}') - return 1 + return SUBTREE_FAIL - return 0 + return SUBTREE_OK def _subtree_record(dbs, source, squash_hash, merge_hash): @@ -1565,7 +1588,8 @@ def _subtree_record(dbs, source, squash_hash, merge_hash): f'{merge_hash[:12]}') -def apply_subtree_update(dbs, source, name, tag, merge_hash, args): # pylint: disable=too-many-arguments +def apply_subtree_update(dbs, source, name, tag, merge_hash, remote, # pylint: disable=too-many-arguments + target, push=True): """Apply a subtree update on the target branch Runs tools/update-subtree.sh to pull the subtree update, then @@ -1577,13 +1601,13 @@ def apply_subtree_update(dbs, source, name, tag, merge_hash, args): # pylint: d 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' + remote (str): Git remote name (e.g. 'ci') + target (str): Target branch name (e.g. 'master') + push (bool): Whether to push the result to the remote 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) @@ -1599,20 +1623,34 @@ def apply_subtree_update(dbs, source, name, tag, merge_hash, args): # pylint: d # Bare name may be ambiguous when multiple remotes have it try: run_git(['checkout', '-b', target, - f'{args.remote}/{target}']) + f'{remote}/{target}']) except command.CommandExc: tout.error(f'Could not checkout {target}') return 1 ret = _subtree_run_update(name, tag) - if ret: - return ret + if ret == SUBTREE_FAIL: + return 1 + if ret == SUBTREE_CONFLICT: + # Resolve via reverse lookup of subtree path + subtree_path = next( + (p for p, n in SUBTREE_NAMES.items() if n == name), None) + tout.info('Merge conflicts detected, invoking agent...') + success, _ = agent.resolve_subtree_conflicts( + name, tag, subtree_path) + if not success: + tout.error('Agent could not resolve subtree conflicts') + try: + run_git(['merge', '--abort']) + except command.CommandExc: + pass + return 1 - if args.push: + if 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 + gitlab_api.push_branch(remote, target, skip_ci=True) + tout.info(f'Pushed {target} to {remote}') + except command.CommandExc as exc: tout.error(f'Failed to push {target}: {exc}') return 1 @@ -1621,7 +1659,7 @@ def apply_subtree_update(dbs, source, name, tag, merge_hash, args): # pylint: d return 0 -def _prepare_get_commits(dbs, source, args): +def _prepare_get_commits(dbs, source, remote, target): """Get the next commits to apply, handling subtrees and skips. Fetch the next batch of commits from the source. If a subtree @@ -1631,7 +1669,9 @@ def _prepare_get_commits(dbs, source, args): Args: dbs (Database): Database instance source (str): Source branch name - args (Namespace): Parsed arguments (needed for subtree updates) + remote (str): Git remote name (e.g. 'ci'), or None to skip + subtree updates + target (str): Target branch name (e.g. 'master') Returns: tuple: (NextCommitsInfo, return_code) where return_code is None @@ -1646,11 +1686,12 @@ def _prepare_get_commits(dbs, source, args): 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') + if not remote: + tout.error('Cannot apply subtree update without remote') return None, 1 ret = apply_subtree_update(dbs, source, name, tag, - info.advance_to, args) + info.advance_to, remote, + target) if ret: return None, ret continue @@ -1668,7 +1709,8 @@ def _prepare_get_commits(dbs, source, args): return info, None -def prepare_apply(dbs, source, branch, args=None, info=None): +def prepare_apply(dbs, source, branch, remote=None, target=None, # pylint: disable=too-many-arguments + info=None): """Prepare for applying commits from a source branch Get the next commits, set up the branch name and prints info about @@ -1679,8 +1721,9 @@ def prepare_apply(dbs, source, branch, args=None, info=None): 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) + remote (str): Git remote name (e.g. 'ci'), or None to skip + subtree updates + target (str): Target branch name (e.g. 'master') info (NextCommitsInfo): Pre-fetched commit info from _prepare_get_commits(), or None to fetch it here @@ -1690,7 +1733,7 @@ def prepare_apply(dbs, source, branch, args=None, info=None): (0 for no commits, 1 for error) """ if info is None: - info, ret = _prepare_get_commits(dbs, source, args) + info, ret = _prepare_get_commits(dbs, source, remote, target) if ret is not None: return None, ret @@ -1910,7 +1953,8 @@ def do_apply(args, dbs, info=None): int: 0 on success, 1 on failure """ source = args.source - info, ret = prepare_apply(dbs, source, args.branch, args, info=info) + info, ret = prepare_apply(dbs, source, args.branch, args.remote, + args.target, info=info) if not info: return ret @@ -2863,7 +2907,7 @@ def do_step(args, dbs): # Process subtree updates and advance past fully-processed merges # regardless of MR count, since these don't create MRs - info, ret = _prepare_get_commits(dbs, source, args) + info, ret = _prepare_get_commits(dbs, source, remote, args.target) if ret is not None: if ret: return ret diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 766cc714ed7..590677d6b5b 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1442,7 +1442,8 @@ class TestApply(unittest.TestCase): database.Database.instances.clear() - args = argparse.Namespace(cmd='apply', source='unknown', branch=None) + args = argparse.Namespace(cmd='apply', source='unknown', branch=None, + remote='ci', target='master') with terminal.capture() as (_, stderr): ret = control.do_pickman(args) self.assertEqual(ret, 1) @@ -1460,7 +1461,8 @@ class TestApply(unittest.TestCase): database.Database.instances.clear() command.TEST_RESULT = command.CommandResult(stdout='') - args = argparse.Namespace(cmd='apply', source='us/next', branch=None) + args = argparse.Namespace(cmd='apply', source='us/next', branch=None, + remote='ci', target='master') with terminal.capture() as (stdout, _): ret = control.do_pickman(args) self.assertEqual(ret, 0) @@ -4026,7 +4028,8 @@ class TestApplySubtreeUpdate(unittest.TestCase): return_value=mock_result): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) self.assertEqual(ret, 0) @@ -4077,7 +4080,48 @@ class TestApplySubtreeUpdate(unittest.TestCase): side_effect=mock_push): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) + + self.assertEqual(ret, 0) + self.assertTrue(pushed[0]) + dbs.close() + + def test_apply_push_defaults_to_true(self): + """Test apply_subtree_update pushes when push is not specified.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'base') + dbs.commit() + + 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_one', + 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', 'ci', 'master') self.assertEqual(ret, 0) self.assertTrue(pushed[0]) @@ -4105,7 +4149,8 @@ class TestApplySubtreeUpdate(unittest.TestCase): side_effect=run_git_handler): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) self.assertEqual(ret, 1) # Source should not be advanced @@ -4144,7 +4189,8 @@ class TestApplySubtreeUpdate(unittest.TestCase): return_value=0): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) self.assertEqual(ret, 0) # Should have tried bare checkout, then fallback @@ -4170,7 +4216,8 @@ class TestApplySubtreeUpdate(unittest.TestCase): return_value='single_parent'): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) self.assertEqual(ret, 1) dbs.close() @@ -4200,7 +4247,8 @@ class TestApplySubtreeUpdate(unittest.TestCase): side_effect=Exception('script failed')): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) self.assertEqual(ret, 1) # Source should not be advanced @@ -4208,7 +4256,7 @@ class TestApplySubtreeUpdate(unittest.TestCase): dbs.close() def test_apply_merge_conflict(self): - """Test apply_subtree_update aborts merge on non-zero exit.""" + """Test apply_subtree_update aborts merge on non-conflict failure.""" with terminal.capture(): dbs = database.Database(self.db_path) dbs.start() @@ -4222,6 +4270,8 @@ class TestApplySubtreeUpdate(unittest.TestCase): def run_git_handler(git_args): if 'rev-parse' in git_args: + if '--verify' in git_args: + raise Exception('no MERGE_HEAD') return 'first_parent\nsquash_hash' if 'checkout' in git_args: return '' @@ -4239,7 +4289,96 @@ class TestApplySubtreeUpdate(unittest.TestCase): return_value=mock_result): ret = control.apply_subtree_update( dbs, 'us/next', 'dts', 'v6.15-dts', - 'merge_hash', args) + 'merge_hash', 'ci', 'master', + push=args.push) + + self.assertEqual(ret, 1) + self.assertTrue(merge_aborted[0]) + # Source should not be advanced + self.assertEqual(dbs.source_get('us/next'), 'base') + dbs.close() + + def test_apply_merge_conflict_agent_resolves(self): + """Test apply_subtree_update invokes agent on conflict and succeeds.""" + 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: + if '--verify' in git_args: + return 'abc123' + return 'first_parent\nsquash_hash' + if 'checkout' in git_args: + return '' + if '--format=%s|%an' in git_args: + return 'subject|author' + 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_one', + return_value=mock_result): + with mock.patch.object( + agent, 'resolve_subtree_conflicts', + return_value=(True, 'resolved ok')): + ret = control.apply_subtree_update( + dbs, 'us/next', 'dts', 'v6.15-dts', + 'merge_hash', 'ci', 'master', + push=args.push) + + self.assertEqual(ret, 0) + # Source should be advanced + self.assertEqual(dbs.source_get('us/next'), 'merge_hash') + dbs.close() + + def test_apply_merge_conflict_agent_fails(self): + """Test apply_subtree_update aborts when agent fails to resolve.""" + 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: + if '--verify' in git_args: + return 'abc123' + 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_one', + return_value=mock_result): + with mock.patch.object( + agent, 'resolve_subtree_conflicts', + return_value=(False, 'failed')): + ret = control.apply_subtree_update( + dbs, 'us/next', 'dts', 'v6.15-dts', + 'merge_hash', 'ci', 'master', + push=args.push) self.assertEqual(ret, 1) self.assertTrue(merge_aborted[0]) @@ -4276,8 +4415,6 @@ class TestPrepareApplySubtreeUpdate(unittest.TestCase): 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) @@ -4296,11 +4433,12 @@ class TestPrepareApplySubtreeUpdate(unittest.TestCase): control, 'apply_subtree_update', return_value=0) as mock_apply: info, ret = control.prepare_apply( - dbs, 'us/next', None, args) + dbs, 'us/next', None, 'ci', 'master') # Should have called apply_subtree_update mock_apply.assert_called_once_with( - dbs, 'us/next', 'dts', 'v6.15-dts', 'merge1', args) + dbs, 'us/next', 'dts', 'v6.15-dts', 'merge1', + 'ci', 'master') # No commits after retry, so returns None/0 self.assertIsNone(info) self.assertEqual(ret, 0) @@ -4314,8 +4452,6 @@ class TestPrepareApplySubtreeUpdate(unittest.TestCase): 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')) @@ -4325,14 +4461,14 @@ class TestPrepareApplySubtreeUpdate(unittest.TestCase): control, 'apply_subtree_update', return_value=1): info, ret = control.prepare_apply( - dbs, 'us/next', None, args) + dbs, 'us/next', None, 'ci', 'master') 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.""" + def test_prepare_apply_subtree_without_remote(self): + """Test prepare_apply returns error when subtree needs remote=None.""" with terminal.capture(): dbs = database.Database(self.db_path) dbs.start() @@ -6265,5 +6401,129 @@ class TestStepFixRetries(unittest.TestCase): self.assertEqual(args.fix_retries, 1) +class TestIsMergeInProgress(unittest.TestCase): + """Tests for _is_merge_in_progress helper.""" + + def test_merge_in_progress(self): + """Test returns True when MERGE_HEAD exists.""" + with mock.patch.object(control, 'run_git', return_value='abc123'): + self.assertTrue(control._is_merge_in_progress()) + + def test_no_merge_in_progress(self): + """Test returns False when MERGE_HEAD does not exist.""" + with mock.patch.object(control, 'run_git', + side_effect=Exception('not found')): + self.assertFalse(control._is_merge_in_progress()) + + +class TestSubtreeRunUpdateReturnValues(unittest.TestCase): + """Tests for _subtree_run_update return values.""" + + def test_returns_ok_on_success(self): + """Test returns SUBTREE_OK on successful update.""" + mock_result = command.CommandResult('done', '', '', 0) + with terminal.capture(): + with mock.patch.object(control.command, 'run_one', + return_value=mock_result): + ret = control._subtree_run_update('dts', 'v6.15-dts') + self.assertEqual(ret, control.SUBTREE_OK) + + def test_returns_conflict_when_merge_in_progress(self): + """Test returns SUBTREE_CONFLICT when merge state exists.""" + mock_result = command.CommandResult( + '', 'CONFLICT', '', 1) + with terminal.capture(): + with mock.patch.object(control.command, 'run_one', + return_value=mock_result): + with mock.patch.object(control, '_is_merge_in_progress', + return_value=True): + ret = control._subtree_run_update('dts', 'v6.15-dts') + self.assertEqual(ret, control.SUBTREE_CONFLICT) + + def test_returns_fail_when_no_merge(self): + """Test returns SUBTREE_FAIL when script fails without merge.""" + mock_result = command.CommandResult( + '', 'error', '', 1) + with terminal.capture(): + with mock.patch.object(control.command, 'run_one', + return_value=mock_result): + with mock.patch.object(control, '_is_merge_in_progress', + return_value=False): + with mock.patch.object(control, 'run_git'): + ret = control._subtree_run_update( + 'dts', 'v6.15-dts') + self.assertEqual(ret, control.SUBTREE_FAIL) + + def test_returns_fail_on_exception(self): + """Test returns SUBTREE_FAIL when script raises exception.""" + with terminal.capture(): + with mock.patch.object(control.command, 'run_one', + side_effect=Exception('boom')): + ret = control._subtree_run_update('dts', 'v6.15-dts') + self.assertEqual(ret, control.SUBTREE_FAIL) + + +class TestSubtreeConflictPrompt(unittest.TestCase): + """Tests for build_subtree_conflict_prompt.""" + + def test_dts_prompt_content(self): + """Test prompt contains correct details for dts subtree.""" + prompt = agent.build_subtree_conflict_prompt( + 'dts', 'v6.15-dts', 'dts/upstream') + self.assertIn('dts/upstream', prompt) + self.assertIn('v6.15-dts', prompt) + self.assertIn('MERGE_HEAD', prompt) + self.assertIn('git commit --no-edit', prompt) + self.assertIn('git merge --abort', prompt) + + def test_mbedtls_prompt_content(self): + """Test prompt contains correct details for mbedtls subtree.""" + prompt = agent.build_subtree_conflict_prompt( + 'mbedtls', 'v3.6.0', 'lib/mbedtls/external/mbedtls') + self.assertIn('lib/mbedtls/external/mbedtls', prompt) + self.assertIn('v3.6.0', prompt) + self.assertIn("'mbedtls'", prompt) + + +class TestResolveSubtreeConflicts(unittest.TestCase): + """Tests for resolve_subtree_conflicts.""" + + def test_success(self): + """Test successful conflict resolution.""" + mock_collect = mock.AsyncMock(return_value=(True, 'resolved')) + with terminal.capture(): + with mock.patch.object(agent, 'AGENT_AVAILABLE', True): + with mock.patch.object(agent, 'run_agent_collect', + mock_collect): + with mock.patch.object(agent, 'ClaudeAgentOptions'): + success, log = agent.resolve_subtree_conflicts( + 'dts', 'v6.15-dts', 'dts/upstream', + '/tmp/test') + self.assertTrue(success) + self.assertEqual(log, 'resolved') + + def test_failure(self): + """Test failed conflict resolution.""" + mock_collect = mock.AsyncMock(return_value=(False, 'failed')) + with terminal.capture(): + with mock.patch.object(agent, 'AGENT_AVAILABLE', True): + with mock.patch.object(agent, 'run_agent_collect', + mock_collect): + with mock.patch.object(agent, 'ClaudeAgentOptions'): + success, log = agent.resolve_subtree_conflicts( + 'dts', 'v6.15-dts', 'dts/upstream', + '/tmp/test') + self.assertFalse(success) + + def test_sdk_unavailable(self): + """Test returns failure when SDK is not available.""" + with terminal.capture(): + with mock.patch.object(agent, 'AGENT_AVAILABLE', False): + success, log = agent.resolve_subtree_conflicts( + 'dts', 'v6.15-dts', 'dts/upstream', '/tmp/test') + self.assertFalse(success) + self.assertEqual(log, '') + + if __name__ == '__main__': unittest.main()