Skip to main content

kranz_engine/
merge.rs

1//! Gated merge orchestration (roadmap M6): a human-triggered Merge action
2//! that refuses on a dirty tracked tree, runs the repo's tracked gate suite,
3//! and advances the base to an exact, gate-tested integration commit only on
4//! green — never pushing.
5//!
6//! `merge_mission` ties together three primitives that each already carry
7//! their own safety contract: [`GitRepo::is_clean_tracked`] (refuse dirty),
8//! [`crate::merge_gate::MergeSuiteGate`] (refuse on a failing gate, base
9//! untouched — the suite runs through the [`crate::gate`] interface), and
10//! [`GitRepo::merge_no_ff`] (clean-abort on conflict). The
11//! merge and gates run in a detached scratch worktree; the primary base only
12//! fast-forwards to that exact tested commit. Every git command on this path
13//! runs via [`GitRepo::with_hooks_disabled`], so mission-planted
14//! `.git/hooks/*` never execute with the server's environment. This module
15//! never calls [`GitRepo::push_mission_branch`] or any other push.
16
17mod external;
18
19use crate::error::Result;
20use crate::gate::{Gate, GateVerdict};
21use crate::git_ops::{with_kranz_trailers, GitRepo, KranzCommitMetadata, MergeOutcome};
22use crate::merge_gate::{parse_gate_suite, MergeSuiteGate, MERGE_GATES_PATH};
23use crate::scrub::{self, SecretFinding};
24use std::path::Path;
25
26/// Number of merge commits on the live base after the mission's pinned base
27/// before the merge response flags likely sibling-merge semantic drift.
28pub const STALE_BASE_MERGE_THRESHOLD: usize = 1;
29
30/// Outcome of a gated merge attempt.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum MergeReport {
33    /// The tracked working tree was dirty; no gates ran and base is untouched.
34    RefusedDirtyTree,
35    /// A gate failed; later gates and the merge itself never ran. Base is
36    /// untouched.
37    GateFailed {
38        /// The failing gate's command string.
39        gate: String,
40        /// That gate's verbatim captured output.
41        output: String,
42    },
43    /// The live base branch has no valid tracked merge-gate suite. Nothing
44    /// ran and base is untouched; this fails closed instead of silently
45    /// treating an empty suite as green.
46    GateConfigInvalid { detail: String },
47    /// The mission branch diff contains an unwaived secret finding. Base is
48    /// untouched and no other gates ran.
49    SecretScanFailed { findings: Vec<SecretFinding> },
50    /// The merge conflicted and was rolled back (working tree left clean).
51    Conflict {
52        /// Conflicting paths git named (best-effort; may be empty).
53        files: Vec<String>,
54    },
55    /// Git refused the merge before it ever started (no `MERGE_HEAD`), e.g. a
56    /// divergent untracked file at a path the merge would overwrite. Base is
57    /// untouched and nothing was aborted (there was no merge in progress).
58    RefusedPreMerge {
59        /// Git's verbatim refusal text.
60        detail: String,
61    },
62    /// The live base's applicable ENFORCED Flight Rules set differs from the
63    /// mission's approved pin (KRZ-342, design D-E): policy moved under the
64    /// mission. The merge is refused before the gate suite runs — neither
65    /// grandfather-skipping current policy nor silently applying new policy
66    /// to an old consent artifact — and the caller records
67    /// `standards.drifted`. Base is untouched.
68    StandardsDrifted {
69        /// The digest pinned at approval.
70        approved_digest: String,
71        /// The digest resolved from the live base (`None`: the live base no
72        /// longer yields a readable standards manifest at all).
73        current_digest: Option<String>,
74        /// Id-level change lines for the applicable enforced set.
75        changed_rules: Vec<String>,
76    },
77    /// An applicable enforced MUST could not produce a current authoritative
78    /// merge verdict. Deterministic checkers run against the exact scratch
79    /// integration tree; contextual/manual checkers must carry positive or
80    /// exactly-waived final evidence from the completed mission.
81    StandardsFailed {
82        rule_id: String,
83        checker: String,
84        output: String,
85    },
86    /// The mission branch merged cleanly into base with a `--no-ff` commit.
87    Merged {
88        /// The new merge commit sha, now the tip of `base_branch`.
89        commit: String,
90        /// Informational warning when the mission's pinned base trails merge
91        /// commits already landed on the live base branch.
92        stale_base: Option<StaleBaseWarning>,
93    },
94}
95
96/// Non-blocking merge-time warning for missions drafted from a stale base.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct StaleBaseWarning {
99    pub base_sha: String,
100    pub live_base: String,
101    pub merge_commits_since_base: usize,
102}
103
104/// Positive final evidence that may be consumed by merge-only checker forms.
105/// Deterministic gates are always re-run against the scratch integration;
106/// only an exact human waiver may permit one of those current failures.
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
108pub struct StandardsMergeEvidence {
109    pub passed: std::collections::BTreeSet<String>,
110    waived_final: std::collections::BTreeSet<String>,
111    approval_seq: Option<u64>,
112    waivers: Vec<crate::standards_waiver::WaiverRecord>,
113    attestations: Vec<crate::standards_attestation::AttestationRecord>,
114    evaluated_at: Option<chrono::DateTime<chrono::Utc>>,
115}
116
117impl StandardsMergeEvidence {
118    pub fn from_mission_events(
119        mission_id: &str,
120        pin: Option<&crate::types::StandardsPin>,
121        coverage: Option<&crate::standards_coverage::StandardsCoverage>,
122        events: &[crate::events::Event],
123        now: chrono::DateTime<chrono::Utc>,
124    ) -> Self {
125        let mut evidence = Self::default();
126        if let Some(coverage) = coverage {
127            for rule in &coverage.rules {
128                match rule.disposition {
129                    crate::standards_coverage::RuleDisposition::Passed => {
130                        evidence.passed.insert(rule.id.clone());
131                    }
132                    crate::standards_coverage::RuleDisposition::Waived => {
133                        evidence.waived_final.insert(rule.id.clone());
134                    }
135                    _ => {}
136                }
137            }
138        }
139        if let Some(pin) = pin {
140            evidence.approval_seq = events
141                .iter()
142                .filter(|event| event.mission_id == mission_id)
143                .filter_map(|event| match &event.kind {
144                    crate::events::EventKind::PlanApproved { plan, .. }
145                        if plan.standards_manifest.as_deref() == Some(pin) =>
146                    {
147                        Some(event.seq)
148                    }
149                    _ => None,
150                })
151                .next_back();
152            evidence.waivers = events
153                .iter()
154                .filter(|event| event.mission_id == mission_id)
155                .filter_map(crate::standards_waiver::WaiverRecord::from_event)
156                .collect();
157            evidence.attestations = events
158                .iter()
159                .filter(|event| event.mission_id == mission_id)
160                .filter_map(crate::standards_attestation::AttestationRecord::from_event)
161                .collect();
162            evidence.evaluated_at = Some(now);
163        }
164        evidence
165    }
166
167    #[allow(clippy::too_many_arguments)]
168    fn current_waiver(
169        &self,
170        repo: &GitRepo,
171        live_base_sha: &str,
172        tested_commit: &str,
173        integration_paths: &[String],
174        pin: &crate::types::StandardsPin,
175        rule: &crate::types::PinnedRule,
176        finding: Option<&crate::types::Finding>,
177    ) -> Result<bool> {
178        if !self.waived_final.contains(&rule.id) {
179            return Ok(false);
180        }
181        let (Some(approval_seq), Some(now)) = (self.approval_seq, self.evaluated_at) else {
182            return Ok(false);
183        };
184        let paths = crate::standards_waiver::affected_paths_with_context(
185            rule,
186            integration_paths,
187            &pin.context_paths,
188        );
189        let diff = if rule.when_paths.is_empty() {
190            repo.diff_full(live_base_sha, tested_commit)?
191        } else if paths.is_empty() {
192            String::new()
193        } else {
194            repo.diff_range_paths(live_base_sha, tested_commit, &paths)?
195        };
196        let diff_digest = crate::standards_waiver::sha256_hex(diff.as_bytes());
197        let fingerprint = finding.map(|finding| {
198            crate::standards_waiver::finding_fingerprint(crate::reducer::ENGINE_RUN_ID, finding)
199        });
200        Ok(self.waivers.iter().any(|waiver| {
201            fingerprint
202                .as_ref()
203                .is_none_or(|fingerprint| waiver.finding_fingerprint == *fingerprint)
204                && crate::standards_waiver::waiver_covers(
205                    waiver,
206                    rule,
207                    pin,
208                    approval_seq,
209                    &waiver.finding_fingerprint,
210                    now,
211                )
212                && waiver.paths == paths
213                && waiver.diff_digest == diff_digest
214        }))
215    }
216
217    fn current_attestation(
218        &self,
219        repo: &GitRepo,
220        live_base_sha: &str,
221        tested_commit: &str,
222        integration_paths: &[String],
223        pin: &crate::types::StandardsPin,
224        rule: &crate::types::PinnedRule,
225    ) -> Result<bool> {
226        let Some(approval_seq) = self.approval_seq else {
227            return Ok(false);
228        };
229        let paths = crate::standards_waiver::affected_paths_with_context(
230            rule,
231            integration_paths,
232            &pin.context_paths,
233        );
234        let diff = if rule.when_paths.is_empty() {
235            repo.diff_full(live_base_sha, tested_commit)?
236        } else if paths.is_empty() {
237            String::new()
238        } else {
239            repo.diff_range_paths(live_base_sha, tested_commit, &paths)?
240        };
241        let diff_digest = crate::standards_waiver::sha256_hex(diff.as_bytes());
242        Ok(self.attestations.iter().rev().any(|record| {
243            record.seq > approval_seq
244                && record.rule_id == rule.id
245                && record.rule_revision == rule.revision
246                && record.manifest_digest == pin.digest
247                && record.approval_seq == approval_seq
248                && record.paths == paths
249                && record.diff_digest == diff_digest
250                && crate::standards_waiver::HUMAN_SURFACES.contains(&record.surface.as_str())
251                && !record.approver.trim().is_empty()
252        }))
253    }
254}
255
256/// Runs the gated merge: refuse-if-dirty, then gates, then `--no-ff` merge.
257///
258/// `executor` is forwarded to the [`crate::merge_gate::MergeSuiteGate`]
259/// adapter as-is (production callers wrap the orchestrator's shell runner,
260/// tests inject a scripted fake). This function never calls
261/// [`GitRepo::push_mission_branch`] or any push — the base branch is only
262/// ever advanced locally.
263///
264/// `standards_pin` is the mission's approved Flight Rules manifest pin
265/// (KRZ-342, design D-E), folded from its event log: `None` keeps the merge
266/// byte-identical. With a repo-tracked pin, the LIVE base policy is
267/// re-resolved against the exact scratch integration diff once the
268/// integration commit exists and BEFORE the gate suite runs — an applicable
269/// enforced-set difference refuses with [`MergeReport::StandardsDrifted`].
270pub fn merge_mission<F>(
271    repo: &GitRepo,
272    base_branch: &str,
273    base_sha: &str,
274    mission_branch: &str,
275    metadata: Option<KranzCommitMetadata>,
276    standards_pin: Option<&crate::types::StandardsPin>,
277    executor: F,
278) -> Result<MergeReport>
279where
280    F: Fn(&str, &Path) -> (bool, String),
281{
282    merge_mission_with_standards_evidence(
283        repo,
284        base_branch,
285        base_sha,
286        mission_branch,
287        metadata,
288        standards_pin,
289        &StandardsMergeEvidence::default(),
290        executor,
291    )
292}
293
294/// The production merge path, including the completed mission's replayed
295/// standards evidence. Kept separate from [`merge_mission`] so existing
296/// embedders with no Flight Rules pin retain their source-compatible call.
297#[allow(clippy::too_many_arguments)]
298pub fn merge_mission_with_standards_evidence<F>(
299    repo: &GitRepo,
300    base_branch: &str,
301    base_sha: &str,
302    mission_branch: &str,
303    metadata: Option<KranzCommitMetadata>,
304    standards_pin: Option<&crate::types::StandardsPin>,
305    standards_evidence: &StandardsMergeEvidence,
306    executor: F,
307) -> Result<MergeReport>
308where
309    F: Fn(&str, &Path) -> (bool, String),
310{
311    merge_with_external_stage(
312        repo,
313        base_branch,
314        base_sha,
315        mission_branch,
316        metadata,
317        standards_pin,
318        standards_evidence,
319        executor,
320        None,
321    )
322}
323
324/// Production merge with approval-pinned external gates and a single-writer
325/// audit session. The invoking capability supplies consent, never a checker.
326#[allow(clippy::too_many_arguments)]
327pub fn merge_mission_with_external_evidence<F>(
328    repo: &GitRepo,
329    base_branch: &str,
330    base_sha: &str,
331    mission_branch: &str,
332    metadata: Option<KranzCommitMetadata>,
333    standards_pin: Option<&crate::types::StandardsPin>,
334    standards_evidence: &StandardsMergeEvidence,
335    executor: F,
336    paths: &crate::paths::MissionPaths,
337    actor: crate::live_permission::Actor,
338) -> Result<MergeReport>
339where
340    F: Fn(&str, &Path) -> (bool, String),
341{
342    let mut audit = external::Audit::open(repo, paths, actor)?;
343    if let Some(audit) = &audit {
344        audit.verify_target(base_branch, base_sha, mission_branch)?;
345    }
346    let report = merge_with_external_stage(
347        repo,
348        base_branch,
349        base_sha,
350        mission_branch,
351        metadata,
352        standards_pin,
353        standards_evidence,
354        executor,
355        audit.as_mut(),
356    );
357    if let Some(audit) = &mut audit {
358        // A failed audit write cannot undo an already-landed local merge.
359        if let Err(error) = audit.record_outcome(&report) {
360            tracing::error!(%error, "could not record merge outcome");
361        }
362    }
363    report
364}
365
366#[allow(clippy::too_many_arguments)]
367fn merge_with_external_stage<F>(
368    repo: &GitRepo,
369    base_branch: &str,
370    base_sha: &str,
371    mission_branch: &str,
372    metadata: Option<KranzCommitMetadata>,
373    standards_pin: Option<&crate::types::StandardsPin>,
374    standards_evidence: &StandardsMergeEvidence,
375    executor: F,
376    mut external: Option<&mut external::Audit>,
377) -> Result<MergeReport>
378where
379    F: Fn(&str, &Path) -> (bool, String),
380{
381    // Every git command this merge issues — primary tree and scratch worktree
382    // alike — runs with hooks disabled: the scratch worktree shares the
383    // primary `.git`, so mission-authored gate code could plant
384    // `.git/hooks/*` and have the merge's own checkout/merge/worktree
385    // commands execute it with the server's full environment (exactly what
386    // the sanitized gate executor withholds). Worker-side git is untouched.
387    let repo = &repo.with_hooks_disabled()?;
388
389    if !repo.is_clean_tracked_strict()? {
390        return Ok(MergeReport::RefusedDirtyTree);
391    }
392    let primary_head_before_gates = repo.head_sha()?;
393    let primary_branch_before_gates = repo.current_branch()?;
394
395    // Resolve moving refs once. Every read, merge, and final advance below
396    // uses these SHAs so a late branch update cannot bypass validation.
397    let live_base_sha = repo.rev_parse(base_branch)?;
398    let mission_tip_sha = repo.rev_parse(mission_branch)?;
399
400    let diff = repo.diff_full(base_sha, &mission_tip_sha)?;
401    let allowlist = repo
402        .show_file(&live_base_sha, scrub::SECRET_ALLOWLIST_PATH)?
403        .map(|bytes| scrub::read_allowlist_text(&String::from_utf8_lossy(&bytes)))
404        .unwrap_or_default();
405    let findings = scrub::filter_allowed(scrub::scan_unified_diff(&diff), &allowlist);
406    if !findings.is_empty() {
407        return Ok(MergeReport::SecretScanFailed { findings });
408    }
409
410    let changed_paths = repo.changed_paths(base_sha, &mission_tip_sha)?;
411    let gate_bytes = match repo.show_file(&live_base_sha, MERGE_GATES_PATH)? {
412        Some(bytes) => bytes,
413        None => {
414            return Ok(MergeReport::GateConfigInvalid {
415                detail: format!(
416                    "live base branch {base_branch:?} has no tracked {MERGE_GATES_PATH}; add an explicit repo gate suite before merging"
417                ),
418            })
419        }
420    };
421    let gate_suite = match parse_gate_suite(&gate_bytes) {
422        Ok(suite) => suite,
423        Err(detail) => return Ok(MergeReport::GateConfigInvalid { detail }),
424    };
425
426    let stale_base = stale_base_warning(repo, base_branch, base_sha)?;
427    let merge_message = metadata
428        .as_ref()
429        .map(|metadata| with_kranz_trailers(&format!("Merge {mission_branch}"), metadata));
430
431    // Sweep scratch leftovers from any earlier merge that died between add
432    // and remove (a panic, kill -9, power loss): stale kranz-merge-* temp
433    // dirs and their .git/worktrees registrations would otherwise pile up
434    // forever. Best-effort — this merge proceeds either way.
435    remove_stale_scratch_worktrees(repo);
436
437    let scratch_path =
438        std::env::temp_dir().join(format!("kranz-merge-{}", uuid::Uuid::new_v4().simple()));
439    repo.add_detached_worktree(&scratch_path, &live_base_sha)?;
440    // RAII cleanup, created immediately after the worktree so a panic or an
441    // overlooked error path cannot leak it. Drop is best-effort (warn, never
442    // propagate): a merge that already landed must be reported as Merged,
443    // not turned into an error by a failed cleanup — the sweep above retries
444    // the removal on the next merge anyway.
445    let _scratch_cleanup = ScratchWorktree {
446        repo,
447        path: scratch_path.clone(),
448    };
449
450    let scratch = GitRepo::open(&scratch_path)?.with_hooks_disabled()?;
451    match scratch.merge_no_ff_with_message(&mission_tip_sha, merge_message.as_deref())? {
452        MergeOutcome::Conflict { files } => return Ok(MergeReport::Conflict { files }),
453        MergeOutcome::RefusedPreMerge { detail } => {
454            return Ok(MergeReport::RefusedPreMerge { detail })
455        }
456        MergeOutcome::Clean => {}
457    }
458    let tested_commit = scratch.head_sha()?;
459    if let Some(audit) = &mut external {
460        audit.prepare(repo, &scratch, &live_base_sha)?;
461    }
462
463    // Flight Rules policy-drift check (KRZ-342, design D-E): re-resolve the
464    // LIVE base standards policy against the exact scratch integration diff
465    // (live_base..tested_commit — what this merge would add to the base) and
466    // compare the applicable ENFORCED set against the approved pin's. A
467    // difference refuses BEFORE the gate suite: the mission needs explicit
468    // revalidation/reapproval, not a green gate run under policy the
469    // operator never consented to. `None` pin ⇒ skip, byte-identical.
470    if let Some(pin) = standards_pin {
471        let integration_paths = repo.changed_paths(&live_base_sha, &tested_commit)?;
472        if let Some(drift) =
473            crate::pack::resolution::merge_drift(repo, &live_base_sha, pin, &integration_paths)
474                .map_err(crate::error::EngineError::Git)?
475        {
476            return Ok(MergeReport::StandardsDrifted {
477                approved_digest: drift.approved_digest,
478                current_digest: drift.current_digest,
479                changed_rules: drift.changed_rules,
480            });
481        }
482
483        // KRZ-346 (D-F): checker bindings and commands come only from the
484        // approval pin. Deterministic merge rules execute once per stable
485        // gate id against the exact scratch integration tree. Approved rules
486        // and enforced SHOULDs still execute but remain advisory; only an
487        // enforced MUST can refuse the merge.
488        let rules = crate::standards_enforcement::applicable_rules(
489            pin,
490            &[crate::pack::standards::RuleStage::Merge],
491            &integration_paths,
492        );
493        let mut gate_rules: std::collections::BTreeMap<
494            String,
495            (&crate::types::PinnedGate, Vec<&crate::types::PinnedRule>),
496        > = std::collections::BTreeMap::new();
497        for rule in &rules {
498            match crate::standards_enforcement::checker_binding(pin, rule, &integration_paths) {
499                crate::standards_enforcement::CheckerBinding::Gate(gate) => {
500                    gate_rules
501                        .entry(gate.id.clone())
502                        .or_insert_with(|| (gate, Vec::new()))
503                        .1
504                        .push(rule);
505                }
506                crate::standards_enforcement::CheckerBinding::AgentJudgement => {
507                    if crate::standards_enforcement::rule_mode(rule)
508                        == crate::standards_enforcement::RuleMode::Authoritative
509                        && !standards_evidence.passed.contains(&rule.id)
510                        && !standards_evidence.current_waiver(
511                            repo,
512                            &live_base_sha,
513                            &tested_commit,
514                            &integration_paths,
515                            pin,
516                            rule,
517                            None,
518                        )?
519                    {
520                        return Ok(MergeReport::StandardsFailed {
521                            rule_id: rule.id.clone(),
522                            checker: rule.checker.clone().unwrap_or_default(),
523                            output: "no positive or exact-waived final checker evidence is present for this completed mission".to_string(),
524                        });
525                    }
526                }
527                crate::standards_enforcement::CheckerBinding::ManualAttestation => {
528                    if crate::standards_enforcement::rule_mode(rule)
529                        == crate::standards_enforcement::RuleMode::Authoritative
530                        && !standards_evidence.current_attestation(
531                            repo,
532                            &live_base_sha,
533                            &tested_commit,
534                            &integration_paths,
535                            pin,
536                            rule,
537                        )?
538                        && !standards_evidence.current_waiver(
539                            repo,
540                            &live_base_sha,
541                            &tested_commit,
542                            &integration_paths,
543                            pin,
544                            rule,
545                            None,
546                        )?
547                    {
548                        return Ok(MergeReport::StandardsFailed {
549                            rule_id: rule.id.clone(),
550                            checker: "manual-attestation".to_string(),
551                            output: "no authorized attestation or exact waiver matches the scratch integration diff".to_string(),
552                        });
553                    }
554                }
555                crate::standards_enforcement::CheckerBinding::Unavailable(detail) => {
556                    if crate::standards_enforcement::rule_mode(rule)
557                        == crate::standards_enforcement::RuleMode::Authoritative
558                    {
559                        return Ok(MergeReport::StandardsFailed {
560                            rule_id: rule.id.clone(),
561                            checker: rule
562                                .checker
563                                .clone()
564                                .unwrap_or_else(|| "<missing>".to_string()),
565                            output: detail,
566                        });
567                    }
568                }
569            }
570        }
571        for (_gate_id, (gate, bound_rules)) in gate_rules {
572            let (passed, output) = executor(&gate.command, scratch.root());
573            if passed {
574                continue;
575            }
576            for rule in bound_rules.into_iter().filter(|rule| {
577                crate::standards_enforcement::rule_mode(rule)
578                    == crate::standards_enforcement::RuleMode::Authoritative
579            }) {
580                let finding = crate::standards_enforcement::failure_finding(
581                    pin,
582                    rule,
583                    &scrub::scrub(&format!(
584                        "checker `{}` failed for {} r{}: {}",
585                        gate.id, rule.id, rule.revision, output
586                    )),
587                );
588                if !standards_evidence.current_waiver(
589                    repo,
590                    &live_base_sha,
591                    &tested_commit,
592                    &integration_paths,
593                    pin,
594                    rule,
595                    Some(&finding),
596                )? {
597                    return Ok(MergeReport::StandardsFailed {
598                        rule_id: rule.id.clone(),
599                        checker: format!("gate:{}", gate.id),
600                        output,
601                    });
602                }
603            }
604        }
605    }
606
607    // The suite runs through the first-class gate interface (gate.rs) —
608    // behavior is unchanged: same commands, same declared order, stop at
609    // first failure, suite bytes read from the live base branch above.
610    let outcome = MergeSuiteGate::new(scratch.root(), &changed_paths, gate_suite.clone(), executor)
611        .evaluate();
612    if outcome.verdict == GateVerdict::Fail {
613        return Ok(MergeReport::GateFailed {
614            gate: outcome.artefact.reference,
615            output: outcome.artefact.detail.unwrap_or_default(),
616        });
617    }
618
619    if let Some(audit) = &mut external {
620        if let Err(error) = audit.evaluate(
621            repo,
622            &scratch,
623            &live_base_sha,
624            &mission_tip_sha,
625            &gate_suite,
626            &changed_paths,
627        ) {
628            return Ok(MergeReport::RefusedPreMerge {
629                detail: error.to_string(),
630            });
631        }
632    }
633    let post_gate_head = scratch.head_sha()?;
634    let tracked_tree_clean = scratch.is_clean_tracked_strict()?;
635    if post_gate_head != tested_commit || !tracked_tree_clean {
636        return Ok(MergeReport::RefusedPreMerge {
637            detail: format!(
638                "merge gates mutated the integrated tree: expected HEAD {tested_commit}, \
639                 found {post_gate_head}, tracked files clean={tracked_tree_clean}; \
640                 refusing to land bytes other than the tested commit"
641            ),
642        });
643    }
644
645    // Production holds the repo-wide busy guard throughout this call.
646    // Re-check anyway so external/manual movement fails closed.
647    let current_base = repo.rev_parse(base_branch)?;
648    if current_base != live_base_sha {
649        return Ok(MergeReport::RefusedPreMerge {
650            detail: format!(
651                "base branch {base_branch:?} moved from {live_base_sha} to {current_base} while gates ran; retry the merge"
652            ),
653        });
654    }
655
656    let primary_head_after_gates = repo.head_sha()?;
657    let primary_branch_after_gates = repo.current_branch()?;
658    let primary_tree_clean = repo.is_clean_tracked_strict()?;
659    if primary_head_after_gates != primary_head_before_gates
660        || primary_branch_after_gates != primary_branch_before_gates
661        || !primary_tree_clean
662    {
663        return Ok(MergeReport::RefusedPreMerge {
664            detail: format!(
665                "merge gates mutated the primary checkout: expected \
666                 {primary_branch_before_gates}@{primary_head_before_gates}, found \
667                 {primary_branch_after_gates}@{primary_head_after_gates}, tracked files \
668                 clean={primary_tree_clean}; refusing to overwrite operator work"
669            ),
670        });
671    }
672
673    repo.checkout(base_branch)?;
674    strip_identical_untracked_twins(repo, base_sha, &mission_tip_sha)?;
675    match repo.fast_forward_to(&tested_commit)? {
676        MergeOutcome::Clean => Ok(MergeReport::Merged {
677            commit: tested_commit,
678            stale_base,
679        }),
680        MergeOutcome::RefusedPreMerge { detail } => Ok(MergeReport::RefusedPreMerge { detail }),
681        MergeOutcome::Conflict { .. } => unreachable!("--ff-only cannot create conflicts"),
682    }
683}
684
685/// RAII cleanup for the merge's detached scratch worktree.
686///
687/// Removal lives in `Drop` so a panic mid-merge (or an error path this module
688/// missed) cannot leak the temp directory and its
689/// `.git/worktrees/kranz-merge-*` registration. Cleanup is best-effort: a
690/// failure is logged at warn and never propagated, so a merge whose report is
691/// already decided keeps that report — [`remove_stale_scratch_worktrees`]
692/// retries the removal at the start of the next merge.
693struct ScratchWorktree<'a> {
694    repo: &'a GitRepo,
695    path: std::path::PathBuf,
696}
697
698impl Drop for ScratchWorktree<'_> {
699    fn drop(&mut self) {
700        if let Err(error) = self.repo.remove_worktree(&self.path) {
701            tracing::warn!(
702                path = %self.path.display(),
703                %error,
704                "failed to remove merge scratch worktree; the next merge will retry"
705            );
706        }
707    }
708}
709
710/// Best-effort removal of `kranz-merge-*` scratch worktrees leaked by an
711/// earlier merge that died between add and remove, plus a
712/// `git worktree prune` for registrations whose directories are already gone
713/// (invisible to `worktree remove`). Failures are logged and swallowed — a
714/// stale leftover must never block a fresh merge.
715fn remove_stale_scratch_worktrees(repo: &GitRepo) {
716    match repo.list_worktrees() {
717        Ok(worktrees) => {
718            for worktree in worktrees {
719                let path = Path::new(&worktree);
720                let is_scratch = path
721                    .file_name()
722                    .and_then(|name| name.to_str())
723                    .is_some_and(|name| name.starts_with("kranz-merge-"));
724                if !is_scratch {
725                    continue;
726                }
727                if let Err(error) = repo.remove_worktree(path) {
728                    tracing::warn!(
729                        path = %path.display(),
730                        %error,
731                        "failed to remove stale merge scratch worktree"
732                    );
733                }
734            }
735        }
736        Err(error) => {
737            tracing::warn!(%error, "failed to list worktrees for stale merge-scratch cleanup")
738        }
739    }
740    if let Err(error) = repo.prune_worktrees() {
741        tracing::warn!(%error, "failed to prune stale worktree registrations");
742    }
743}
744
745fn stale_base_warning(
746    repo: &GitRepo,
747    base_branch: &str,
748    base_sha: &str,
749) -> Result<Option<StaleBaseWarning>> {
750    let merge_commits_since_base = repo.merge_commit_count(base_sha, base_branch)?;
751    if merge_commits_since_base < STALE_BASE_MERGE_THRESHOLD {
752        return Ok(None);
753    }
754    Ok(Some(StaleBaseWarning {
755        base_sha: base_sha.to_string(),
756        live_base: base_branch.to_string(),
757        merge_commits_since_base,
758    }))
759}
760
761/// Removes untracked working-tree files the incoming merge would touch, but
762/// ONLY when their bytes are byte-identical to the version already committed
763/// on `mission_branch` — the operator-visibility "preview twins" written
764/// straight into the primary tree at canonical mission paths (plan.md,
765/// report.md, revised-plan.md) alongside the same paths tracked on the
766/// mission branch. Removing an identical file is lossless (the merge would
767/// write back the exact same bytes); a divergent or tracked file is left
768/// alone so git's own conflict/refusal machinery handles it.
769fn strip_identical_untracked_twins(
770    repo: &GitRepo,
771    base_sha: &str,
772    mission_branch: &str,
773) -> Result<()> {
774    for path in repo.changed_paths(base_sha, mission_branch)? {
775        if !repo.is_untracked(&path)? {
776            continue;
777        }
778        let incoming = match repo.show_file(mission_branch, &path)? {
779            Some(bytes) => bytes,
780            None => continue,
781        };
782        let full_path = repo.root().join(&path);
783        let current = match std::fs::read(&full_path) {
784            Ok(bytes) => bytes,
785            Err(_) => continue,
786        };
787        if current == incoming {
788            std::fs::remove_file(&full_path)?;
789        }
790    }
791    Ok(())
792}