1use 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
24pub const STALE_BASE_MERGE_THRESHOLD: usize = 1;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum MergeReport {
31 RefusedDirtyTree,
33 GateFailed {
36 gate: String,
38 output: String,
40 },
41 GateConfigInvalid { detail: String },
45 SecretScanFailed { findings: Vec<SecretFinding> },
48 Conflict {
50 files: Vec<String>,
52 },
53 RefusedPreMerge {
57 detail: String,
59 },
60 StandardsDrifted {
67 approved_digest: String,
69 current_digest: Option<String>,
72 changed_rules: Vec<String>,
74 },
75 StandardsFailed {
80 rule_id: String,
81 checker: String,
82 output: String,
83 },
84 Merged {
86 commit: String,
88 stale_base: Option<StaleBaseWarning>,
91 },
92}
93
94#[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#[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
254pub 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#[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 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 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 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 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 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 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 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 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
596struct 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
621fn 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
672fn 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}