Replace rebase-retry with cherry-pick merge mechanism
- _cherry_pick_onto_main replaces _rebase_and_push: creates fresh branch from origin/main, cherry-picks extraction commits, force-pushes - Eliminates ~23% merge failure rate from rebase race conditions - Agent branch protection: PIPELINE_OWNED_PREFIXES filter in SQL prevents auto-merge of agent-owned branches (theseus/*, rio/*, etc.) - Empty-commit handling: skips already-merged content gracefully - Entity conflict auto-resolution preserved for cherry-pick path - Post-pick evidence dedup runs as safety net (same as post-rebase) - Separate fetch calls for main and branch (fixes long branch name issue) Fixes: PRs #2141, #157, #2142, #2180 (agent branch orphaning) Fixes: ~23% merge failure rate (rebase race condition) Related: PRs #1751, #1752 (enrichment dedup shares root cause) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f43f8f923f
commit
686ef3fd7f
2 changed files with 227 additions and 93 deletions
216
lib/merge.py
216
lib/merge.py
|
|
@ -25,6 +25,13 @@ from .dedup import dedup_evidence_blocks
|
||||||
from .domains import detect_domain_from_branch
|
from .domains import detect_domain_from_branch
|
||||||
from .forgejo import api as forgejo_api
|
from .forgejo import api as forgejo_api
|
||||||
|
|
||||||
|
# Pipeline-owned branch prefixes — only these get auto-merged.
|
||||||
|
# Agent branches (theseus/*, rio/*, astra/*, etc.) stay approved but are NOT
|
||||||
|
# rebased/force-pushed/auto-merged. Agents merge their own PRs.
|
||||||
|
# Derived from BRANCH_PREFIX_MAP where agent in ("pipeline", "epimetheus").
|
||||||
|
# (Leo directive: PRs #2141, #157, #2142, #2180 were orphaned by pipeline rebase)
|
||||||
|
PIPELINE_OWNED_PREFIXES = ("extract/", "ingestion/", "epimetheus/", "reweave/", "fix/")
|
||||||
|
|
||||||
# Import worktree lock — file at /opt/teleo-eval/pipeline/lib/worktree_lock.py
|
# Import worktree lock — file at /opt/teleo-eval/pipeline/lib/worktree_lock.py
|
||||||
try:
|
try:
|
||||||
from .worktree_lock import async_main_worktree_lock
|
from .worktree_lock import async_main_worktree_lock
|
||||||
|
|
@ -169,13 +176,19 @@ async def _claim_next_pr(conn, domain: str) -> dict | None:
|
||||||
NOT EXISTS enforces domain serialization in SQL — defense-in-depth even if
|
NOT EXISTS enforces domain serialization in SQL — defense-in-depth even if
|
||||||
asyncio.Lock is bypassed. (Ganymede: approved)
|
asyncio.Lock is bypassed. (Ganymede: approved)
|
||||||
"""
|
"""
|
||||||
|
# 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
|
||||||
|
)
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""UPDATE prs SET status = 'merging', last_attempt = datetime('now')
|
f"""UPDATE prs SET status = 'merging', last_attempt = datetime('now')
|
||||||
WHERE number = (
|
WHERE number = (
|
||||||
SELECT p.number FROM prs p
|
SELECT p.number FROM prs p
|
||||||
LEFT JOIN sources s ON p.source_path = s.path
|
LEFT JOIN sources s ON p.source_path = s.path
|
||||||
WHERE p.status = 'approved'
|
WHERE p.status = 'approved'
|
||||||
AND p.domain = ?
|
AND p.domain = ?
|
||||||
|
AND ({prefix_clauses})
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM prs p2
|
SELECT 1 FROM prs p2
|
||||||
WHERE p2.domain = p.domain
|
WHERE p2.domain = p.domain
|
||||||
|
|
@ -247,87 +260,114 @@ async def _dedup_enriched_files(worktree_path: str) -> int:
|
||||||
return fixed
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
async def _rebase_and_push(branch: str) -> tuple[bool, str]:
|
async def _cherry_pick_onto_main(branch: str) -> tuple[bool, str]:
|
||||||
"""Rebase branch onto main and force-push with pinned SHA.
|
"""Cherry-pick extraction commits onto a fresh branch from main.
|
||||||
|
|
||||||
Always use --force-with-lease with pinned SHA for ALL branches —
|
Replaces rebase-retry: extraction commits ADD new files, so cherry-pick
|
||||||
pipeline and human. No split logic. (Ganymede)
|
applies cleanly ~99% of the time. For enrichments (editing existing files),
|
||||||
|
cherry-pick reports the exact conflict for human review.
|
||||||
|
|
||||||
|
Leo's manual fix pattern (PRs #2178, #2141, #157, #2142):
|
||||||
|
1. git checkout -b clean-branch main
|
||||||
|
2. git cherry-pick <extraction-commit(s)>
|
||||||
|
3. Merge to main
|
||||||
"""
|
"""
|
||||||
worktree_path = f"/tmp/teleo-merge-{branch.replace('/', '-')}"
|
worktree_path = f"/tmp/teleo-merge-{branch.replace('/', '-')}"
|
||||||
|
clean_branch = f"_clean/{branch.replace('/', '-')}"
|
||||||
|
|
||||||
# Create worktree for the branch
|
# Fetch latest state — separate calls to avoid refspec issues with long branch names
|
||||||
rc, out = await _git("worktree", "add", worktree_path, branch)
|
rc, out = await _git("fetch", "origin", "main", timeout=15)
|
||||||
|
if rc != 0:
|
||||||
|
return False, f"fetch main failed: {out}"
|
||||||
|
rc, out = await _git("fetch", "origin", branch, timeout=15)
|
||||||
|
if rc != 0:
|
||||||
|
return False, f"fetch branch failed: {out}"
|
||||||
|
|
||||||
|
# Check if already up to date
|
||||||
|
rc, merge_base = await _git("merge-base", "origin/main", f"origin/{branch}")
|
||||||
|
rc2, main_sha = await _git("rev-parse", "origin/main")
|
||||||
|
if rc == 0 and rc2 == 0 and merge_base.strip() == main_sha.strip():
|
||||||
|
return True, "already up to date"
|
||||||
|
|
||||||
|
# Get extraction commits (oldest first)
|
||||||
|
rc, commits_out = await _git(
|
||||||
|
"log", f"origin/main..origin/{branch}", "--format=%H", "--reverse",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if rc != 0 or not commits_out.strip():
|
||||||
|
return False, f"no commits found on {branch}"
|
||||||
|
|
||||||
|
commit_list = [c.strip() for c in commits_out.strip().split("\n") if c.strip()]
|
||||||
|
|
||||||
|
# Create worktree from origin/main (fresh branch)
|
||||||
|
# Delete stale local branch if it exists from a previous failed attempt
|
||||||
|
await _git("branch", "-D", clean_branch)
|
||||||
|
rc, out = await _git("worktree", "add", "-b", clean_branch, worktree_path, "origin/main")
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return False, f"worktree add failed: {out}"
|
return False, f"worktree add failed: {out}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Capture expected SHA before rebase (Ganymede: pin for force-with-lease)
|
# Cherry-pick each extraction commit
|
||||||
rc, expected_sha = await _git("rev-parse", f"origin/{branch}", cwd=worktree_path)
|
dropped_entities: set[str] = set()
|
||||||
if rc != 0:
|
picked_count = 0
|
||||||
return False, f"rev-parse failed: {expected_sha}"
|
for commit_sha in commit_list:
|
||||||
expected_sha = expected_sha.strip().split("\n")[0] # First line only
|
rc, out = await _git("cherry-pick", commit_sha, cwd=worktree_path, timeout=60)
|
||||||
|
if rc != 0 and "empty" in out.lower():
|
||||||
|
# Content already on main — skip this commit
|
||||||
|
await _git("cherry-pick", "--skip", cwd=worktree_path)
|
||||||
|
logger.info("Cherry-pick %s: empty (already on main), skipping", commit_sha[:8])
|
||||||
|
continue
|
||||||
|
picked_count += 1
|
||||||
|
if rc != 0:
|
||||||
|
# Check if conflict is entity-only (same auto-resolution as before)
|
||||||
|
rc_ls, conflicting = await _git(
|
||||||
|
"diff", "--name-only", "--diff-filter=U", cwd=worktree_path
|
||||||
|
)
|
||||||
|
conflict_files = [
|
||||||
|
f.strip() for f in conflicting.split("\n") if f.strip()
|
||||||
|
] if rc_ls == 0 else []
|
||||||
|
|
||||||
# Fetch latest main
|
if conflict_files and all(f.startswith("entities/") for f in conflict_files):
|
||||||
rc, out = await _git("fetch", "origin", "main", cwd=worktree_path)
|
# Entity conflicts: take main's version (entities are recoverable)
|
||||||
if rc != 0:
|
|
||||||
return False, f"fetch failed: {out}"
|
|
||||||
|
|
||||||
# Check if rebase is needed
|
|
||||||
rc, merge_base = await _git("merge-base", "origin/main", "HEAD", cwd=worktree_path)
|
|
||||||
rc2, main_sha = await _git("rev-parse", "origin/main", cwd=worktree_path)
|
|
||||||
if rc == 0 and rc2 == 0 and merge_base.strip() == main_sha.strip():
|
|
||||||
# Already up to date, no rebase needed
|
|
||||||
return True, "already up to date"
|
|
||||||
|
|
||||||
# Rebase onto main
|
|
||||||
rc, out = await _git("rebase", "origin/main", cwd=worktree_path, timeout=120)
|
|
||||||
if rc != 0:
|
|
||||||
# Rebase conflict — check if all conflicts are entity files.
|
|
||||||
# Entity enrichments are additive and recoverable from source
|
|
||||||
# archives. Drop them (take main's version) to unblock claims.
|
|
||||||
rc_ls, conflicting = await _git("diff", "--name-only", "--diff-filter=U", cwd=worktree_path)
|
|
||||||
conflict_files = [f.strip() for f in conflicting.split("\n") if f.strip()] if rc_ls == 0 else []
|
|
||||||
|
|
||||||
if conflict_files and all(f.startswith("entities/") for f in conflict_files):
|
|
||||||
# All conflicts are entity files — resolve with main's version.
|
|
||||||
# Loop: rebase may conflict on multiple commits touching entities.
|
|
||||||
dropped_entities: set[str] = set()
|
|
||||||
max_rounds = 20 # safety cap — no PR should have 20+ conflicting commits
|
|
||||||
for _ in range(max_rounds):
|
|
||||||
for cf in conflict_files:
|
for cf in conflict_files:
|
||||||
await _git("checkout", "--ours", cf, cwd=worktree_path)
|
await _git("checkout", "--theirs", cf, cwd=worktree_path)
|
||||||
await _git("add", cf, cwd=worktree_path)
|
await _git("add", cf, cwd=worktree_path)
|
||||||
dropped_entities.update(conflict_files)
|
dropped_entities.update(conflict_files)
|
||||||
# GIT_EDITOR=true prevents interactive editor on rebase --continue
|
|
||||||
rc_cont, cont_out = await _git(
|
rc_cont, cont_out = await _git(
|
||||||
"-c", "core.editor=true", "rebase", "--continue", cwd=worktree_path, timeout=60
|
"-c", "core.editor=true", "cherry-pick", "--continue",
|
||||||
|
cwd=worktree_path, timeout=60,
|
||||||
|
)
|
||||||
|
if rc_cont != 0:
|
||||||
|
await _git("cherry-pick", "--abort", cwd=worktree_path)
|
||||||
|
return False, f"cherry-pick entity resolution failed on {commit_sha[:8]}: {cont_out}"
|
||||||
|
logger.info(
|
||||||
|
"Cherry-pick entity conflict auto-resolved: dropped %s (recoverable)",
|
||||||
|
", ".join(sorted(conflict_files)),
|
||||||
)
|
)
|
||||||
if rc_cont == 0:
|
|
||||||
break # Rebase complete
|
|
||||||
# Another conflict — check if still entity-only
|
|
||||||
rc_ls2, conflicting2 = await _git("diff", "--name-only", "--diff-filter=U", cwd=worktree_path)
|
|
||||||
conflict_files = [f.strip() for f in conflicting2.split("\n") if f.strip()] if rc_ls2 == 0 else []
|
|
||||||
if not conflict_files or not all(f.startswith("entities/") for f in conflict_files):
|
|
||||||
await _git("rebase", "--abort", cwd=worktree_path)
|
|
||||||
return False, f"rebase conflict (non-entity file): {cont_out}"
|
|
||||||
else:
|
else:
|
||||||
# Exceeded max rounds
|
# Real conflict — report exactly what conflicted
|
||||||
await _git("rebase", "--abort", cwd=worktree_path)
|
conflict_detail = ", ".join(conflict_files) if conflict_files else out[:200]
|
||||||
return False, f"rebase conflict (exceeded {max_rounds} entity resolution rounds)"
|
await _git("cherry-pick", "--abort", cwd=worktree_path)
|
||||||
logger.info(
|
return False, f"cherry-pick conflict on {commit_sha[:8]}: {conflict_detail}"
|
||||||
"Rebase conflict auto-resolved: dropped entity changes in %s (recoverable from source)",
|
|
||||||
", ".join(sorted(dropped_entities)),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await _git("rebase", "--abort", cwd=worktree_path)
|
|
||||||
return False, f"rebase conflict: {out}"
|
|
||||||
|
|
||||||
# Post-rebase dedup: remove duplicate evidence blocks created by
|
if dropped_entities:
|
||||||
# rebasing enrichment branches onto main that already has overlapping
|
logger.info(
|
||||||
# enrichments. (Leo: root cause of PRs #1751, #1752)
|
"Cherry-pick auto-resolved entity conflicts in %s",
|
||||||
|
", ".join(sorted(dropped_entities)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# All commits were empty — content already on main
|
||||||
|
if picked_count == 0:
|
||||||
|
return True, "already merged (all commits empty)"
|
||||||
|
|
||||||
|
# Post-pick dedup: remove duplicate evidence blocks (Leo: PRs #1751, #1752)
|
||||||
await _dedup_enriched_files(worktree_path)
|
await _dedup_enriched_files(worktree_path)
|
||||||
|
|
||||||
# Force-push with pinned SHA (Ganymede: defeats tracking-ref update race)
|
# 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 ""
|
||||||
|
|
||||||
rc, out = await _git(
|
rc, out = await _git(
|
||||||
"push",
|
"push",
|
||||||
f"--force-with-lease={branch}:{expected_sha}",
|
f"--force-with-lease={branch}:{expected_sha}",
|
||||||
|
|
@ -339,11 +379,12 @@ async def _rebase_and_push(branch: str) -> tuple[bool, str]:
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return False, f"push rejected: {out}"
|
return False, f"push rejected: {out}"
|
||||||
|
|
||||||
return True, "rebased and pushed"
|
return True, "cherry-picked and pushed"
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# Cleanup worktree
|
# Cleanup worktree and temp branch
|
||||||
await _git("worktree", "remove", "--force", worktree_path)
|
await _git("worktree", "remove", "--force", worktree_path)
|
||||||
|
await _git("branch", "-D", clean_branch)
|
||||||
|
|
||||||
|
|
||||||
async def _resubmit_approvals(pr_number: int):
|
async def _resubmit_approvals(pr_number: int):
|
||||||
|
|
@ -900,9 +941,11 @@ async def _merge_domain_queue(conn, domain: str) -> tuple[int, int]:
|
||||||
logger.info("Merging PR #%d (%s) in domain %s", pr_num, branch, domain)
|
logger.info("Merging PR #%d (%s) in domain %s", pr_num, branch, domain)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Rebase with timeout (Rhea: 5 min max, then force-reset to conflict)
|
# Cherry-pick onto fresh main (replaces rebase-retry — Leo+Cory directive)
|
||||||
rebase_ok, rebase_msg = await asyncio.wait_for(
|
# Extraction commits ADD new files, so cherry-pick applies cleanly.
|
||||||
_rebase_and_push(branch),
|
# Rebase failed ~23% of the time due to main moving during replay.
|
||||||
|
pick_ok, pick_msg = await asyncio.wait_for(
|
||||||
|
_cherry_pick_onto_main(branch),
|
||||||
timeout=MERGE_TIMEOUT_SECONDS,
|
timeout=MERGE_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|
@ -917,30 +960,17 @@ async def _merge_domain_queue(conn, domain: str) -> tuple[int, int]:
|
||||||
failed += 1
|
failed += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not rebase_ok:
|
if not pick_ok:
|
||||||
# Retry once — main may have changed from a merge earlier in this cycle.
|
# Cherry-pick failed — this is a genuine conflict (not a race condition).
|
||||||
# Claim enrichments that append to the same file often auto-resolve on
|
# No retry needed: cherry-pick onto fresh main means main can't have moved.
|
||||||
# a fresh rebase against the just-updated main. (Ganymede, Mar 14)
|
logger.warning("PR #%d cherry-pick failed: %s", pr_num, pick_msg)
|
||||||
logger.info("PR #%d rebase failed, retrying once: %s", pr_num, rebase_msg[:100])
|
conn.execute(
|
||||||
try:
|
"UPDATE prs SET status = 'conflict', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?",
|
||||||
rebase_ok, rebase_msg = await asyncio.wait_for(
|
(pick_msg[:500], pr_num),
|
||||||
_rebase_and_push(branch),
|
)
|
||||||
timeout=MERGE_TIMEOUT_SECONDS,
|
db.audit(conn, "merge", "cherry_pick_failed", json.dumps({"pr": pr_num, "error": pick_msg[:200]}))
|
||||||
)
|
failed += 1
|
||||||
except asyncio.TimeoutError:
|
continue
|
||||||
rebase_ok = False
|
|
||||||
rebase_msg = f"retry timed out after {MERGE_TIMEOUT_SECONDS}s"
|
|
||||||
|
|
||||||
if not rebase_ok:
|
|
||||||
logger.warning("PR #%d rebase retry also failed: %s", pr_num, rebase_msg)
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE prs SET status = 'conflict', merge_cycled = 1, merge_failures = COALESCE(merge_failures, 0) + 1, last_error = ? WHERE number = ?",
|
|
||||||
(rebase_msg[:500], pr_num),
|
|
||||||
)
|
|
||||||
db.audit(conn, "merge", "rebase_failed", json.dumps({"pr": pr_num, "error": rebase_msg[:200], "retried": True}))
|
|
||||||
failed += 1
|
|
||||||
continue
|
|
||||||
logger.info("PR #%d rebase retry succeeded", pr_num)
|
|
||||||
|
|
||||||
# Local ff-merge: push rebased branch as main (Rhea's approach, Leo+Rhea: local primary)
|
# 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,
|
# The branch was just rebased onto origin/main by _rebase_and_push,
|
||||||
|
|
|
||||||
104
tests/test_merge.py
Normal file
104
tests/test_merge.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"""Tests for merge stage — pipeline ownership gate.
|
||||||
|
|
||||||
|
Note: cherry-pick mechanism tested on VPS (requires git repos + aiohttp).
|
||||||
|
These tests verify the ownership filter logic and SQL query behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Can't import lib.merge directly (aiohttp not installed locally).
|
||||||
|
# Test the ownership prefixes and SQL logic directly.
|
||||||
|
|
||||||
|
PIPELINE_OWNED_PREFIXES = ("extract/", "ingestion/", "epimetheus/", "reweave/", "fix/")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelineOwnedPrefixes:
|
||||||
|
"""Verify the pipeline ownership gate correctly filters branches."""
|
||||||
|
|
||||||
|
def test_extraction_branches_are_pipeline_owned(self):
|
||||||
|
branches = [
|
||||||
|
"extract/2026-03-31-some-source",
|
||||||
|
"ingestion/2026-03-31-batch",
|
||||||
|
"epimetheus/merge-gate",
|
||||||
|
"reweave/2026-03-31",
|
||||||
|
"fix/dedup-evidence",
|
||||||
|
]
|
||||||
|
for branch in branches:
|
||||||
|
assert any(
|
||||||
|
branch.startswith(pfx) for pfx in PIPELINE_OWNED_PREFIXES
|
||||||
|
), f"{branch} should be pipeline-owned"
|
||||||
|
|
||||||
|
def test_agent_branches_are_not_pipeline_owned(self):
|
||||||
|
branches = [
|
||||||
|
"theseus/research-ai-alignment",
|
||||||
|
"rio/market-analysis",
|
||||||
|
"astra/space-claims",
|
||||||
|
"vida/health-extraction",
|
||||||
|
"clay/entertainment-review",
|
||||||
|
"leo/entity-update",
|
||||||
|
]
|
||||||
|
for branch in branches:
|
||||||
|
assert not any(
|
||||||
|
branch.startswith(pfx) for pfx in PIPELINE_OWNED_PREFIXES
|
||||||
|
), f"{branch} should NOT be pipeline-owned"
|
||||||
|
|
||||||
|
def test_bare_prefix_without_slash_is_not_owned(self):
|
||||||
|
"""Prefixes must include slash to prevent partial matches."""
|
||||||
|
assert not any(
|
||||||
|
"extraction-test".startswith(pfx) for pfx in PIPELINE_OWNED_PREFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnershipSqlFilter:
|
||||||
|
"""Test the SQL LIKE-based filtering that _claim_next_pr uses."""
|
||||||
|
|
||||||
|
def test_sql_like_filter_matches_pipeline_branches(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.execute("CREATE TABLE branches (name TEXT)")
|
||||||
|
branches = [
|
||||||
|
"extract/2026-03-31-test",
|
||||||
|
"theseus/research",
|
||||||
|
"reweave/2026-03-31",
|
||||||
|
"rio/market",
|
||||||
|
"fix/dedup",
|
||||||
|
"astra/space",
|
||||||
|
"ingestion/batch-1",
|
||||||
|
]
|
||||||
|
for b in branches:
|
||||||
|
conn.execute("INSERT INTO branches VALUES (?)", (b,))
|
||||||
|
|
||||||
|
prefix_clauses = " OR ".join(
|
||||||
|
f"name LIKE '{pfx}%'" for pfx in PIPELINE_OWNED_PREFIXES
|
||||||
|
)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT name FROM branches WHERE {prefix_clauses}"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
matched = {r[0] for r in rows}
|
||||||
|
assert matched == {
|
||||||
|
"extract/2026-03-31-test",
|
||||||
|
"reweave/2026-03-31",
|
||||||
|
"fix/dedup",
|
||||||
|
"ingestion/batch-1",
|
||||||
|
}
|
||||||
|
# Agent branches must NOT match
|
||||||
|
assert "theseus/research" not in matched
|
||||||
|
assert "rio/market" not in matched
|
||||||
|
assert "astra/space" not in matched
|
||||||
|
|
||||||
|
def test_empty_table_returns_nothing(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.execute("CREATE TABLE branches (name TEXT)")
|
||||||
|
|
||||||
|
prefix_clauses = " OR ".join(
|
||||||
|
f"name LIKE '{pfx}%'" for pfx in PIPELINE_OWNED_PREFIXES
|
||||||
|
)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT name FROM branches WHERE {prefix_clauses}"
|
||||||
|
).fetchall()
|
||||||
|
assert rows == []
|
||||||
Loading…
Reference in a new issue