1mod 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
26pub const STALE_BASE_MERGE_THRESHOLD: usize = 1;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum MergeReport {
33 RefusedDirtyTree,
35 GateFailed {
38 gate: String,
40 output: String,
42 },
43 GateConfigInvalid { detail: String },
47 SecretScanFailed { findings: Vec<SecretFinding> },
50 Conflict {
52 files: Vec<String>,
54 },
55 RefusedPreMerge {
59 detail: String,
61 },
62 StandardsDrifted {
69 approved_digest: String,
71 current_digest: Option<String>,
74 changed_rules: Vec<String>,
76 },
77 StandardsFailed {
82 rule_id: String,
83 checker: String,
84 output: String,
85 },
86 Merged {
88 commit: String,
90 stale_base: Option<StaleBaseWarning>,
93 },
94}
95
96#[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#[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
256pub 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#[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#[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 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 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 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 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 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 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 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 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 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
685struct 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
710fn 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
761fn 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}