From 0415b28ba7c0160e7b2f70cc41b1ac6ad2ede7dc Mon Sep 17 00:00:00 2001 From: Pawel Lebioda Date: Mon, 22 Jun 2026 11:49:47 +0200 Subject: [PATCH 1/2] Add deterministic merge gate and pick to `fork merge-pick` A deterministic gate (count + age + force-strategy override, capped by a max batch size) decides whether to merge now; the deterministic pick chooses where to cut within the candidate window. No AI tokens, safe for an unprivileged periodic phase. - config: `MergeGateConfig` (min_commits / max_age_days / max_commits / force_strategies, with string-or-list and enum validation) and `AiPickConfig` (consumed here by `--plan` to report the mode) on `ForkConfig`. - `ForkStatus.unmerged_oldest_age_days`, surfaced in `to_dict()`. - gate: pure `evaluate_merge_gate()` + `GateDecision`. - fork.py: candidate-window restriction, `compute_gate`, the banded `resolve_deterministic_sha` (forced-below-min, else first in-band match, else window tip), `merge-pick --plan` (token-free JSON decision) and `merge-pick --gate` (gate-respecting deterministic pick), and the gate decision surfaced in `fork status`. - docs + tests for the gate, window capping, deterministic resolution, and config parsing. --- README.md | 57 ++++ src/mergai/commands/fork.py | 319 ++++++++++++++++++++++- src/mergai/config.py | 144 ++++++++++ src/mergai/merge_pick_strategies/gate.py | 85 ++++++ src/mergai/utils/git_utils.py | 23 ++ tests/test_merge_gate.py | 263 +++++++++++++++++++ 6 files changed, 890 insertions(+), 1 deletion(-) create mode 100644 src/mergai/merge_pick_strategies/gate.py create mode 100644 tests/test_merge_gate.py diff --git a/README.md b/README.md index 0803dd6..935373b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ fork: - important_files: - src/core/critical_module.cpp - src/api/public_api.h + merge_gate: + min_commits: 50 # merge once this many unmerged commits accumulate + max_age_days: 2 # ...or sooner if the oldest unmerged commit is older than this + max_commits: 150 # never advance more than this many commits in one merge + force_strategies: # ...or as soon as one of these strategies matches + - conflict + - important_files + ai_pick: + enabled: false # false = deterministic pick (default) + agent: claude-cli:claude-opus-4-5 # same format as resolve.agent + rules_file: .mergai/merge_pick_rules.md # project-specific rules (optional) + fallback: deterministic # on agent error / invalid sha: deterministic | error resolve: # Agent format: [:] @@ -130,6 +142,48 @@ The `fork.merge_picks.strategies` list defines how `mergai fork merge-pick` prio Set `most_recent_fallback: true` to select the most recent unmerged commit when no strategy matches. +### Merge Gate and AI Pick + +A deterministic **gate** decides *when* to merge; the **pick** (deterministic or AI) decides *which* upstream commit to merge to. The gate is a pure go/no-go decision over already-computed fork status, so it needs no AI tokens and is safe to run in an unprivileged/periodic phase. + +`fork.merge_gate` opens the gate when any of the following hold (in order): + +| Setting | Default | Opens the gate when... | +|---------|---------|------------------------| +| `force_strategies` | `[conflict, important_files]` | any prioritized commit matches one of these strategies (reason `force:`) | +| `min_commits` | `50` | at least this many unmerged commits have accumulated | +| `max_age_days` | `2` | the oldest unmerged commit is at least this many days old | +| `max_commits` | `150` | (not a trigger) batch ceiling: a single merge never advances more than this many commits | + +`max_commits` defines the **candidate window** - the oldest `max_commits` unmerged commits (`base..base+max_commits`). It bounds both the merge batch size and the AI prompt size; commits newer than the window are omitted (counted) and drained by later merges. Defaults are tied to the historical merge cadence (median ~47 commits/merge, p75 ~67). + +`fork.ai_pick` controls the pick made once the gate opens: + +| Setting | Default | Description | +|---------|---------|-------------| +| `enabled` | `false` | When `false`, the pick is deterministic. When `true`, an AI agent chooses the merge boundary within the window. | +| `agent` | `""` | Agent descriptor (e.g. `claude-cli:claude-opus-4-5`), same format as `resolve.agent`. Empty falls back to `resolve.agent`. | +| `rules_file` | `""` | Optional path to a project-specific merge-pick rules markdown file, appended to the built-in system prompt. | +| `fallback` | `deterministic` | On agent error / invalid sha: `deterministic` (resilient) or `error`. | + +The two phases are exposed as: + +```bash +mergai fork merge-pick --plan # token-free gate decision (JSON): wait / merge (+ mode, sha) +mergai fork merge-pick --ai --next # privileged AI pick within the window; prints the chosen sha +mergai fork merge-pick --ai --force # skip the gate re-check and pick regardless +``` + +`--plan` emits a JSON decision the periodic workflow consumes, e.g.: + +```json +{ "action": "wait", "reason": "wait (12 < 50 commits; oldest 0.3d < 2d)" } +{ "action": "merge", "mode": "deterministic", "sha": "", "reason": "min_commits (63 >= 50)" } +{ "action": "merge", "mode": "ai", "sha": null, "reason": "force:conflict" } +``` + +The gate decision is also surfaced in `mergai fork status` (text and `--json`). + ### Branch Naming Format The `branch.name_format` setting controls how mergai names branches. Available tokens: @@ -196,6 +250,9 @@ Notes are automatically attached when using `mergai commit` subcommands. Use `me |---------|-------------| | `mergai config` | Configure git settings (conflictstyle, notes display) | | `mergai fork merge-pick` | Get prioritized commits from upstream based on configured strategies | +| `mergai fork merge-pick --plan` | Token-free merge-gate decision (JSON): whether to merge and which sha (deterministic mode) | +| `mergai fork merge-pick --gate` | Token-free gate-respecting deterministic pick within the candidate window (bare sha) | +| `mergai fork merge-pick --ai` | AI-assisted pick of the merge boundary within the candidate window | | `mergai fork fetch` | Fetch upstream repository | | `mergai context init` | Initialize merge context with commit SHA and target branch | | `mergai notes update` | Fetch and merge notes from remote | diff --git a/src/mergai/commands/fork.py b/src/mergai/commands/fork.py index dc10ea8..e552894 100644 --- a/src/mergai/commands/fork.py +++ b/src/mergai/commands/fork.py @@ -5,8 +5,9 @@ from git import Commit from ..app import AppContext -from ..config import DEFAULT_CONFIG_PATH, MergePicksConfig +from ..config import DEFAULT_CONFIG_PATH, MergeGateConfig, MergePicksConfig from ..merge_pick_strategies import MergePickCommit, MergePickStrategyContext +from ..merge_pick_strategies.gate import GateDecision, evaluate_merge_gate from ..utils import git_utils from ..utils.output import OutputFormat, format_option from ..utils.util import ( @@ -183,6 +184,132 @@ def get_prioritized_commits( return prioritized +def restrict_to_window( + unmerged_commit_shas: list[str], max_commits: int | None +) -> tuple[list[str], int]: + """Restrict candidates to the oldest ``max_commits`` unmerged commits. + + ``unmerged_commit_shas`` is newest-first, so the oldest ``max_commits`` + commits are the *tail* of the list. The candidate window + (``base..base+max_commits``) bounds both the merge batch and the AI prompt + size; commits newer than the window are omitted here and drained by later + merges. + + Args: + unmerged_commit_shas: All unmerged commit SHAs, newest first. + max_commits: Batch ceiling, or None/<=0 to disable capping. + + Returns: + A tuple of ``(window_shas_newest_first, omitted_count)`` where + ``omitted_count`` is the number of newer commits dropped from the window. + """ + if ( + max_commits is None + or max_commits <= 0 + or len(unmerged_commit_shas) <= max_commits + ): + return unmerged_commit_shas, 0 + window = unmerged_commit_shas[-max_commits:] + omitted = len(unmerged_commit_shas) - max_commits + return window, omitted + + +def resolve_deterministic_sha( + window_shas: list[str], + prioritized: list[MergePickCommit], + gate_cfg: MergeGateConfig, +) -> str | None: + """Resolve the deterministic next-pick sha within the candidate window. + + Walks the window oldest-first and cuts at: + + * a ``force_strategies`` match *below* ``min_commits`` - a risky commit + (e.g. conflict / important_files) is merged in its own small batch + rather than deferred; otherwise + * the first prioritized strategy match once at least ``min_commits`` + commits would be merged - the first boundary in the + ``[min_commits, max_commits]`` band, forced or not; otherwise + * the window tip - the ``max_commits`` boundary (the window never extends + past ``max_commits``). + + A forced match at or after ``min_commits`` gets no special priority: it is + just one of the in-band boundaries, so the earliest in-band boundary wins. + A forced commit sitting past an earlier in-band boundary is excluded from + this batch and picked up by a later one (where it falls below + ``min_commits`` and triggers the early cut). Non-forced matches before + ``min_commits`` are skipped so batches are not nibbled one boundary at a + time. + + Args: + window_shas: Candidate window SHAs, newest first. + prioritized: Prioritized commits within the window (oldest first). + gate_cfg: Merge-gate config (``min_commits``, ``force_strategies``). + + Returns: + The chosen full SHA, or None when the window is empty. + """ + if not window_shas: + return None + + strategy_by_sha = {pc.commit.hexsha: pc.strategy_name for pc in prioritized} + force = set(gate_cfg.force_strategies or []) + + # Oldest-first walk; `count` is how many commits a cut here would merge. + oldest_first = list(reversed(window_shas)) + for count, sha in enumerate(oldest_first, start=1): + strategy = strategy_by_sha.get(sha) + if strategy is None: + continue + if strategy in force and count < gate_cfg.min_commits: + return sha # forced match below min: cut early to isolate it + if count >= gate_cfg.min_commits: + return sha # first in-band boundary (forced or not) + + # No forced match below min and no in-band boundary: cut at the window tip + # (the max boundary; the window never extends past max_commits). + return oldest_first[-1] + + +def compute_gate( + app: AppContext, + fork_status, + upstream_ref: str, + fork_ref: str, +) -> tuple[GateDecision, list[str], int, list[MergePickCommit]]: + """Evaluate the merge gate over the candidate window. + + Computes the candidate window from ``merge_gate.max_commits``, the + prioritized commits within it (used for force-strategy detection and the + deterministic pick), and the gate decision. + + Returns: + A tuple of ``(decision, window_shas, omitted_count, prioritized)``. + """ + merge_picks_config = app.config.fork.merge_picks or MergePicksConfig() + gate_cfg = app.config.fork.merge_gate + + window_shas, omitted = restrict_to_window( + fork_status.unmerged_commit_shas, gate_cfg.max_commits + ) + + # Only walk the window when there are strategies to evaluate. With no + # strategies, get_prioritized_commits would load every commit object in the + # window for nothing - wasteful now that `fork status` calls this on every + # diverged status. An empty `prioritized` is correct here: the gate's + # force-strategy check has nothing to match, and the deterministic resolver + # falls back to the window tip regardless. + if merge_picks_config.strategies: + context = MergePickStrategyContext(upstream_ref=upstream_ref, fork_ref=fork_ref) + prioritized = get_prioritized_commits( + app.repo, window_shas, merge_picks_config, context + ) + else: + prioritized = [] + + decision = evaluate_merge_gate(fork_status, prioritized, gate_cfg) + return decision, window_shas, omitted, prioritized + + def build_status_summary( app: AppContext, fork_status, @@ -319,6 +446,7 @@ def build_status_json( upstream_ref: str, prioritized: list[MergePickCommit] | None = None, include_commits: bool = False, + gate: GateDecision | None = None, ) -> dict: """Build JSON representation of fork status. @@ -328,6 +456,7 @@ def build_status_json( upstream_ref: Resolved upstream ref string. prioritized: Optional list of prioritized commits with strategy info. include_commits: Whether to include the full list of unmerged commits. + gate: Optional merge-gate decision to surface under a ``gate`` key. Returns: Dictionary suitable for JSON serialization. @@ -342,6 +471,10 @@ def build_status_json( # Add fork status info (includes divergence, key commits, etc.) result.update(fork_status.to_dict()) + # Surface the merge-gate decision for the Fork Status page / automation. + if gate is not None: + result["gate"] = {"open": gate.open, "reason": gate.reason} + # Add unmerged commits list if requested (-l flag) if include_commits and not fork_status.is_up_to_date: result["unmerged_commits"] = [ @@ -538,6 +671,35 @@ def status( context, ) + # Evaluate the merge gate (go/no-go) when diverged so it can be surfaced + # alongside the divergence info. Guarded so a gate failure never breaks + # status output. + gate_decision: GateDecision | None = None + if not fork_status.is_up_to_date: + try: + if prioritized is not None: + # Reuse the prioritized list already computed for -p instead of + # recomputing it: the gate only needs the window subset for its + # force-strategy check, and filtering the full (oldest-first) + # list to the window is equivalent to evaluating over the window. + gate_cfg = app.config.fork.merge_gate + window_shas, _ = restrict_to_window( + fork_status.unmerged_commit_shas, gate_cfg.max_commits + ) + window_set = set(window_shas) + window_prioritized = [ + pc for pc in prioritized if pc.commit.hexsha in window_set + ] + gate_decision = evaluate_merge_gate( + fork_status, window_prioritized, gate_cfg + ) + else: + gate_decision, _, _, _ = compute_gate( + app, fork_status, upstream_ref, fork_ref + ) + except Exception as e: + log.warning("Failed to evaluate merge gate: %s", e, exc_info=True) + # Handle JSON output format if format == OutputFormat.JSON.value: output_dict = build_status_json( @@ -546,12 +708,21 @@ def status( upstream_ref, prioritized=prioritized if show_merge_picks else None, include_commits=list_commits, + gate=gate_decision, ) print(json.dumps(output_dict, indent=2, default=str)) return # Build status summary output (text format) output_lines = build_status_summary(app, fork_status, upstream_ref) + if gate_decision is not None: + # The closed-gate reason already begins with "wait (...)", so only the + # open case needs a state prefix - avoids "wait (wait (...))". + if gate_decision.open: + output_lines.append(f"Merge gate: open ({gate_decision.reason})") + else: + output_lines.append(f"Merge gate: {gate_decision.reason}") + output_lines.append("") # Commit listing based on options: # -p only: show only merge picks @@ -616,12 +787,47 @@ def status( default=False, help="List all unmerged commits with picks marked (like fork status -lp)", ) +@click.option( + "--plan", + "plan", + is_flag=True, + default=False, + help=( + "Token-free phase-1 decision: evaluate the merge gate and print a JSON " + "decision (wait / merge). Deterministic mode resolves the sha; AI mode " + "leaves it null." + ), +) +@click.option( + "--gate", + "gate", + is_flag=True, + default=False, + help=( + "Token-free gate-respecting deterministic pick: re-checks the gate " + "(unless --force) and prints the gate-windowed deterministic pick as a " + "bare sha - a forced-strategy match below min_commits, else the first " + "prioritized match within the [min_commits, max_commits] band, else the " + "window tip. Prints nothing when up to date or the gate is closed. The " + "deterministic sibling of --ai, independent of fork.ai_pick.enabled." + ), +) +@click.option( + "--force", + "force", + is_flag=True, + default=False, + help="With --gate, skip the gate re-check and pick regardless.", +) def merge_pick( app: AppContext, upstream_ref: str | None, fork_ref: str, next_only: bool, list_commits: bool, + plan: bool, + gate: bool, + force: bool, ): """Suggest commits to merge based on configured priority strategies. @@ -638,14 +844,33 @@ def merge_pick( Use --next/-n to get just the hash of the recommended next commit. Use --list/-l to show all unmerged commits with picks marked. + Use --plan for the token-free gate decision (JSON), and --gate for the + gate-respecting deterministic pick. \b Examples: mergai fork merge-pick # List prioritized commits mergai fork merge-pick --list # List all commits with picks marked mergai fork merge-pick --next # Get next commit hash + mergai fork merge-pick --plan # Phase-1 gate decision (JSON) + mergai fork merge-pick --gate # Gate-respecting deterministic pick (sha only) mergai fork merge-pick mongodb/master # Use specific upstream ref """ + selected_modes = [ + name for name, used in (("--plan", plan), ("--gate", gate)) if used + ] + if len(selected_modes) > 1: + click.echo( + f"Error: {', '.join(selected_modes)} are mutually exclusive.", err=True + ) + raise SystemExit(1) + + # --force applies to the gate-respecting pick (--gate). Fail fast rather than + # silently ignore so automation can't come to rely on a no-op flag. + if force and not gate: + click.echo("Error: --force requires --gate.", err=True) + raise SystemExit(1) + upstream_ref = resolve_upstream_ref(app, upstream_ref) # Get fork status to obtain unmerged commits @@ -655,6 +880,14 @@ def merge_pick( click.echo(f"Error: Failed to get fork status: {e}", err=True) raise SystemExit(1) from e + if plan: + _merge_pick_plan(app, fork_status, upstream_ref, fork_ref) + return + + if gate: + _merge_pick_gate(app, fork_status, upstream_ref, fork_ref, force) + return + if fork_status.is_up_to_date: # No unmerged commits - nothing to do # For --next, output nothing (success with empty output) @@ -717,3 +950,87 @@ def merge_pick( output_lines.append("(no merge picks found)") print_or_page("\n".join(output_lines)) + + +def _merge_pick_plan( + app: AppContext, + fork_status, + upstream_ref: str, + fork_ref: str, +) -> None: + """Phase-1 decision (token-free): print the gate's JSON decision. + + Evaluates the merge gate and reads the mode from ``fork.ai_pick.enabled``. + In deterministic mode the sha is resolved here (capped to the + ``max_commits`` window); in AI mode the sha is left null (decided in + phase 2). When the fork is up to date, reports ``wait``. + """ + if fork_status.is_up_to_date: + print(json.dumps({"action": "wait", "reason": "up to date (0 commits)"})) + return + + decision, window_shas, _omitted, prioritized = compute_gate( + app, fork_status, upstream_ref, fork_ref + ) + + if not decision.open: + print(json.dumps({"action": "wait", "reason": decision.reason})) + return + + if app.config.fork.ai_pick.enabled: + output = { + "action": "merge", + "mode": "ai", + "sha": None, + "reason": decision.reason, + } + else: + output = { + "action": "merge", + "mode": "deterministic", + "sha": resolve_deterministic_sha( + window_shas, prioritized, app.config.fork.merge_gate + ), + "reason": decision.reason, + } + print(json.dumps(output)) + + +def _merge_pick_gate( + app: AppContext, + fork_status, + upstream_ref: str, + fork_ref: str, + force: bool, +) -> None: + """Gate-respecting deterministic pick (token-free), as a bare sha. + + The deterministic sibling of ``_merge_pick_ai``: re-checks the merge gate + (unless ``force``) and, when it is open, prints the gate-windowed + deterministic pick (see ``resolve_deterministic_sha`` - a forced match + below ``min_commits``, else the first prioritized match in the + ``[min_commits, max_commits]`` band, else the window tip; the window never + advances past ``max_commits``). Prints nothing (exit 0) when the fork is up + to date or the gate is closed, so callers can capture stdout safely. + Independent of + ``fork.ai_pick.enabled`` (that flag only affects ``--plan``'s reported mode). + """ + if fork_status.is_up_to_date: + return + + decision, window_shas, _omitted, prioritized = compute_gate( + app, fork_status, upstream_ref, fork_ref + ) + + if not force and not decision.open: + click.echo(f"Merge gate closed: {decision.reason}", err=True) + return + + sha = resolve_deterministic_sha( + window_shas, prioritized, app.config.fork.merge_gate + ) + if sha is None: + click.echo("No candidate commits in the window.", err=True) + return + + click.echo(sha) diff --git a/src/mergai/config.py b/src/mergai/config.py index db9d1a3..60f0ab5 100644 --- a/src/mergai/config.py +++ b/src/mergai/config.py @@ -20,6 +20,132 @@ DEFAULT_COMMIT_FIELDS = ["hexsha"] +@dataclass +class MergeGateConfig: + """Deterministic gate controlling *when* a merge happens. + + The gate is a pure go/no-go decision evaluated over already-computed fork + status + prioritized commits (no AI tokens), so it is safe to run in the + unprivileged periodic phase. It is mode-agnostic: it only decides whether to + merge now, not which commit to merge to. + + Attributes: + min_commits: Merge once at least this many unmerged commits accumulate. + max_age_days: ...or sooner if the oldest unmerged commit is older than + this many days. + max_commits: Never advance more than this many upstream commits in a + single merge. Defines the candidate window (the oldest + ``max_commits`` unmerged commits); bounds both the merge batch size + and the AI prompt size. Commits newer than the window are omitted + and drained by later merges. + force_strategies: Merge-pick strategy names that, when any prioritized + commit matches one, open the gate immediately regardless of count or + age (e.g. ``conflict``, ``important_files``). + """ + + min_commits: int = 50 + max_age_days: int = 2 + max_commits: int = 150 + force_strategies: list[str] = field( + default_factory=lambda: ["conflict", "important_files"] + ) + + @classmethod + def from_dict(cls, data: dict) -> "MergeGateConfig": + """Create a MergeGateConfig from a dictionary. + + Raises: + ValueError: If an integer field is non-integer, or + ``force_strategies`` is neither a string nor a list of strings. + """ + + def _int(name: str, default: int) -> int: + # `null` (key present, value None) falls back to the default; a + # non-integer fails fast here rather than later as a TypeError in + # gate evaluation (e.g. ``commits_behind >= None``). bool is a + # subclass of int but is almost certainly a YAML mistake here. + value = data.get(name) + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"'merge_gate.{name}' must be an integer, " + f"got {type(value).__name__}" + ) + return value + + # `null` (key present, value None) falls back to the defaults; an + # explicit empty list still intentionally disables force strategies. A + # bare string is accepted as a single strategy name so a stray + # ``force_strategies: conflict`` is not silently split into characters + # by ``list(...)``. + raw = data.get("force_strategies") + if raw is None: + force_strategies = list(cls().force_strategies) + elif isinstance(raw, str): + force_strategies = [raw] + elif isinstance(raw, list) and all(isinstance(s, str) for s in raw): + force_strategies = list(raw) + else: + raise ValueError( + "'merge_gate.force_strategies' must be a string or a list of " + f"strings, got {type(raw).__name__}" + ) + return cls( + min_commits=_int("min_commits", cls.min_commits), + max_age_days=_int("max_age_days", cls.max_age_days), + max_commits=_int("max_commits", cls.max_commits), + force_strategies=force_strategies, + ) + + +@dataclass +class AiPickConfig: + """Configuration for the AI-assisted merge pick. + + When enabled, the privileged merge phase (``merge-pick --ai``) asks an AI + agent which upstream commit to merge to, within the gate's candidate + window. When disabled, the pick is made deterministically. + + Attributes: + enabled: Whether the AI pick is used. When False, ``merge-pick --plan`` + reports ``mode: deterministic`` and resolves the sha itself. + agent: Agent descriptor (e.g. ``claude-cli:claude-opus-4-5``), same + format as ``resolve.agent``. Empty falls back to ``resolve.agent``. + rules_file: Optional path to a project-specific merge-pick rules file + (markdown) appended to the built-in system prompt. + fallback: What to do on agent error / invalid sha: ``deterministic`` + (resilient, the default) or ``error``. + """ + + enabled: bool = False + agent: str = "" + rules_file: str = "" + fallback: str = "deterministic" + + @classmethod + def from_dict(cls, data: dict) -> "AiPickConfig": + """Create an AiPickConfig from a dictionary. + + Raises: + ValueError: If ``fallback`` is not one of ``deterministic`` / + ``error`` (an unknown value would otherwise be silently treated + as ``deterministic``). + """ + fallback = data.get("fallback", cls.fallback) + if fallback not in ("deterministic", "error"): + raise ValueError( + f"Invalid value for ai_pick.fallback: '{fallback}'. " + "Valid values are: deterministic, error" + ) + return cls( + enabled=data.get("enabled", cls.enabled), + agent=data.get("agent", cls.agent), + rules_file=data.get("rules_file", cls.rules_file), + fallback=fallback, + ) + + @dataclass class ForkConfig: """Configuration for the fork subcommand. @@ -29,12 +155,16 @@ class ForkConfig: upstream_branch: Branch name to use when auto-detecting upstream ref. upstream_remote: Name of the git remote for upstream (if not set, derived from URL). merge_picks: Configuration for commit prioritization in fork merge-pick. + merge_gate: Deterministic gate controlling when to merge. + ai_pick: Configuration for the AI-assisted merge pick. """ upstream_url: str | None = None upstream_branch: str = "master" upstream_remote: str | None = None merge_picks: Optional["MergePicksConfig"] = None + merge_gate: MergeGateConfig = field(default_factory=MergeGateConfig) + ai_pick: AiPickConfig = field(default_factory=AiPickConfig) @classmethod def from_dict(cls, data: dict) -> "ForkConfig": @@ -51,11 +181,25 @@ def from_dict(cls, data: dict) -> "ForkConfig": MergePicksConfig.from_dict(merge_picks_data) if merge_picks_data else None ) + merge_gate_data = data.get("merge_gate") + merge_gate = ( + MergeGateConfig.from_dict(merge_gate_data) + if merge_gate_data + else MergeGateConfig() + ) + + ai_pick_data = data.get("ai_pick") + ai_pick = ( + AiPickConfig.from_dict(ai_pick_data) if ai_pick_data else AiPickConfig() + ) + return cls( upstream_url=data.get("upstream_url"), upstream_branch=data.get("upstream_branch", cls.upstream_branch), upstream_remote=data.get("upstream_remote"), merge_picks=merge_picks, + merge_gate=merge_gate, + ai_pick=ai_pick, ) diff --git a/src/mergai/merge_pick_strategies/gate.py b/src/mergai/merge_pick_strategies/gate.py new file mode 100644 index 0000000..b13210f --- /dev/null +++ b/src/mergai/merge_pick_strategies/gate.py @@ -0,0 +1,85 @@ +"""Deterministic merge gate. + +The gate decides *whether* to merge now; it does not decide *which* commit to +merge to (that is the pick, deterministic or AI). It is a pure function over +already-computed data (fork status + prioritized commits + gate config), so it +needs no AI tokens and is safe to run in the unprivileged periodic phase. It is +also mode-agnostic: the same decision drives both deterministic and AI picks. +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..config import MergeGateConfig + from ..utils.git_utils import ForkStatus + from .base import MergePickCommit + + +@dataclass +class GateDecision: + """The gate's go/no-go verdict. + + Attributes: + open: True if a merge should happen now. + reason: Human-readable explanation (e.g. ``"force:conflict"``, + ``"min_commits (63 >= 50)"``, ``"wait (12 < 50 commits; ...)"``). + """ + + open: bool + reason: str + + +def evaluate_merge_gate( + fork_status: "ForkStatus", + prioritized: "list[MergePickCommit] | None", + cfg: "MergeGateConfig", +) -> GateDecision: + """Decide whether to merge now. + + Open if (in priority order): + - any prioritized match's strategy is in ``force_strategies`` + (reason ``"force:"``), or + - ``commits_behind >= min_commits`` (reason ``"min_commits"``), or + - the oldest unmerged commit is at least ``max_age_days`` old + (reason ``"max_age"``). + Otherwise wait. + + Args: + fork_status: Fork divergence info (provides ``commits_behind`` and + ``unmerged_oldest_age_days``). + prioritized: Prioritized commits within the candidate window (used only + for the force-strategy check). May be None/empty. + cfg: The merge-gate configuration. + + Returns: + A :class:`GateDecision`. + """ + force = set(cfg.force_strategies or []) + if force and prioritized: + for pick in prioritized: + if pick.strategy_name in force: + return GateDecision(open=True, reason=f"force:{pick.strategy_name}") + + commits_behind = fork_status.commits_behind + if commits_behind >= cfg.min_commits: + return GateDecision( + open=True, + reason=f"min_commits ({commits_behind} >= {cfg.min_commits})", + ) + + age = fork_status.unmerged_oldest_age_days + if age is not None and age >= cfg.max_age_days: + return GateDecision( + open=True, + reason=f"max_age ({age:.1f}d >= {cfg.max_age_days}d)", + ) + + age_str = f"{age:.1f}d" if age is not None else "n/a" + return GateDecision( + open=False, + reason=( + f"wait ({commits_behind} < {cfg.min_commits} commits; " + f"oldest {age_str} < {cfg.max_age_days}d)" + ), + ) diff --git a/src/mergai/utils/git_utils.py b/src/mergai/utils/git_utils.py index 25da084..1a2bc8b 100644 --- a/src/mergai/utils/git_utils.py +++ b/src/mergai/utils/git_utils.py @@ -844,6 +844,28 @@ def days_behind(self) -> int: now = datetime.now(tz=timezone.utc) return (now - last_merged_date).days + @property + def unmerged_oldest_age_days(self) -> float | None: + """Days since the oldest unmerged commit was authored. + + This is the age of the *front* of the unmerged backlog - the signal the + merge gate uses to decide whether the oldest pending change has waited + long enough to merge. It is distinct from :attr:`days_behind`, which + measures time since the last *merged* commit (the wrong signal for a + gate that asks "how stale is the oldest thing we still owe?"). + + Returns the fractional number of days, or None when the fork is up to + date (no unmerged commits). + """ + if not self.first_unmerged_commit: + return None + + authored_date = datetime.fromtimestamp( + self.first_unmerged_commit.authored_date, tz=timezone.utc + ) + now = datetime.now(tz=timezone.utc) + return (now - authored_date).total_seconds() / 86400.0 + @property def unmerged_date_range(self) -> tuple[datetime, datetime] | None: """Get the date range of unmerged commits (first, last).""" @@ -885,6 +907,7 @@ def to_dict(self) -> dict: divergence: dict[str, Any] = { "commits_behind": self.commits_behind, "days_behind": self.days_behind, + "unmerged_oldest_age_days": self.unmerged_oldest_age_days, "files_affected": self.files_affected, "total_additions": self.total_additions, "total_deletions": self.total_deletions, diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py new file mode 100644 index 0000000..ba9b969 --- /dev/null +++ b/tests/test_merge_gate.py @@ -0,0 +1,263 @@ +"""Tests for the deterministic merge gate and its supporting helpers. + +Covers the pure ``evaluate_merge_gate`` decision (wait / count / age / force / +disabled), the ``max_commits`` candidate-window capping, the deterministic +pick resolution, agent-sha resolution against the window, and config parsing +for ``merge_gate`` / ``ai_pick``. All hand-built stubs - no git repo. +""" + +from types import SimpleNamespace + +from mergai.commands.fork import ( + resolve_deterministic_sha, + restrict_to_window, +) +from mergai.config import AiPickConfig, MergaiConfig, MergeGateConfig +from mergai.merge_pick_strategies.gate import evaluate_merge_gate + + +def _fork_status(commits_behind, age_days): + return SimpleNamespace( + commits_behind=commits_behind, + unmerged_oldest_age_days=age_days, + ) + + +def _pick(strategy_name, sha="x"): + return SimpleNamespace( + strategy_name=strategy_name, + commit=SimpleNamespace(hexsha=sha), + ) + + +# --- evaluate_merge_gate -------------------------------------------------- + + +def test_gate_waits_below_thresholds(): + cfg = MergeGateConfig() # min 50, age 2 + decision = evaluate_merge_gate(_fork_status(12, 0.3), [], cfg) + assert decision.open is False + assert decision.reason == "wait (12 < 50 commits; oldest 0.3d < 2d)" + + +def test_gate_opens_on_min_commits(): + cfg = MergeGateConfig() + decision = evaluate_merge_gate(_fork_status(63, 0.3), [], cfg) + assert decision.open is True + assert decision.reason == "min_commits (63 >= 50)" + + +def test_gate_opens_on_max_age(): + cfg = MergeGateConfig() + decision = evaluate_merge_gate(_fork_status(12, 3.5), [], cfg) + assert decision.open is True + assert decision.reason == "max_age (3.5d >= 2d)" + + +def test_gate_opens_on_force_strategy(): + cfg = MergeGateConfig() + decision = evaluate_merge_gate( + _fork_status(12, 0.3), + [_pick("huge_commit"), _pick("conflict")], + cfg, + ) + assert decision.open is True + assert decision.reason == "force:conflict" + + +def test_gate_force_takes_priority_over_count(): + # Even above min_commits, a forced strategy gives the more specific reason. + cfg = MergeGateConfig() + decision = evaluate_merge_gate( + _fork_status(99, 9.0), [_pick("important_files")], cfg + ) + assert decision.reason == "force:important_files" + + +def test_gate_ignores_non_force_strategies(): + cfg = MergeGateConfig(force_strategies=["conflict"]) + decision = evaluate_merge_gate( + _fork_status(12, 0.3), [_pick("huge_commit"), _pick("branching_point")], cfg + ) + assert decision.open is False + + +def test_gate_empty_force_list_disables_force(): + cfg = MergeGateConfig(force_strategies=[]) + decision = evaluate_merge_gate(_fork_status(12, 0.3), [_pick("conflict")], cfg) + assert decision.open is False + + +def test_gate_handles_missing_age(): + cfg = MergeGateConfig() + decision = evaluate_merge_gate(_fork_status(12, None), [], cfg) + assert decision.open is False + assert "oldest n/a" in decision.reason + + +# --- restrict_to_window --------------------------------------------------- + + +def test_window_no_capping_when_under_max(): + shas = ["c", "b", "a"] # newest-first + window, omitted = restrict_to_window(shas, 10) + assert window == shas + assert omitted == 0 + + +def test_window_keeps_oldest_max_commits(): + # newest-first: e d c b a ; oldest two are a, b -> window tail ["b", "a"] + shas = ["e", "d", "c", "b", "a"] + window, omitted = restrict_to_window(shas, 2) + assert window == ["b", "a"] + assert omitted == 3 + + +def test_window_disabled_with_none_or_zero(): + shas = ["c", "b", "a"] + assert restrict_to_window(shas, None) == (shas, 0) + assert restrict_to_window(shas, 0) == (shas, 0) + + +# --- resolve_deterministic_sha -------------------------------------------- + + +def test_deterministic_forced_match_overrides_min(): + # A force-strategy match is honored even before min_commits. + cfg = MergeGateConfig(min_commits=50) # force defaults include "conflict" + window = ["c", "b", "a"] # newest-first -> oldest-first a, b, c + prioritized = [_pick("conflict", sha="a")] # 'a' at count 1 + assert resolve_deterministic_sha(window, prioritized, cfg) == "a" + + +def test_deterministic_forced_at_or_after_min_has_no_priority(): + # A forced match is special only *below* min_commits. At/after min it is + # just an in-band boundary, so an earlier in-band match wins and the later + # forced commit is excluded from this batch (picked up by a later one). + cfg = MergeGateConfig(min_commits=2, force_strategies=["conflict"]) + window = ["c", "b", "a"] # oldest-first a(1), b(2), c(3) + prioritized = [_pick("huge_commit", sha="b"), _pick("conflict", sha="c")] + assert resolve_deterministic_sha(window, prioritized, cfg) == "b" + + +def test_deterministic_skips_non_forced_match_before_min(): + # A non-forced match before min_commits is skipped; the first match at/after + # min_commits is the cut point. + cfg = MergeGateConfig(min_commits=2, force_strategies=[]) + window = ["c", "b", "a"] # oldest-first a(1), b(2), c(3) + prioritized = [_pick("huge_commit", sha="a"), _pick("huge_commit", sha="c")] + assert resolve_deterministic_sha(window, prioritized, cfg) == "c" + + +def test_deterministic_window_tip_when_no_match_at_or_after_min(): + # Only a pre-min non-forced match exists -> cut at the window tip (newest). + cfg = MergeGateConfig(min_commits=2, force_strategies=[]) + window = ["c", "b", "a"] # oldest-first a(1), b(2), c(3) + prioritized = [_pick("huge_commit", sha="a")] + assert resolve_deterministic_sha(window, prioritized, cfg) == "c" + + +def test_deterministic_falls_back_to_window_tip(): + # No prioritized match -> cap at the window's newest commit (element 0). + cfg = MergeGateConfig() + window = ["c", "b", "a"] + assert resolve_deterministic_sha(window, [], cfg) == "c" + + +def test_deterministic_empty_window(): + assert resolve_deterministic_sha([], [], MergeGateConfig()) is None + + +# --- config parsing ------------------------------------------------------- + + +def test_merge_gate_config_defaults(): + cfg = MergeGateConfig() + assert cfg.min_commits == 50 + assert cfg.max_age_days == 2 + assert cfg.max_commits == 150 + assert cfg.force_strategies == ["conflict", "important_files"] + + +def test_fork_config_parses_gate_and_ai_pick(): + data = { + "fork": { + "merge_gate": { + "min_commits": 30, + "max_age_days": 5, + "max_commits": 100, + "force_strategies": ["conflict"], + }, + "ai_pick": { + "enabled": True, + "agent": "claude-cli:claude-opus-4-5", + "rules_file": ".mergai/merge_pick_rules.md", + "fallback": "error", + }, + } + } + cfg = MergaiConfig.from_dict(data) + assert cfg.fork.merge_gate == MergeGateConfig( + min_commits=30, + max_age_days=5, + max_commits=100, + force_strategies=["conflict"], + ) + assert cfg.fork.ai_pick == AiPickConfig( + enabled=True, + agent="claude-cli:claude-opus-4-5", + rules_file=".mergai/merge_pick_rules.md", + fallback="error", + ) + + +def test_fork_config_uses_defaults_when_sections_absent(): + cfg = MergaiConfig.from_dict({"fork": {}}) + assert cfg.fork.merge_gate == MergeGateConfig() + assert cfg.fork.ai_pick == AiPickConfig() + + +def test_merge_gate_force_strategies_null_falls_back_to_defaults(): + # `force_strategies: null` must not raise (list(None)); it uses defaults. + cfg = MergeGateConfig.from_dict({"force_strategies": None}) + assert cfg.force_strategies == ["conflict", "important_files"] + + +def test_merge_gate_force_strategies_empty_list_disables(): + # An explicit empty list intentionally disables force strategies. + cfg = MergeGateConfig.from_dict({"force_strategies": []}) + assert cfg.force_strategies == [] + + +def test_merge_gate_force_strategies_single_string_normalized(): + # A bare string is a single strategy name, not split into characters. + cfg = MergeGateConfig.from_dict({"force_strategies": "conflict"}) + assert cfg.force_strategies == ["conflict"] + + +def test_merge_gate_force_strategies_invalid_type_raises(): + import pytest + + with pytest.raises(ValueError, match="force_strategies"): + MergeGateConfig.from_dict({"force_strategies": 123}) + with pytest.raises(ValueError, match="force_strategies"): + MergeGateConfig.from_dict({"force_strategies": [1, 2]}) + + +def test_merge_gate_int_fields_null_fall_back_to_defaults(): + # `min_commits: null` etc. must not pass None through (which would crash + # gate evaluation later with a TypeError); they fall back to the defaults. + cfg = MergeGateConfig.from_dict( + {"min_commits": None, "max_age_days": None, "max_commits": None} + ) + assert (cfg.min_commits, cfg.max_age_days, cfg.max_commits) == (50, 2, 150) + + +def test_merge_gate_int_fields_invalid_type_raises(): + import pytest + + for field in ("min_commits", "max_age_days", "max_commits"): + with pytest.raises(ValueError, match=field): + MergeGateConfig.from_dict({field: "50"}) + with pytest.raises(ValueError, match=field): + MergeGateConfig.from_dict({field: True}) From c9b4bfe6f81ac541277ced55ac9eda33884f9eee Mon Sep 17 00:00:00 2001 From: Pawel Lebioda Date: Mon, 22 Jun 2026 11:50:13 +0200 Subject: [PATCH 2/2] Add AI-assisted merge pick to `fork merge-pick` Builds on the deterministic gate/pick: when fork.ai_pick is enabled, an AI agent chooses the merge boundary within the candidate window instead of the deterministic resolver. - config: `AiPickConfig.fallback` is validated against {deterministic, error}. - prompts: `system_prompt_merge_pick.md` + loader + `build_merge_pick_prompt` (system prompt + optional project rules + candidate JSON). - fork.py: `merge-pick --ai [--next] [--force] [--json]` (agent pick within the window, sha + reasoning validation, deterministic/error fallback, clean stdout in capture modes), the candidate-window input builder, and the agent-sha resolver. - docs + tests for the AI pick output, validation, and fallback. --- README.md | 19 +- src/mergai/commands/fork.py | 321 +++++++++++++-- src/mergai/config.py | 12 +- src/mergai/prompt_builder.py | 35 ++ src/mergai/prompts/__init__.py | 4 + .../prompts/system_prompt_merge_pick.md | 44 ++ tests/test_merge_gate.py | 27 +- tests/test_merge_pick_ai.py | 378 ++++++++++++++++++ 8 files changed, 778 insertions(+), 62 deletions(-) create mode 100644 src/mergai/prompts/system_prompt_merge_pick.md create mode 100644 tests/test_merge_pick_ai.py diff --git a/README.md b/README.md index 935373b..2a68b59 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Set `most_recent_fallback: true` to select the most recent unmerged commit when ### Merge Gate and AI Pick -A deterministic **gate** decides *when* to merge; the **pick** (deterministic or AI) decides *which* upstream commit to merge to. The gate is a pure go/no-go decision over already-computed fork status, so it needs no AI tokens and is safe to run in an unprivileged/periodic phase. +A deterministic **gate** decides *when* to merge; the **pick** decides *which* upstream commit to merge to. The gate is a pure go/no-go decision over already-computed fork status, so it needs no AI tokens. `fork.merge_gate` opens the gate when any of the following hold (in order): @@ -157,32 +157,31 @@ A deterministic **gate** decides *when* to merge; the **pick** (deterministic or `max_commits` defines the **candidate window** - the oldest `max_commits` unmerged commits (`base..base+max_commits`). It bounds both the merge batch size and the AI prompt size; commits newer than the window are omitted (counted) and drained by later merges. Defaults are tied to the historical merge cadence (median ~47 commits/merge, p75 ~67). -`fork.ai_pick` controls the pick made once the gate opens: +`fork.ai_pick` configures the AI pick (`mergai fork merge-pick --ai`): | Setting | Default | Description | |---------|---------|-------------| -| `enabled` | `false` | When `false`, the pick is deterministic. When `true`, an AI agent chooses the merge boundary within the window. | | `agent` | `""` | Agent descriptor (e.g. `claude-cli:claude-opus-4-5`), same format as `resolve.agent`. Empty falls back to `resolve.agent`. | | `rules_file` | `""` | Optional path to a project-specific merge-pick rules markdown file, appended to the built-in system prompt. | | `fallback` | `deterministic` | On agent error / invalid sha: `deterministic` (resilient) or `error`. | -The two phases are exposed as: +The gate decision and the picks are separate, explicit commands: ```bash -mergai fork merge-pick --plan # token-free gate decision (JSON): wait / merge (+ mode, sha) -mergai fork merge-pick --ai --next # privileged AI pick within the window; prints the chosen sha +mergai fork merge-pick --plan # token-free gate decision (JSON): action + reason +mergai fork merge-pick --gate # gate-respecting deterministic pick; prints the chosen sha +mergai fork merge-pick --ai --next # AI pick within the window; prints the chosen sha mergai fork merge-pick --ai --force # skip the gate re-check and pick regardless ``` -`--plan` emits a JSON decision the periodic workflow consumes, e.g.: +`--plan` emits the gate's go/no-go decision the periodic workflow consumes, e.g.: ```json { "action": "wait", "reason": "wait (12 < 50 commits; oldest 0.3d < 2d)" } -{ "action": "merge", "mode": "deterministic", "sha": "", "reason": "min_commits (63 >= 50)" } -{ "action": "merge", "mode": "ai", "sha": null, "reason": "force:conflict" } +{ "action": "merge", "reason": "min_commits (63 >= 50)" } ``` -The gate decision is also surfaced in `mergai fork status` (text and `--json`). +Which commit to merge to is then chosen explicitly with `--gate` (deterministic) or `--ai`. The gate decision is also surfaced in `mergai fork status` (text and `--json`). ### Branch Naming Format diff --git a/src/mergai/commands/fork.py b/src/mergai/commands/fork.py index e552894..7995454 100644 --- a/src/mergai/commands/fork.py +++ b/src/mergai/commands/fork.py @@ -1,5 +1,8 @@ +import contextlib import json import logging +import sys +from contextlib import nullcontext import click from git import Commit @@ -310,6 +313,57 @@ def compute_gate( return decision, window_shas, omitted, prioritized +def build_merge_pick_input( + app: AppContext, + fork_status, + window_shas: list[str], + omitted: int, + prioritized: list[MergePickCommit], + decision: GateDecision, +) -> dict: + """Build the AI merge-pick agent input for the candidate window. + + Each candidate carries its sha, summary, author, date, size stats, and the + strategy (if any) that matched it. The payload also includes cumulative + divergence, the omitted-tail count, and the gate decision/reason. + """ + picks_by_sha = {pc.commit.hexsha: pc for pc in prioritized} + stats = git_utils.get_batch_commit_stats(app.repo, window_shas) + + candidates = [] + # Present oldest-first so the window reads as a forward timeline. + for sha in reversed(window_shas): + commit = app.repo.commit(sha) + commit_stats = stats.get(sha) + pick = picks_by_sha.get(sha) + candidates.append( + { + "sha": sha, + "summary": commit.summary, + "author": commit.author.name, + "date": commit.authored_datetime.isoformat(), + "files": commit_stats.files_changed if commit_stats else None, + "lines_added": commit_stats.lines_added if commit_stats else None, + "lines_deleted": commit_stats.lines_deleted if commit_stats else None, + "dirs": commit_stats.num_of_dirs if commit_stats else None, + "strategy": pick.strategy_name if pick else None, + } + ) + + return { + "gate": {"open": decision.open, "reason": decision.reason}, + "divergence": { + "commits_behind": fork_status.commits_behind, + "files_affected": fork_status.files_affected, + "total_additions": fork_status.total_additions, + "total_deletions": fork_status.total_deletions, + "unmerged_oldest_age_days": fork_status.unmerged_oldest_age_days, + }, + "window": {"size": len(window_shas), "omitted_tail": omitted}, + "candidates": candidates, + } + + def build_status_summary( app: AppContext, fork_status, @@ -793,9 +847,19 @@ def status( is_flag=True, default=False, help=( - "Token-free phase-1 decision: evaluate the merge gate and print a JSON " - "decision (wait / merge). Deterministic mode resolves the sha; AI mode " - "leaves it null." + "Token-free gate decision: evaluate the merge gate and print a JSON " + 'verdict, {"action": "merge"|"wait", "reason": ...}. The go/no-go only; ' + "which commit to merge to is a separate pick (--gate/--ai/--next)." + ), +) +@click.option( + "--ai", + "ai", + is_flag=True, + default=False, + help=( + "AI pick: ask the configured agent which commit in the candidate window " + "to merge to. Re-checks the gate unless --force." ), ) @click.option( @@ -809,7 +873,7 @@ def status( "bare sha - a forced-strategy match below min_commits, else the first " "prioritized match within the [min_commits, max_commits] band, else the " "window tip. Prints nothing when up to date or the gate is closed. The " - "deterministic sibling of --ai, independent of fork.ai_pick.enabled." + "deterministic counterpart of --ai." ), ) @click.option( @@ -817,7 +881,17 @@ def status( "force", is_flag=True, default=False, - help="With --gate, skip the gate re-check and pick regardless.", + help="With --ai or --gate, skip the gate re-check and pick regardless.", +) +@click.option( + "--json", + "as_json", + is_flag=True, + default=False, + help=( + "With --ai, emit the pick as a one-line JSON object with keys " + "sha, short_sha, reasoning, source - for further processing." + ), ) def merge_pick( app: AppContext, @@ -826,8 +900,10 @@ def merge_pick( next_only: bool, list_commits: bool, plan: bool, + ai: bool, gate: bool, force: bool, + as_json: bool, ): """Suggest commits to merge based on configured priority strategies. @@ -844,20 +920,25 @@ def merge_pick( Use --next/-n to get just the hash of the recommended next commit. Use --list/-l to show all unmerged commits with picks marked. - Use --plan for the token-free gate decision (JSON), and --gate for the - gate-respecting deterministic pick. + Use --plan for the token-free gate decision (JSON), and --ai for the + privileged AI pick. \b Examples: mergai fork merge-pick # List prioritized commits mergai fork merge-pick --list # List all commits with picks marked mergai fork merge-pick --next # Get next commit hash - mergai fork merge-pick --plan # Phase-1 gate decision (JSON) + mergai fork merge-pick --plan # Gate decision (JSON: action/reason) + mergai fork merge-pick --ai # AI pick (sha + reasoning) + mergai fork merge-pick --ai --next # AI pick (sha only, for capture) + mergai fork merge-pick --ai --json # AI pick (JSON, for processing) mergai fork merge-pick --gate # Gate-respecting deterministic pick (sha only) mergai fork merge-pick mongodb/master # Use specific upstream ref """ selected_modes = [ - name for name, used in (("--plan", plan), ("--gate", gate)) if used + name + for name, used in (("--plan", plan), ("--ai", ai), ("--gate", gate)) + if used ] if len(selected_modes) > 1: click.echo( @@ -865,10 +946,14 @@ def merge_pick( ) raise SystemExit(1) - # --force applies to the gate-respecting pick (--gate). Fail fast rather than - # silently ignore so automation can't come to rely on a no-op flag. - if force and not gate: - click.echo("Error: --force requires --gate.", err=True) + # --force applies to the gate-respecting picks (--ai / --gate); --json only to + # --ai. Fail fast rather than silently ignore so automation can't come to rely + # on a no-op flag. + if force and not (ai or gate): + click.echo("Error: --force requires --ai or --gate.", err=True) + raise SystemExit(1) + if as_json and not ai: + click.echo("Error: --json requires --ai.", err=True) raise SystemExit(1) upstream_ref = resolve_upstream_ref(app, upstream_ref) @@ -884,6 +969,12 @@ def merge_pick( _merge_pick_plan(app, fork_status, upstream_ref, fork_ref) return + if ai: + _merge_pick_ai( + app, fork_status, upstream_ref, fork_ref, next_only, force, as_json + ) + return + if gate: _merge_pick_gate(app, fork_status, upstream_ref, fork_ref, force) return @@ -958,42 +1049,23 @@ def _merge_pick_plan( upstream_ref: str, fork_ref: str, ) -> None: - """Phase-1 decision (token-free): print the gate's JSON decision. + """Token-free gate decision: print the merge gate's JSON verdict. - Evaluates the merge gate and reads the mode from ``fork.ai_pick.enabled``. - In deterministic mode the sha is resolved here (capped to the - ``max_commits`` window); in AI mode the sha is left null (decided in - phase 2). When the fork is up to date, reports ``wait``. + Evaluates the merge gate over the candidate window and prints + ``{"action": "merge"|"wait", "reason": ...}``. This is the go/no-go + decision only; which commit to merge to is a separate, explicit step + (``merge-pick --gate`` / ``--ai`` / ``--next``). """ if fork_status.is_up_to_date: print(json.dumps({"action": "wait", "reason": "up to date (0 commits)"})) return - decision, window_shas, _omitted, prioritized = compute_gate( + decision, _window_shas, _omitted, _prioritized = compute_gate( app, fork_status, upstream_ref, fork_ref ) - if not decision.open: - print(json.dumps({"action": "wait", "reason": decision.reason})) - return - - if app.config.fork.ai_pick.enabled: - output = { - "action": "merge", - "mode": "ai", - "sha": None, - "reason": decision.reason, - } - else: - output = { - "action": "merge", - "mode": "deterministic", - "sha": resolve_deterministic_sha( - window_shas, prioritized, app.config.fork.merge_gate - ), - "reason": decision.reason, - } - print(json.dumps(output)) + action = "merge" if decision.open else "wait" + print(json.dumps({"action": action, "reason": decision.reason})) def _merge_pick_gate( @@ -1012,8 +1084,6 @@ def _merge_pick_gate( ``[min_commits, max_commits]`` band, else the window tip; the window never advances past ``max_commits``). Prints nothing (exit 0) when the fork is up to date or the gate is closed, so callers can capture stdout safely. - Independent of - ``fork.ai_pick.enabled`` (that flag only affects ``--plan``'s reported mode). """ if fork_status.is_up_to_date: return @@ -1034,3 +1104,170 @@ def _merge_pick_gate( return click.echo(sha) + + +def _resolve_window_sha(candidate: str, window_shas: list[str]) -> str | None: + """Map an agent-returned sha to a full window sha. + + Accepts a full sha or an unambiguous prefix (agents often return short + shas). Returns the matching full sha, or None if it isn't in the window. + """ + if candidate in window_shas: + return candidate + candidate = candidate.strip().lower() + matches = [sha for sha in window_shas if sha.lower().startswith(candidate)] + if len(matches) == 1: + return matches[0] + return None + + +def _merge_pick_ai( + app: AppContext, + fork_status, + upstream_ref: str, + fork_ref: str, + next_only: bool, + force: bool, + as_json: bool, +) -> None: + """AI pick: ask the agent which commit to merge to. + + Re-checks the gate unless ``force`` (so the gate is always checked before + the agent runs), builds the candidate-window input, runs the configured + agent, and validates that the chosen sha is a candidate in the window. On + agent error / invalid sha, applies the configured ``fallback`` + (deterministic by default). + + Output modes (stdout carries only the pick, so it is safe to capture; + progress/diagnostics go to stderr): + - ``next_only``: the bare full sha (for ``$(...)`` capture); + - ``as_json``: a one-line JSON object ``{sha, short_sha, reasoning, + source}`` (for downstream processing, e.g. a PR comment); + - default: a styled, human-readable block with the sha highlighted. + """ + from ..agent_executor import AgentExecutionError, AgentExecutor + from ..prompt_builder import build_merge_pick_prompt + + if fork_status.is_up_to_date: + # Nothing to merge; --next prints nothing, otherwise a short note. + if not (next_only or as_json): + click.echo("Fork is up to date; nothing to pick.", err=True) + return + + decision, window_shas, omitted, prioritized = compute_gate( + app, fork_status, upstream_ref, fork_ref + ) + + if not force and not decision.open: + click.echo(f"Merge gate closed: {decision.reason}", err=True) + return + + if not window_shas: + click.echo("No candidate commits in the window.", err=True) + return + + ai_cfg = app.config.fork.ai_pick + + def emit(sha: str, reasoning: str | None, source: str) -> None: + """Render the chosen pick to stdout in the selected output mode. + + ``source`` is ``"ai"`` for an agent pick or ``"deterministic"`` when + the deterministic fallback produced it. + """ + if next_only: + click.echo(sha) + return + if as_json: + click.echo( + json.dumps( + { + "sha": sha, + "short_sha": git_utils.short_sha(sha), + "reasoning": reasoning, + "source": source, + } + ) + ) + return + # Human-readable: highlight the sha, set off the reasoning clearly. + label = ( + "Merge pick" if source == "ai" else "Merge pick (deterministic fallback)" + ) + click.echo( + click.style(f"{label}: ", bold=True) + + click.style(sha, fg="green", bold=True) + + click.style(f" ({git_utils.short_sha(sha)})", fg="bright_black") + ) + if reasoning: + click.echo() + click.echo(click.style("Reasoning:", bold=True)) + for line in reasoning.splitlines() or [reasoning]: + click.echo(f" {line}") + + def fallback(message: str) -> None: + if ai_cfg.fallback == "error": + click.echo(f"Error: AI pick failed: {message}", err=True) + raise SystemExit(1) + # deterministic fallback + sha = resolve_deterministic_sha( + window_shas, prioritized, app.config.fork.merge_gate + ) + if sha is None: + click.echo("Error: no deterministic fallback sha available.", err=True) + raise SystemExit(1) + click.echo( + f"AI pick failed ({message}); falling back to deterministic pick.", + err=True, + ) + emit(sha, "deterministic fallback applied", source="deterministic") + + candidates = build_merge_pick_input( + app, fork_status, window_shas, omitted, prioritized, decision + ) + prompt = build_merge_pick_prompt(candidates, ai_cfg.rules_file or None) + + agent = app.get_agent(agent_desc=ai_cfg.agent or None, yolo=False) + executor = AgentExecutor( + agent=agent, + state_dir=app.state.path, + max_attempts=app.config.resolve.max_attempts, + repo=app.repo, + ) + + window_set = set(window_shas) + + def validator(result: dict) -> str | None: + response = result.get("response") + if not isinstance(response, dict): + return "Response must be a JSON object with 'sha' and 'reasoning'." + sha = response.get("sha") + if not isinstance(sha, str) or not sha.strip(): + return "'sha' must be a non-empty string." + if _resolve_window_sha(sha, window_shas) is None: + return ( + f"'{sha}' is not one of the candidate commits in the window. " + "Pick a sha from the provided candidates." + ) + reasoning = response.get("reasoning") + if not isinstance(reasoning, str) or not reasoning.strip(): + return "'reasoning' must be a non-empty string explaining the pick." + return None + + # In capture modes stdout must carry only the sha / JSON, but the executor + # (and agent) echo progress to stdout. Redirect that to stderr so the + # captured stream stays clean; emit() below writes the result to real stdout. + capture = next_only or as_json + try: + with contextlib.redirect_stdout(sys.stderr) if capture else nullcontext(): + result = executor.run_with_retry(prompt=prompt, validator=validator) + except AgentExecutionError as e: + fallback(str(e)) + return + + response = result.get("response", {}) + chosen = _resolve_window_sha(response.get("sha", ""), window_shas) + if chosen is None or chosen not in window_set: + fallback("agent returned an invalid sha") + return + + emit(chosen, response.get("reasoning"), source="ai") diff --git a/src/mergai/config.py b/src/mergai/config.py index 60f0ab5..6577a59 100644 --- a/src/mergai/config.py +++ b/src/mergai/config.py @@ -101,15 +101,13 @@ def _int(name: str, default: int) -> int: @dataclass class AiPickConfig: - """Configuration for the AI-assisted merge pick. + """Configuration for the AI-assisted merge pick (``merge-pick --ai``). - When enabled, the privileged merge phase (``merge-pick --ai``) asks an AI - agent which upstream commit to merge to, within the gate's candidate - window. When disabled, the pick is made deterministically. + The AI pick asks an agent which upstream commit to merge to, within the + gate's candidate window. It is always selected explicitly via + ``merge-pick --ai``; these settings only tune how it behaves. Attributes: - enabled: Whether the AI pick is used. When False, ``merge-pick --plan`` - reports ``mode: deterministic`` and resolves the sha itself. agent: Agent descriptor (e.g. ``claude-cli:claude-opus-4-5``), same format as ``resolve.agent``. Empty falls back to ``resolve.agent``. rules_file: Optional path to a project-specific merge-pick rules file @@ -118,7 +116,6 @@ class AiPickConfig: (resilient, the default) or ``error``. """ - enabled: bool = False agent: str = "" rules_file: str = "" fallback: str = "deterministic" @@ -139,7 +136,6 @@ def from_dict(cls, data: dict) -> "AiPickConfig": "Valid values are: deterministic, error" ) return cls( - enabled=data.get("enabled", cls.enabled), agent=data.get("agent", cls.agent), rules_file=data.get("rules_file", cls.rules_file), fallback=fallback, diff --git a/src/mergai/prompt_builder.py b/src/mergai/prompt_builder.py index c23693c..7903991 100644 --- a/src/mergai/prompt_builder.py +++ b/src/mergai/prompt_builder.py @@ -392,6 +392,41 @@ def build_ci_fix_prompt( ) + build_ci_fix_run_section(context, heading="") +def build_merge_pick_prompt(candidates: dict, rules_file: str | None = None) -> str: + """Build the prompt for the AI-assisted merge pick. + + Layout mirrors the other free-function prompts (CI fix / review): + 1. The built-in merge-pick system prompt (general boundary-picking rules). + 2. Optional project-specific rules loaded from ``rules_file`` if present + (the ``.mergai/invariants.md`` precedent - loaded via + :func:`util.load_if_exists`, so a missing file is simply skipped). + 3. The candidate window as JSON (the agent's input). + + Free function (not a ``PromptBuilder`` method) because the merge pick runs + on a fork ref without a merge note. + + Args: + candidates: The candidate-window input dict (gate decision, divergence, + window info, and per-commit candidates). + rules_file: Optional path to a project-specific merge-pick rules file. + + Returns: + The complete prompt string for the AI agent. + """ + parts: list[str] = [prompts.load_system_prompt_merge_pick(), "\n\n"] + + if rules_file: + project_rules = util.load_if_exists(rules_file) + if project_rules: + parts.extend(["## Project rules\n\n", project_rules, "\n\n"]) + + parts.append("## Candidates\n\n") + parts.append("```json\n") + parts.append(json.dumps(candidates, indent=2, default=str)) + parts.append("\n```\n") + return "".join(parts) + + def build_review_prompt( context, note: MergaiNote | None = None, diff --git a/src/mergai/prompts/__init__.py b/src/mergai/prompts/__init__.py index 74e584b..5554712 100644 --- a/src/mergai/prompts/__init__.py +++ b/src/mergai/prompts/__init__.py @@ -53,6 +53,10 @@ def load_system_prompt_review(context: dict | None = None) -> str: return _render_prompt("system_prompt_review.md", context) +def load_system_prompt_merge_pick() -> str: + return load_prompt("system_prompt_merge_pick.md") + + def load_conflict_context_prompt() -> str: return load_prompt("conflict_context.md") diff --git a/src/mergai/prompts/system_prompt_merge_pick.md b/src/mergai/prompts/system_prompt_merge_pick.md new file mode 100644 index 0000000..16ebe97 --- /dev/null +++ b/src/mergai/prompts/system_prompt_merge_pick.md @@ -0,0 +1,44 @@ +# System Prompt + +## Overview + +- You choose the next upstream commit to merge into the fork, from the + **candidate window** you are given. +- The window is the oldest unmerged upstream commits (oldest first), each with + size stats (`files`, `lines_added`, `lines_deleted`, `dirs`) and, where it + matched a configured strategy, a `strategy` flag + (`conflict` / `huge_commit` / `branching_point` / `important_files`). You are + also given the cumulative divergence and the count of any omitted tail + beyond the window. +- Merging your chosen commit pulls in **everything from the fork base up to and + including it**. So your pick is a **merge boundary**: the cut point of this + batch. + +## How to choose + +- Pick the sha that is the best **merge boundary**: prefer logical stopping + points, and avoid splitting obviously related commits across two merges. +- Use the strategy flags as **signals, not rules**. A `conflict` or + `important_files` commit is informative, but it does **not** force you to stop + before it - you may pick **past** it if that yields a cleaner boundary. If the + merge then hits the conflict, the existing resolve flow handles it. +- You may pick any commit in the window, including its newest commit. You may + **not** pick a commit outside the window. +- When in doubt, prefer a larger, coherent batch over an arbitrarily small one - + the gate already decided it is time to merge. + +## Output format + +Write your JSON response to the specified response file path provided in the +prompt. Use the appropriate file-writing tool available to you. + +**IMPORTANT:** You MUST write valid JSON. Return only this object: + +```json +{ + "sha": "", + "reasoning": "why this commit is the best merge boundary" +} +``` + +The `sha` MUST be one of the candidate shas from the window (full sha preferred). diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py index ba9b969..53f15da 100644 --- a/tests/test_merge_gate.py +++ b/tests/test_merge_gate.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from mergai.commands.fork import ( + _resolve_window_sha, resolve_deterministic_sha, restrict_to_window, ) @@ -168,6 +169,30 @@ def test_deterministic_empty_window(): assert resolve_deterministic_sha([], [], MergeGateConfig()) is None +# --- _resolve_window_sha -------------------------------------------------- + +FULL = "abc123" + "0" * 34 # 40 chars +OTHER = "def456" + "0" * 34 + + +def test_resolve_window_sha_full_match(): + assert _resolve_window_sha(FULL, [FULL, OTHER]) == FULL + + +def test_resolve_window_sha_prefix_match(): + assert _resolve_window_sha("abc123", [FULL, OTHER]) == FULL + + +def test_resolve_window_sha_absent(): + assert _resolve_window_sha("999999", [FULL, OTHER]) is None + + +def test_resolve_window_sha_ambiguous_prefix_rejected(): + a = "aaa111" + "0" * 34 + b = "aaa222" + "0" * 34 + assert _resolve_window_sha("aaa", [a, b]) is None + + # --- config parsing ------------------------------------------------------- @@ -189,7 +214,6 @@ def test_fork_config_parses_gate_and_ai_pick(): "force_strategies": ["conflict"], }, "ai_pick": { - "enabled": True, "agent": "claude-cli:claude-opus-4-5", "rules_file": ".mergai/merge_pick_rules.md", "fallback": "error", @@ -204,7 +228,6 @@ def test_fork_config_parses_gate_and_ai_pick(): force_strategies=["conflict"], ) assert cfg.fork.ai_pick == AiPickConfig( - enabled=True, agent="claude-cli:claude-opus-4-5", rules_file=".mergai/merge_pick_rules.md", fallback="error", diff --git a/tests/test_merge_pick_ai.py b/tests/test_merge_pick_ai.py new file mode 100644 index 0000000..2316422 --- /dev/null +++ b/tests/test_merge_pick_ai.py @@ -0,0 +1,378 @@ +"""Command-level tests for ``mergai fork merge-pick --plan`` and ``--ai``. + +The gate evaluation and candidate-window construction are stubbed (covered by +``test_merge_gate``); these tests focus on the command wiring: the ``--plan`` +gate decision (merge / wait) and the ``--ai`` flow's sha validation, +deterministic fallback, and ``fallback: error`` behavior. A fake +``AgentExecutor`` runs the real validator against a canned agent result. +""" + +import json +from types import SimpleNamespace + +from click.testing import CliRunner + +import mergai.agent_executor +import mergai.commands.fork as fork_mod +import mergai.prompt_builder +from mergai.config import AiPickConfig, MergaiConfig +from mergai.merge_pick_strategies.gate import GateDecision + +# Reference these via their modules (fork_mod.fork, +# mergai.agent_executor.AgentExecutionError) rather than re-importing with +# `from ... import`, which would import the same module two ways (CodeQL). +fork = fork_mod.fork +AgentExecutionError = mergai.agent_executor.AgentExecutionError + +FULL = "abc123" + "0" * 34 # 40-char candidate sha +OTHER = "def456" + "0" * 34 +UPSTREAM = "upstream/master" + + +def _app(*, ai_pick=None): + config = MergaiConfig() + config.fork.ai_pick = ai_pick or AiPickConfig() + return SimpleNamespace( + config=config, + repo=SimpleNamespace(), + state=SimpleNamespace(path="/tmp"), + get_agent=lambda agent_desc=None, yolo=False: SimpleNamespace(), + ) + + +def _patch_fork_status(monkeypatch, *, up_to_date=False): + fs = SimpleNamespace( + is_up_to_date=up_to_date, + commits_behind=0 if up_to_date else 60, + unmerged_oldest_age_days=None if up_to_date else 1.0, + ) + monkeypatch.setattr(fork_mod.git_utils, "get_fork_status", lambda *a, **k: fs) + return fs + + +def _patch_gate(monkeypatch, *, open_, window, prioritized=None, reason="min_commits"): + decision = GateDecision(open=open_, reason=reason) + monkeypatch.setattr( + fork_mod, + "compute_gate", + lambda *a, **k: (decision, window, 0, prioritized or []), + ) + return decision + + +def _run(app, args): + return CliRunner().invoke(fork, ["merge-pick", *args], obj=app) + + +# --- --plan --------------------------------------------------------------- + + +def test_plan_wait_when_gate_closed(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate( + monkeypatch, + open_=False, + window=[FULL], + reason="wait (12 < 50 commits; oldest 0.3d < 2d)", + ) + res = _run(_app(), ["--plan", UPSTREAM]) + assert res.exit_code == 0 + assert json.loads(res.output) == { + "action": "wait", + "reason": "wait (12 < 50 commits; oldest 0.3d < 2d)", + } + + +def test_plan_merge_when_gate_open(monkeypatch): + # --plan is the go/no-go decision only: action + reason, no mode/sha (which + # commit to merge to is a separate, explicit pick step). + _patch_fork_status(monkeypatch) + _patch_gate( + monkeypatch, open_=True, window=[FULL, OTHER], reason="min_commits (60 >= 50)" + ) + res = _run(_app(), ["--plan", UPSTREAM]) + assert res.exit_code == 0 + assert json.loads(res.output) == { + "action": "merge", + "reason": "min_commits (60 >= 50)", + } + + +def test_plan_up_to_date(monkeypatch): + _patch_fork_status(monkeypatch, up_to_date=True) + res = _run(_app(), ["--plan", UPSTREAM]) + assert json.loads(res.output) == { + "action": "wait", + "reason": "up to date (0 commits)", + } + + +def test_plan_and_ai_mutually_exclusive(): + res = _run(_app(), ["--plan", "--ai", UPSTREAM]) + assert res.exit_code != 0 + assert "mutually exclusive" in res.stderr.lower() + + +# --- --ai ----------------------------------------------------------------- + + +class _FakeExecutor: + """Stub AgentExecutor: runs the real validator against a canned result. + + Mirrors the real contract closely enough for the command: a result that + fails validation raises ``AgentExecutionError`` (as the real executor does + once retries are exhausted). + """ + + result = {"response": {"sha": FULL, "reasoning": "best boundary"}} + + def __init__(self, *args, **kwargs): + pass + + def run_with_retry(self, prompt, validator=None): + if validator is not None: + error = validator(self.result) + if error is not None: + raise AgentExecutionError(error) + return self.result + + +def _patch_ai_machinery(monkeypatch, executor_cls): + monkeypatch.setattr(fork_mod, "build_merge_pick_input", lambda *a, **k: {}) + monkeypatch.setattr( + mergai.prompt_builder, "build_merge_pick_prompt", lambda *a, **k: "prompt" + ) + monkeypatch.setattr(mergai.agent_executor, "AgentExecutor", executor_cls) + + +def test_ai_emits_chosen_sha_and_reasoning(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + _patch_ai_machinery(monkeypatch, _FakeExecutor) + + res = _run(_app(), ["--ai", UPSTREAM]) + assert res.exit_code == 0 + # Styled human output: the full sha and the reasoning are both visible. + assert FULL in res.output + assert "Merge pick" in res.output + assert "Reasoning" in res.output + assert "best boundary" in res.output + + +def test_ai_json_output(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + _patch_ai_machinery(monkeypatch, _FakeExecutor) + + res = _run(_app(), ["--ai", "--json", UPSTREAM]) + assert res.exit_code == 0 + data = json.loads(res.output) + assert data == { + "sha": FULL, + "short_sha": FULL[:11], + "reasoning": "best boundary", + "source": "ai", + } + + +def test_ai_json_output_on_deterministic_fallback(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + + class _BadShaExecutor(_FakeExecutor): + result = {"response": {"sha": "9" * 40, "reasoning": "nope"}} + + _patch_ai_machinery(monkeypatch, _BadShaExecutor) + + res = _run(_app(), ["--ai", "--json", UPSTREAM]) + assert res.exit_code == 0 + # The fallback note must land on stderr, keeping stdout's JSON payload clean. + assert "falling back to deterministic" in res.stderr + # In this Click version res.output interleaves stderr into stdout, so the + # JSON is the last line; the stderr assertion above guards against the note + # actually leaking onto stdout. + data = json.loads(res.output.strip().splitlines()[-1]) + assert data["sha"] == FULL # window tip + assert data["source"] == "deterministic" + + +def test_ai_next_prints_only_sha(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + _patch_ai_machinery(monkeypatch, _FakeExecutor) + + res = _run(_app(), ["--ai", "--next", UPSTREAM]) + assert res.output.strip() == FULL + + +def test_ai_invalid_sha_falls_back_deterministic(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + + class _BadShaExecutor(_FakeExecutor): + result = {"response": {"sha": "9" * 40, "reasoning": "nope"}} + + _patch_ai_machinery(monkeypatch, _BadShaExecutor) + + res = _run(_app(), ["--ai", "--next", UPSTREAM]) + assert res.exit_code == 0 + # Deterministic fallback -> window tip (newest) emitted to stdout. + assert FULL in res.output + assert "falling back to deterministic" in res.stderr + + +def test_ai_invalid_sha_with_error_fallback_exits_nonzero(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + + class _BadShaExecutor(_FakeExecutor): + result = {"response": {"sha": "9" * 40}} + + _patch_ai_machinery(monkeypatch, _BadShaExecutor) + + app = _app(ai_pick=AiPickConfig(fallback="error")) + res = _run(app, ["--ai", UPSTREAM]) + assert res.exit_code != 0 + assert "AI pick failed" in res.stderr + + +def test_ai_bails_when_gate_closed(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=False, window=[FULL], reason="wait (...)") + _patch_ai_machinery(monkeypatch, _FakeExecutor) + + res = _run(_app(), ["--ai", "--next", UPSTREAM]) + assert res.exit_code == 0 + # No sha emitted; the closed-gate note goes to stderr only. + assert FULL not in res.output + assert "Merge gate closed" in res.stderr + + +def test_ai_force_skips_gate_recheck(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=False, window=[FULL, OTHER], reason="wait (...)") + _patch_ai_machinery(monkeypatch, _FakeExecutor) + + res = _run(_app(), ["--ai", "--force", "--next", UPSTREAM]) + assert res.exit_code == 0 + assert res.output.strip() == FULL + + +def test_ai_up_to_date_returns_quietly(monkeypatch): + _patch_fork_status(monkeypatch, up_to_date=True) + res = _run(_app(), ["--ai", "--next", UPSTREAM]) + assert res.exit_code == 0 + assert res.output.strip() == "" + + +def test_ai_missing_reasoning_falls_back(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + + class _NoReasoningExecutor(_FakeExecutor): + result = {"response": {"sha": FULL}} # valid sha, no reasoning + + _patch_ai_machinery(monkeypatch, _NoReasoningExecutor) + + res = _run(_app(), ["--ai", "--next", UPSTREAM]) + assert res.exit_code == 0 + assert FULL in res.output # deterministic fallback (window tip) + assert "falling back to deterministic" in res.stderr + + +def test_ai_next_keeps_stdout_clean(monkeypatch): + """Executor progress (stdout chatter) must not pollute the captured sha.""" + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + + import click + + class _NoisyExecutor(_FakeExecutor): + def run_with_retry(self, prompt, validator=None): + click.echo("Attempt 1 of 3...") # the kind of noise the real one emits + return super().run_with_retry(prompt, validator) + + _patch_ai_machinery(monkeypatch, _NoisyExecutor) + + res = _run(_app(), ["--ai", "--next", UPSTREAM]) + assert res.exit_code == 0 + assert FULL in res.output + # The chatter lands in stderr only because the redirect moved it off stdout; + # without the redirect click.echo would write it to stdout (not stderr). + assert "Attempt 1 of 3" in res.stderr + + +def test_force_without_ai_or_gate_errors(): + res = _run(_app(), ["--force", UPSTREAM]) + assert res.exit_code != 0 + assert "--force" in res.stderr and "--ai or --gate" in res.stderr + + +def test_json_without_ai_errors(): + res = _run(_app(), ["--json", UPSTREAM]) + assert res.exit_code != 0 + assert "--json" in res.stderr and "requires --ai" in res.stderr + + +# --- --gate --------------------------------------------------------------- + + +def test_gate_emits_window_tip_when_no_match(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER]) + res = _run(_app(), ["--gate", UPSTREAM]) + assert res.exit_code == 0 + # No prioritized match -> window tip (newest, capped to the window). + assert res.output.strip() == FULL + + +def test_gate_emits_forced_pick(monkeypatch): + _patch_fork_status(monkeypatch) + # A force-strategy (conflict) match is the cut point even before min_commits. + pick = SimpleNamespace( + commit=SimpleNamespace(hexsha=OTHER), strategy_name="conflict" + ) + _patch_gate(monkeypatch, open_=True, window=[FULL, OTHER], prioritized=[pick]) + res = _run(_app(), ["--gate", UPSTREAM]) + assert res.exit_code == 0 + assert res.output.strip() == OTHER + + +def test_gate_closed_emits_no_pick(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=False, window=[FULL], reason="wait (5 < 50)") + res = _run(_app(), ["--gate", UPSTREAM]) + assert res.exit_code == 0 + # No pick on stdout (the closed-gate note goes to stderr). + assert FULL not in res.output and OTHER not in res.output + + +def test_gate_force_picks_despite_closed_gate(monkeypatch): + _patch_fork_status(monkeypatch) + _patch_gate(monkeypatch, open_=False, window=[FULL], reason="wait") + res = _run(_app(), ["--gate", "--force", UPSTREAM]) + assert res.exit_code == 0 + assert res.output.strip() == FULL + + +def test_gate_up_to_date_emits_nothing(monkeypatch): + _patch_fork_status(monkeypatch, up_to_date=True) + res = _run(_app(), ["--gate", UPSTREAM]) + assert res.exit_code == 0 + assert res.output.strip() == "" + + +def test_gate_and_ai_mutually_exclusive(): + res = _run(_app(), ["--gate", "--ai", UPSTREAM]) + assert res.exit_code != 0 + assert "mutually exclusive" in res.stderr.lower() + + +def test_ai_pick_invalid_fallback_raises(): + import pytest + + with pytest.raises(ValueError, match="ai_pick.fallback"): + AiPickConfig.from_dict({"fallback": "nope"}) + # ...and surfaced through the top-level config parser too. + with pytest.raises(ValueError, match="ai_pick.fallback"): + MergaiConfig.from_dict({"fork": {"ai_pick": {"fallback": "nope"}}})