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