diff --git a/lib/merge.py b/lib/merge.py index f3aa7f4..6099e29 100644 --- a/lib/merge.py +++ b/lib/merge.py @@ -178,9 +178,8 @@ async def _claim_next_pr(conn, domain: str) -> dict | None: """ # Build prefix filter for pipeline-owned branches only # Agent branches stay approved but are NOT auto-merged (Leo: PRs #2141, #157, #2142, #2180) - prefix_clauses = " OR ".join( - f"p.branch LIKE '{pfx}%'" for pfx in PIPELINE_OWNED_PREFIXES - ) + prefix_clauses = " OR ".join("p.branch LIKE ?" for _ in PIPELINE_OWNED_PREFIXES) + prefix_params = [f"{pfx}%" for pfx in PIPELINE_OWNED_PREFIXES] row = conn.execute( f"""UPDATE prs SET status = 'merging', last_attempt = datetime('now') WHERE number = ( @@ -210,7 +209,7 @@ async def _claim_next_pr(conn, domain: str) -> dict | None: LIMIT 1 ) RETURNING number, source_path, branch, domain""", - (domain,), + (domain, *prefix_params), ).fetchone() return dict(row) if row else None @@ -329,8 +328,10 @@ async def _cherry_pick_onto_main(branch: str) -> tuple[bool, str]: if conflict_files and all(f.startswith("entities/") for f in conflict_files): # Entity conflicts: take main's version (entities are recoverable) + # In cherry-pick: --ours = branch we're ON (clean branch from origin/main) + # --theirs = commit being cherry-picked (extraction branch) for cf in conflict_files: - await _git("checkout", "--theirs", cf, cwd=worktree_path) + await _git("checkout", "--ours", cf, cwd=worktree_path) await _git("add", cf, cwd=worktree_path) dropped_entities.update(conflict_files) rc_cont, cont_out = await _git( @@ -366,7 +367,9 @@ async def _cherry_pick_onto_main(branch: str) -> tuple[bool, str]: # Force-push clean branch as the original branch name # Capture expected SHA for force-with-lease rc, expected_sha = await _git("rev-parse", f"origin/{branch}") - expected_sha = expected_sha.strip().split("\n")[0] if rc == 0 else "" + if rc != 0: + return False, f"rev-parse origin/{branch} failed: {expected_sha}" + expected_sha = expected_sha.strip().split("\n")[0] rc, out = await _git( "push", @@ -972,8 +975,8 @@ async def _merge_domain_queue(conn, domain: str) -> tuple[int, int]: failed += 1 continue - # Local ff-merge: push rebased branch as main (Rhea's approach, Leo+Rhea: local primary) - # The branch was just rebased onto origin/main by _rebase_and_push, + # Local ff-merge: push cherry-picked branch as main (Rhea's approach, Leo+Rhea: local primary) + # The branch was just cherry-picked onto origin/main, # so origin/{branch} is a descendant of origin/main. Push it as main. await _git("fetch", "origin", branch, timeout=15) rc, main_sha = await _git("rev-parse", "origin/main") @@ -1256,13 +1259,13 @@ async def _handle_permanent_conflicts(conn) -> int: async def _retry_conflict_prs(conn) -> tuple[int, int]: - """Retry rebase on conflict PRs that were previously approved. + """Retry conflict PRs via cherry-pick onto fresh main. Design: Ganymede (extend merge stage), Rhea (safety guards), Leo (re-eval required). - Pick up PRs with status='conflict' and both approvals - - Attempt fresh rebase onto origin/main - - If rebase succeeds: force-push, reset to 'open' with verdicts cleared for re-eval - - If rebase fails: increment attempt counter, leave as 'conflict' + - Cherry-pick extraction commits onto fresh branch from origin/main + - If cherry-pick succeeds: force-push, reset to 'open' with verdicts cleared for re-eval + - If cherry-pick fails: increment attempt counter, leave as 'conflict' - After MAX_CONFLICT_REBASE_ATTEMPTS failures: mark 'conflict_permanent' - Skip branches with new commits since conflict was set (Rhea: someone is working on it) """ @@ -1293,8 +1296,8 @@ async def _retry_conflict_prs(conn) -> tuple[int, int]: await _git("fetch", "origin", branch, timeout=30) await _git("fetch", "origin", "main", timeout=30) - # Attempt rebase - ok, msg = await _rebase_and_push(branch) + # Attempt cherry-pick onto fresh main (replaces rebase — Leo+Cory directive) + ok, msg = await _cherry_pick_onto_main(branch) if ok: # Rebase succeeded — reset for re-eval (Ganymede: approvals are stale after rebase)