1use std::{fs, path::Path};
21
22use anyhow::{Context, Result, anyhow};
23use merge::{
24 MergeBlobSource, MergeError, MergeOptions as EngineMergeOptions, MergeStrategy,
25 RenameMatcherStats, RenameOptions, SemanticMergeFn, SemanticSimilarityFn,
26 detect_renames_between_trees, merge_trees,
27};
28use objects::{
29 object::{Attribution, Blob, ContentHash, FacetKind, StateId, ThreadName, Tree},
30 store::ObjectStore,
31};
32use oplog::{OpBatch, OpLogBackend, OpLogRecorder, OpRecord};
33use refs::Head;
34use repo::{
35 ActorPresenceStatus, ActorPresenceStore, CommitGraphIndex, Repository, Thread, ThreadFreshness,
36 ThreadIntegrationPolicy, ThreadManager, ThreadState, describe_thread_advice, find_merge_base,
37 refresh_thread_freshness,
38};
39use schemars::JsonSchema;
40use serde::{Serialize, Serializer, ser::SerializeStruct};
41use sley::Repository as SleyRepository;
42
43use crate::{
44 ActionTemplate, DiffReport, SemanticChangeEntry, compute_state_diff, compute_tree_diff,
45 verify::{
46 MachineContractInput, RepositoryVerificationState, action_template,
47 build_repository_verification_state_with_machine_contract, serialize_empty_action_as_null,
48 },
49};
50
51mod advice;
52mod apply;
53mod git_commit;
54mod plan;
55mod relation;
56mod structured;
57mod worktree_safety;
58
59pub use apply::apply_merged_tree;
60pub use git_commit::{GitCommitInfo, GitCommitPreview};
61pub use merge::ConflictLabels;
62pub use plan::MergePlan;
63pub use relation::{MergeRelation, MergeRelationKind};
64pub use structured::build_conflict_payload;
65pub use worktree_safety::ensure_worktree_clean;
66
67struct RepositoryMergeBlobSource<'repo> {
70 repo: &'repo Repository,
71}
72
73impl MergeBlobSource for RepositoryMergeBlobSource<'_> {
74 fn load_blob(&self, hash: &ContentHash, _path: &str) -> Result<Vec<u8>> {
75 Ok(self.repo.require_blob(hash)?.content().to_vec())
76 }
77}
78
79pub(crate) fn map_tree_merge_error(error: anyhow::Error) -> anyhow::Error {
80 match error.downcast_ref::<MergeError>() {
81 Some(MergeError::RepositoryIntegrity {
82 error,
83 unsafe_condition,
84 would_change,
85 preserved,
86 }) => anyhow!(advice::merge_integrity_refusal(
87 error.clone(),
88 unsafe_condition.clone(),
89 would_change.clone(),
90 preserved.clone(),
91 )),
92 None => error,
93 }
94}
95
96#[derive(Clone, Debug, Serialize)]
97pub struct RenameEntry {
98 pub from: String,
99 pub to: String,
100 pub score: f64,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum OperatorAction {
106 Abort,
107 Bisect,
108 CherryPick,
109 #[default]
110 Continue,
111 Land,
112 Merge,
113 Ready,
114 Rebase,
115 Revert,
116 Sync,
117 ThreadCleanup,
118 ThreadDrop,
119 ThreadPromote,
120 ThreadRefresh,
121 ThreadResolve,
122}
123
124impl OperatorAction {
125 pub const fn wire_value(self) -> &'static str {
126 match self {
127 Self::Abort => "abort",
128 Self::Bisect => "bisect",
129 Self::CherryPick => "cherry-pick",
130 Self::Continue => "continue",
131 Self::Land => "land",
132 Self::Merge => "merge",
133 Self::Ready => "ready",
134 Self::Rebase => "rebase",
135 Self::Revert => "revert",
136 Self::Sync => "sync",
137 Self::ThreadCleanup => "thread_cleanup",
138 Self::ThreadDrop => "thread_drop",
139 Self::ThreadPromote => "thread_promote",
140 Self::ThreadRefresh => "thread_refresh",
141 Self::ThreadResolve => "thread_resolve",
142 }
143 }
144}
145
146impl Serialize for OperatorAction {
147 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
148 where
149 S: Serializer,
150 {
151 serializer.serialize_str(self.wire_value())
152 }
153}
154
155#[derive(Debug, Clone, Default)]
157pub struct OperatorCommandOutput {
158 pub status: String,
159 pub action: OperatorAction,
160 pub message: String,
161 pub blockers: Vec<String>,
162 pub warnings: Vec<String>,
163 pub next_action: Option<String>,
164 pub recommended_action: Option<String>,
165}
166
167impl OperatorCommandOutput {
168 fn serialize_with_output_kind<S>(
169 &self,
170 serializer: S,
171 output_kind: OperatorAction,
172 ) -> std::result::Result<S::Ok, S::Error>
173 where
174 S: Serializer,
175 {
176 let next_action = self.next_action.as_deref().filter(|a| !a.trim().is_empty());
177 let recommended_action = self
178 .recommended_action
179 .as_deref()
180 .filter(|a| !a.trim().is_empty());
181 let next_action_template = next_action.and_then(action_template);
182 let recommended_action_template = recommended_action.and_then(action_template);
183
184 let mut len = 8;
185 if !self.blockers.is_empty() {
186 len += 1;
187 }
188 if !self.warnings.is_empty() {
189 len += 1;
190 }
191
192 let mut state = serializer.serialize_struct("OperatorCommandOutput", len)?;
193 state.serialize_field("output_kind", &output_kind.wire_value())?;
194 state.serialize_field("status", &self.status)?;
195 state.serialize_field("action", &self.action)?;
196 state.serialize_field("message", &self.message)?;
197 if !self.blockers.is_empty() {
198 state.serialize_field("blockers", &self.blockers)?;
199 }
200 if !self.warnings.is_empty() {
201 state.serialize_field("warnings", &self.warnings)?;
202 }
203 state.serialize_field("next_action", &next_action)?;
204 state.serialize_field("next_action_template", &next_action_template)?;
205 state.serialize_field("recommended_action", &recommended_action)?;
206 state.serialize_field("recommended_action_template", &recommended_action_template)?;
207 state.end()
208 }
209}
210
211impl Serialize for OperatorCommandOutput {
212 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
213 where
214 S: Serializer,
215 {
216 self.serialize_with_output_kind(serializer, self.action)
217 }
218}
219
220impl OperatorCommandOutput {
221 pub fn blocked_by_repository_verification(
222 action: OperatorAction,
223 message: impl Into<String>,
224 trust: &RepositoryVerificationState,
225 ) -> Self {
226 let recommended_action = repository_verification_primary_command(trust);
227 Self {
228 status: "blocked".to_string(),
229 action,
230 message: message.into(),
231 blockers: repository_verification_blockers(trust),
232 warnings: Vec::new(),
233 next_action: Some(recommended_action.clone()),
234 recommended_action: Some(recommended_action),
235 }
236 }
237}
238
239fn repository_verification_primary_command(trust: &RepositoryVerificationState) -> String {
240 if trust.recommended_action.trim().is_empty() {
241 "heddle verify".to_string()
242 } else {
243 trust.recommended_action.clone()
244 }
245}
246
247fn repository_verification_blockers(trust: &RepositoryVerificationState) -> Vec<String> {
248 trust
249 .checks
250 .iter()
251 .filter(|check| !check.clean)
252 .map(|check| format!("{}: {}", check.name, check.summary))
253 .collect()
254}
255
256fn trust_state(
257 repo: &Repository,
258 machine_contract: &MachineContractInput,
259) -> Result<RepositoryVerificationState> {
260 Ok(build_repository_verification_state_with_machine_contract(
261 repo,
262 machine_contract,
263 )?)
264}
265
266#[derive(Clone, Debug, Serialize, JsonSchema)]
267pub struct ThreadPreviewReport {
268 pub thread: String,
269 pub thread_mode: String,
270 pub thread_state: String,
271 pub freshness: String,
272 pub task: Option<String>,
273 pub changed_paths: Vec<String>,
274 pub changed_path_count: usize,
275 pub impact_categories: Vec<String>,
276 pub heavy_impact_paths: Vec<String>,
277 pub merge_relation: String,
278 pub conflicts: Vec<String>,
279 pub conflict_count: usize,
280 pub blockers: Vec<String>,
281 #[serde(serialize_with = "serialize_empty_action_as_null")]
284 #[schemars(with = "Option<String>")]
285 pub recommended_action: String,
286 pub recommended_action_template: Option<ActionTemplate>,
287 pub thread_health: String,
288}
289
290impl ThreadPreviewReport {
291 pub fn refresh_recommended_action_metadata(&mut self) {
292 self.recommended_action_template = action_template(&self.recommended_action);
293 }
294}
295
296#[derive(Clone, Debug, Serialize)]
297pub struct MergeReport {
298 #[serde(flatten)]
299 pub operator: OperatorCommandOutput,
300 pub would_merge: bool,
301 pub applied: bool,
302 pub fast_forward: bool,
303 pub preview_only: bool,
304 pub merge_state: Option<String>,
305 pub conflicts: Vec<String>,
306 pub preview_summary: Vec<String>,
307 pub thread_state: Option<String>,
308 pub freshness: Option<String>,
309 pub changed_paths: Vec<String>,
310 pub changed_path_count: usize,
311 pub impact_categories: Vec<String>,
312 pub promotion_suggested: bool,
313 pub heavy_impact_paths: Vec<String>,
314 pub merge_relation: Option<String>,
315 pub conflict_count: usize,
316 pub thread_health: String,
317 #[serde(skip_serializing_if = "Vec::is_empty")]
318 pub renames: Vec<RenameEntry>,
319 #[serde(skip_serializing_if = "Vec::is_empty")]
320 pub directory_renames: Vec<RenameEntry>,
321 #[serde(skip_serializing_if = "Option::is_none")]
331 pub semantic_changes: Option<Vec<SemanticChangeEntry>>,
332 #[serde(skip_serializing_if = "Option::is_none")]
337 pub diff: Option<DiffReport>,
338 #[serde(skip_serializing_if = "Option::is_none")]
342 pub git_commit_preview: Option<GitCommitPreview>,
343 #[serde(skip_serializing_if = "Option::is_none")]
347 pub git_commit: Option<GitCommitInfo>,
348 #[serde(skip_serializing)]
349 #[serde(skip_serializing_if = "Option::is_none")]
350 #[serde(rename = "verification")]
351 pub trust: Option<RepositoryVerificationState>,
352}
353
354struct MergeReportInput<'a> {
355 repo: &'a Repository,
356 machine_contract: &'a MachineContractInput,
360 thread: &'a Option<Thread>,
361 preview_report: Option<&'a ThreadPreviewReport>,
362 conflicts: Option<Vec<String>>,
363 merge_relation: Option<String>,
364 conflict_count: Option<usize>,
365 changed_paths: Option<Vec<String>>,
366 preview_summary: Vec<String>,
367 message: String,
368 renames: Vec<RenameEntry>,
369 directory_renames: Vec<RenameEntry>,
370 merge_state: Option<String>,
371 fast_forward: bool,
372 preview_only: bool,
373 diff: Option<DiffReport>,
374 git_commit_preview: Option<GitCommitPreview>,
375 git_commit: Option<GitCommitInfo>,
376 extra_blockers: Vec<String>,
381 semantic_changes: Option<Vec<SemanticChangeEntry>>,
386}
387
388struct SourceThreadUncapturedWork {
389 checkout_path: String,
390 dirty_paths: Vec<String>,
391}
392
393fn semantic_merge_enabled(no_semantic: bool) -> bool {
394 cfg!(feature = "semantic") && !no_semantic
395}
396
397fn merge_strategy_for(use_semantic: bool) -> MergeStrategy {
398 if use_semantic {
399 MergeStrategy::Semantic
400 } else {
401 MergeStrategy::HunkOnly
402 }
403}
404
405pub fn tree_merge_options(labels: ConflictLabels<'_>) -> EngineMergeOptions<'_> {
406 EngineMergeOptions {
407 labels,
408 rename_options: RenameOptions {
409 semantic_similarity: semantic_similarity_hook(),
410 ..RenameOptions::default()
411 },
412 semantic_merge: semantic_merge_hook(),
413 }
414}
415
416#[cfg(feature = "semantic")]
417fn semantic_merge_hook() -> Option<SemanticMergeFn> {
418 Some(semantic::merge_driver::semantic_three_way_merge)
419}
420
421#[cfg(not(feature = "semantic"))]
422fn semantic_merge_hook() -> Option<SemanticMergeFn> {
423 None
424}
425
426#[cfg(feature = "semantic")]
427fn semantic_similarity_hook() -> Option<SemanticSimilarityFn> {
428 Some(compute_semantic_similarity)
429}
430
431#[cfg(not(feature = "semantic"))]
432fn semantic_similarity_hook() -> Option<SemanticSimilarityFn> {
433 None
434}
435
436#[cfg(feature = "semantic")]
437fn compute_semantic_similarity(
438 from_path: &str,
439 to_path: &str,
440 from_content: &[u8],
441 to_content: &[u8],
442) -> f64 {
443 let Ok(from_str) = std::str::from_utf8(from_content) else {
444 return 0.0;
445 };
446 let Ok(to_str) = std::str::from_utf8(to_content) else {
447 return 0.0;
448 };
449
450 let language = semantic::parser::Language::from_path(std::path::Path::new(from_path));
451 let language = if language == semantic::parser::Language::Unknown {
452 semantic::parser::Language::from_path(std::path::Path::new(to_path))
453 } else {
454 language
455 };
456
457 semantic::analysis::analysis_similarity::compute_similarity_with_language(
458 from_str,
459 to_str,
460 semantic::analysis::analysis_similarity::SimilarityMethod::Ast,
461 language,
462 )
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq)]
478pub struct MergeAttemptPlan {
479 strategy: MergeStrategy,
480 use_semantic: bool,
485}
486
487impl MergeAttemptPlan {
488 pub(crate) fn decide(no_semantic: bool) -> Self {
492 let use_semantic = semantic_merge_enabled(no_semantic);
493 Self {
494 strategy: merge_strategy_for(use_semantic),
495 use_semantic,
496 }
497 }
498
499 pub(crate) fn strategy(&self) -> MergeStrategy {
503 self.strategy
504 }
505
506 pub(crate) fn use_semantic(&self) -> bool {
509 self.use_semantic
510 }
511}
512
513#[allow(clippy::too_many_arguments)]
514#[derive(Clone, Debug)]
516pub struct MergeOptions {
517 pub track_name: String,
518 pub message: Option<String>,
519 pub no_commit: bool,
520 pub preview: bool,
521 pub with_diff: bool,
522 pub no_semantic: bool,
523 pub git_commit: bool,
524}
525
526pub fn merge_thread(repo: &Repository, opts: MergeOptions) -> Result<MergeReport> {
528 FacetKind::SourceHistory
529 .require_land()
530 .map_err(|kind| anyhow!("{kind} cannot be landed onto source history"))?;
531 merge_thread_into_current(
532 repo,
533 &opts.track_name,
534 opts.message,
535 opts.no_commit,
536 opts.preview,
537 opts.with_diff,
538 opts.no_semantic,
539 opts.git_commit,
540 )
541}
542
543#[allow(clippy::too_many_arguments)]
547pub fn merge_thread_into_current(
548 repo: &Repository,
549 track_name: &str,
550 message: Option<String>,
551 no_commit: bool,
552 preview: bool,
553 with_diff: bool,
554 no_semantic: bool,
555 git_commit: bool,
556) -> Result<MergeReport> {
557 merge_thread_into_current_with_machine_contract(
558 repo,
559 track_name,
560 message,
561 no_commit,
562 preview,
563 with_diff,
564 no_semantic,
565 git_commit,
566 &MachineContractInput::default(),
567 )
568}
569
570#[allow(clippy::too_many_arguments)]
575pub fn merge_thread_into_current_with_machine_contract(
576 repo: &Repository,
577 track_name: &str,
578 message: Option<String>,
579 no_commit: bool,
580 preview: bool,
581 with_diff: bool,
582 no_semantic: bool,
583 git_commit: bool,
584 machine_contract: &MachineContractInput,
585) -> Result<MergeReport> {
586 merge_thread_into_current_transactional(
587 repo,
588 track_name,
589 message,
590 no_commit,
591 preview,
592 with_diff,
593 no_semantic,
594 git_commit,
595 machine_contract,
596 None,
597 )
598}
599
600#[allow(clippy::too_many_arguments)]
604pub fn merge_thread_into_current_transactional(
605 repo: &Repository,
606 track_name: &str,
607 message: Option<String>,
608 no_commit: bool,
609 preview: bool,
610 with_diff: bool,
611 no_semantic: bool,
612 git_commit: bool,
613 machine_contract: &MachineContractInput,
614 transaction_id: Option<&str>,
615) -> Result<MergeReport> {
616 FacetKind::SourceHistory
617 .require_land()
618 .map_err(|kind| anyhow!("{kind} cannot be landed onto source history"))?;
619 let attempt = MergeAttemptPlan::decide(no_semantic);
625 let use_semantic = attempt.use_semantic();
626 let registry = ActorPresenceStore::new(repo.heddle_dir());
627 let thread_manager = ThreadManager::new(repo.heddle_dir());
628 let mut thread = thread_manager.find_by_thread(track_name)?;
629 if let Some(ref mut thread) = thread {
630 refresh_thread_freshness(repo, thread)?;
631 }
632 let thread_entry = registry
633 .list()?
634 .into_iter()
635 .filter(|entry| entry.thread == track_name)
636 .max_by_key(|entry| entry.started_at);
637
638 let merge_manager = repo.merge_state_manager();
639 if merge_manager.is_merge_in_progress() {
640 return Err(anyhow!(advice::merge_already_in_progress()));
641 }
642
643 if preview {
644 ensure_worktree_clean(repo, "merge")?;
645 }
646
647 if preview {
648 let trust = trust_state(repo, machine_contract)?;
649 if trust_blocks_merge_preview(&trust) {
650 return Ok(merge_blocked_by_trust_output(
651 &thread, None, trust, preview, None,
652 ));
653 }
654 }
655
656 let merge_target_id = repo
657 .refs()
658 .get_thread(&ThreadName::new(track_name))?
659 .ok_or_else(|| anyhow!(advice::thread_not_found(track_name, "merge")))?;
660
661 let current_change = repo
662 .current_state()?
663 .map(|state| state.state_id)
664 .ok_or_else(|| {
665 anyhow!(
666 "No current state to merge into; capture or bootstrap the repository before merging"
667 )
668 })?;
669 let current_state = repo
670 .store()
671 .get_state(¤t_change)?
672 .ok_or_else(|| anyhow!("Current state not found"))?;
673
674 ensure_worktree_clean(repo, "merge")?;
675
676 let mut graph = CommitGraphIndex::new(repo);
677 let current_thread = repo
684 .current_lane()?
685 .unwrap_or_else(|| "detached".to_string());
686 let preview_report = match thread.as_mut() {
694 Some(thread) => Some(build_thread_preview_report_with_graph(
695 repo,
696 &mut graph,
697 thread,
698 preview,
699 attempt.strategy(),
700 Some(PreviewTarget {
701 label: ¤t_thread,
702 state_id: current_state.state_id,
703 }),
704 )?),
705 None => None,
706 };
707 if let Some(thread) = thread.as_ref()
708 && let Some(uncaptured) = source_thread_uncaptured_work(repo, thread)?
709 {
710 return Err(anyhow!(advice::source_thread_uncaptured_work(
711 &thread.id,
712 &uncaptured.checkout_path,
713 &uncaptured.dirty_paths,
714 preview,
715 )));
716 }
717 if let Some(output) = merge_freshness_preflight_output(
718 repo,
719 machine_contract,
720 &thread,
721 preview_report.as_ref(),
722 preview,
723 )? {
724 return Ok(output);
725 }
726 let preview_summary = build_preview_summary(preview_report.as_ref());
727 let current_label = format!("CURRENT ({current_thread})");
728 let incoming_label = format!("INCOMING ({track_name})");
729 let merge_plan = MergePlan::for_merge_command(
730 repo,
731 &mut graph,
732 ¤t_state.state_id,
733 &merge_target_id,
734 ConflictLabels {
735 current: ¤t_label,
736 incoming: &incoming_label,
737 strategy: attempt.strategy(),
738 },
739 )?;
740
741 let diff_for = |from: &StateId, to: &StateId| -> Result<Option<DiffReport>> {
749 if !with_diff {
750 return Ok(None);
751 }
752 Ok(Some(compute_state_diff(repo, from, to, use_semantic, 3)?))
753 };
754 let top_level_semantic = |diff: Option<&DiffReport>| -> Option<Vec<SemanticChangeEntry>> {
764 if !use_semantic || !with_diff {
765 return None;
766 }
767 Some(
768 diff.and_then(|d| d.semantic_changes.clone())
769 .unwrap_or_default(),
770 )
771 };
772
773 if merge_plan.relation().kind() == MergeRelationKind::AlreadyUpToDate {
774 let trust = trust_state(repo, machine_contract)?;
775 if !trust.verified {
776 return Ok(merge_blocked_by_trust_output(
777 &thread,
778 preview_report.as_ref(),
779 trust,
780 preview,
781 Some(merge_plan.relation().as_json_value().to_string()),
782 ));
783 }
784 let already_up_to_date_diff = if with_diff {
789 Some(empty_diff_output(¤t_state.state_id))
790 } else {
791 None
792 };
793 return merge_output_from_report(MergeReportInput {
794 repo,
795 machine_contract,
796 thread: &thread,
797 preview_report: preview_report.as_ref(),
798 conflicts: Some(vec![]),
799 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
800 conflict_count: Some(0),
801 changed_paths: Some(Vec::new()),
802 preview_summary: vec![],
803 message: "Already up to date".to_string(),
804 renames: vec![],
805 directory_renames: vec![],
806 merge_state: None,
807 fast_forward: false,
808 preview_only: preview,
809 semantic_changes: top_level_semantic(already_up_to_date_diff.as_ref()),
810 diff: already_up_to_date_diff,
811 git_commit_preview: None,
812 git_commit: None,
813 extra_blockers: Vec::new(),
814 });
815 }
816
817 if merge_plan.relation().kind() == MergeRelationKind::FastForward {
818 let ff_paths = merge_changed_paths(repo, ¤t_state.state_id, &merge_target_id)?;
822
823 let (ff_renames, ff_directory_renames) =
826 fast_forward_renames(repo, ¤t_state.state_id, &merge_target_id)?;
827 let ff_diff = diff_for(¤t_state.state_id, &merge_target_id)?
828 .map(|diff| diff_with_known_renames(diff, &ff_renames));
829
830 let mut git_commit_blockers: Vec<String> = Vec::new();
835 if git_commit
836 && !preview
837 && let Err(blocked) = git_commit::validate_git_state(repo, &ff_paths)
838 {
839 git_commit_blockers = blocked.blockers;
840 }
841
842 if !git_commit_blockers.is_empty() {
843 return merge_output_from_report(MergeReportInput {
845 repo,
846 machine_contract,
847 thread: &thread,
848 preview_report: preview_report.as_ref(),
849 conflicts: Some(vec![]),
850 merge_relation: Some("fast_forward".to_string()),
851 conflict_count: Some(0),
852 changed_paths: Some(ff_paths.clone()),
853 preview_summary,
854 message: "Fast-forward blocked: --git-commit precondition failed".to_string(),
855 renames: ff_renames,
856 directory_renames: ff_directory_renames,
857 merge_state: None,
858 fast_forward: false,
859 preview_only: preview,
860 semantic_changes: top_level_semantic(ff_diff.as_ref()),
861 diff: ff_diff,
862 git_commit_preview: None,
863 git_commit: None,
864 extra_blockers: git_commit_blockers,
865 });
866 }
867
868 let git_branch_before = if git_commit && !preview {
869 Some(
870 repo.git_overlay_current_branch()?
871 .unwrap_or_else(|| "HEAD".to_string()),
872 )
873 } else {
874 None
875 };
876 let git_oid_before = if git_commit && !preview {
877 git_rev_parse_head(repo.root())
878 } else {
879 None
880 };
881 let source_git_parent = if git_commit {
882 source_git_parent_for_thread(repo, track_name, &merge_target_id)?
883 } else {
884 None
885 };
886 let mut git_commit_preview_payload: Option<GitCommitPreview> = None;
887 let mut git_commit_info: Option<GitCommitInfo> = None;
888
889 if !preview {
890 if let Some(transaction_id) = transaction_id {
905 repo.fast_forward_attached_transactional(
906 track_name,
907 &merge_target_id,
908 transaction_id,
909 )?;
910 } else {
911 let head_before_ff = repo.head_ref()?;
912 repo.fast_forward_attached_without_record(&merge_target_id)?;
913 match &head_before_ff {
914 Head::Attached {
915 thread: target_thread,
916 } => {
917 repo.oplog().record_fast_forward(
918 &ThreadName::new(track_name),
919 target_thread,
920 ¤t_state.state_id,
921 &merge_target_id,
922 Some(&repo.op_scope()),
923 )?;
924 }
925 Head::Detached { state } => {
926 repo.oplog().record_goto(
927 &merge_target_id,
928 Some(state),
929 Some(&repo.op_scope()),
930 )?;
931 }
932 }
933 }
934 if let Some(entry) = &thread_entry {
935 registry.update_status(&entry.session_id, ActorPresenceStatus::Merged)?;
936 }
937 if let Some(thread) = thread.as_mut() {
938 thread.state = ThreadState::Merged;
939 thread.merged_state = Some(merge_target_id.short());
940 thread.current_state = Some(merge_target_id.short());
941 thread.updated_at = chrono::Utc::now();
942 thread.freshness = ThreadFreshness::Current;
943 thread_manager.save(thread)?;
944 }
945
946 if git_commit {
947 let attribution = Attribution::human(repo.get_principal()?);
951 let ff_message = preview_merge_message(repo, &message, thread.as_ref(), track_name);
952 let commit_message = git_commit::build_commit_message(
953 &ff_message,
954 &merge_target_id.short(),
955 &attribution,
956 );
957 let extra_parents = source_git_parent.clone().into_iter().collect::<Vec<_>>();
958 let info = git_commit::write_git_commit(
959 repo,
960 &merge_target_id,
961 &ff_paths,
962 &commit_message,
963 &extra_parents,
964 )?;
965 finalize_merge_git_checkpoint(
966 repo,
967 &merge_target_id,
968 git_branch_before.unwrap_or_else(|| "HEAD".to_string()),
969 git_oid_before,
970 &info.sha,
971 &ff_message,
972 )?;
973 git_commit_info = Some(info);
974 }
975 } else if git_commit {
976 let attribution = Attribution::human(repo.get_principal()?);
978 let ff_message = preview_merge_message(repo, &message, thread.as_ref(), track_name);
979 let preview_msg = git_commit::build_commit_message(
980 &ff_message,
981 &merge_target_id.short(),
982 &attribution,
983 );
984 git_commit_preview_payload = Some(GitCommitPreview {
985 message: preview_msg,
986 files: ff_paths.clone(),
987 });
988 }
989 let output_changed_paths = ff_paths.clone();
990 let output_changed_path_count = output_changed_paths.len();
991
992 let recommended_action = if preview {
993 if let Some(thread) = thread.as_ref() {
994 if thread.state == ThreadState::Ready {
995 mark_merge_previewed(repo, &thread.id)?;
996 }
997 if let Some(report) = preview_report.as_ref()
998 && !report.blockers.is_empty()
999 && !report.recommended_action.trim().is_empty()
1000 && report
1001 .blockers
1002 .iter()
1003 .any(|blocker| is_real_merge_blocker(blocker))
1004 {
1005 Some(report.recommended_action.clone())
1006 } else {
1007 Some(land_local_command(&thread.thread))
1008 }
1009 } else {
1010 None
1011 }
1012 } else {
1013 None
1014 };
1015 return Ok(MergeReport {
1016 operator: OperatorCommandOutput {
1017 status: if preview { "preview" } else { "completed" }.to_string(),
1018 action: OperatorAction::Merge,
1019 message: match (preview, git_commit, repo.head_ref()?) {
1020 (true, true, Head::Attached { thread }) => {
1021 format!(
1022 "Would advance {} to {} and write a Git checkpoint commit",
1023 thread,
1024 merge_target_id.short()
1025 )
1026 }
1027 (true, true, Head::Detached { .. }) => {
1028 format!(
1029 "Would advance to {} and write a Git checkpoint commit",
1030 merge_target_id.short()
1031 )
1032 }
1033 (false, true, Head::Attached { thread }) => {
1034 format!(
1035 "Advanced {} to {} and wrote a Git checkpoint commit",
1036 thread,
1037 merge_target_id.short()
1038 )
1039 }
1040 (false, true, Head::Detached { .. }) => {
1041 format!(
1042 "Advanced to {} and wrote a Git checkpoint commit",
1043 merge_target_id.short()
1044 )
1045 }
1046 (true, false, Head::Attached { thread }) => {
1047 format!(
1048 "Would fast-forward {} to {}",
1049 thread,
1050 merge_target_id.short()
1051 )
1052 }
1053 (true, false, Head::Detached { .. }) => {
1054 format!("Would fast-forward to {}", merge_target_id.short())
1055 }
1056 (false, false, Head::Attached { thread }) => {
1057 format!("Fast-forwarded {} to {}", thread, merge_target_id.short())
1058 }
1059 (false, false, Head::Detached { .. }) => {
1060 format!("Fast-forwarded to {}", merge_target_id.short())
1061 }
1062 },
1063 blockers: Vec::new(),
1069 warnings: preview_report
1070 .as_ref()
1071 .map(|r| r.blockers.clone())
1072 .unwrap_or_default(),
1073 next_action: recommended_action.clone(),
1074 recommended_action: recommended_action.clone(),
1075 },
1076 would_merge: preview,
1077 applied: !preview,
1078 fast_forward: true,
1079 preview_only: preview,
1080 merge_state: (!preview).then(|| merge_target_id.short()),
1081 conflicts: vec![],
1082 preview_summary,
1083 thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
1084 freshness: thread.as_ref().map(|thread| thread.freshness.to_string()),
1085 changed_paths: output_changed_paths,
1086 changed_path_count: output_changed_path_count,
1087 impact_categories: thread_impacts(&thread),
1088 promotion_suggested: thread
1089 .as_ref()
1090 .map(|thread| thread.promotion_suggested)
1091 .unwrap_or(false),
1092 heavy_impact_paths: thread_heavy_paths(&thread),
1093 merge_relation: Some("fast_forward".to_string()),
1094 conflict_count: 0,
1095 thread_health: merge_output_thread_health(thread.as_ref(), preview_report.as_ref()),
1096 renames: ff_renames,
1097 directory_renames: ff_directory_renames,
1098 semantic_changes: top_level_semantic(ff_diff.as_ref()),
1099 diff: ff_diff,
1100 git_commit_preview: git_commit_preview_payload,
1101 git_commit: git_commit_info,
1102 trust: Some({
1103 let mut trust = trust_state(repo, machine_contract)?;
1104 if let Some(action) = recommended_action.as_ref() {
1105 override_trust_recommended_action(&mut trust, action.clone());
1106 }
1107 trust
1108 }),
1109 });
1110 }
1111
1112 let merge_base_id = merge_plan
1113 .relation()
1114 .merge_base_id()
1115 .ok_or_else(|| anyhow!("Merge base missing from merge plan"))?;
1116 let merge_result = merge_plan
1117 .merge_result()
1118 .ok_or_else(|| anyhow!("Merge result missing from merge plan"))?;
1119 let rename_entries: Vec<RenameEntry> = merge_result
1120 .renames
1121 .iter()
1122 .map(|rename| RenameEntry {
1123 from: rename.from.clone(),
1124 to: rename.to.clone(),
1125 score: rename.score,
1126 })
1127 .collect();
1128 let dir_rename_entries: Vec<RenameEntry> = merge_result
1129 .directory_renames
1130 .iter()
1131 .map(|rename| RenameEntry {
1132 from: rename.from.clone(),
1133 to: rename.to.clone(),
1134 score: 1.0,
1135 })
1136 .collect();
1137
1138 if preview {
1139 let git_commit_preview = if git_commit && merge_result.conflicts.is_empty() {
1146 let preview_message =
1147 preview_merge_message(repo, &message, thread.as_ref(), track_name);
1148 let attribution = Attribution::human(repo.get_principal()?);
1149 let preview_msg =
1150 git_commit::build_commit_message(&preview_message, "<pending>", &attribution);
1151 Some(GitCommitPreview {
1152 message: preview_msg,
1153 files: merge_changed_paths(repo, ¤t_state.state_id, &merge_target_id)?,
1154 })
1155 } else {
1156 None
1157 };
1158 let preview_path_diff = compute_tree_diff(
1163 repo,
1164 ¤t_state.state_id,
1165 &merge_result.tree,
1166 "<merged-preview>",
1167 with_diff && use_semantic,
1168 if with_diff { 3 } else { 0 },
1169 )
1170 .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1171 let preview_changed_paths = diff_changed_paths(&preview_path_diff);
1172 let preview_diff = with_diff.then_some(preview_path_diff);
1173 if merge_result.conflicts.is_empty()
1174 && thread
1175 .as_ref()
1176 .is_some_and(|thread| thread.state == ThreadState::Ready)
1177 && let Some(thread) = thread.as_ref()
1178 {
1179 mark_merge_previewed(repo, &thread.id)?;
1180 }
1181 return merge_output_from_report(MergeReportInput {
1182 repo,
1183 machine_contract,
1184 thread: &thread,
1185 preview_report: preview_report.as_ref(),
1186 conflicts: Some(merge_result.conflicts.clone()),
1187 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1188 conflict_count: Some(merge_plan.relation().conflict_count()),
1189 changed_paths: Some(preview_changed_paths.clone()),
1190 preview_summary,
1191 message: merge_preview_message(
1192 thread.as_ref(),
1193 track_name,
1194 merge_result.conflicts.len(),
1195 preview_changed_paths.len(),
1196 ),
1197 renames: rename_entries.clone(),
1198 directory_renames: dir_rename_entries.clone(),
1199 merge_state: None,
1200 fast_forward: false,
1201 preview_only: true,
1202 semantic_changes: top_level_semantic(preview_diff.as_ref()),
1203 diff: preview_diff,
1204 git_commit_preview,
1205 git_commit: None,
1206 extra_blockers: Vec::new(),
1207 });
1208 }
1209
1210 apply_merged_tree(repo, &merge_result.tree)?;
1211
1212 if !merge_result.conflicts.is_empty() {
1213 let structured_conflicts = merge_plan
1214 .structured_conflicts()
1215 .map(|payload| -> Result<ContentHash> {
1216 let bytes = payload.encode()?;
1217 Ok(repo.store().put_blob(&Blob::new(bytes))?)
1218 })
1219 .transpose()?;
1220 merge_manager.start(
1221 current_state.state_id,
1222 merge_target_id,
1223 Some(merge_base_id),
1224 merge_result.conflicts.clone(),
1225 structured_conflicts,
1226 )?;
1227 let conflict_diff = if with_diff {
1233 Some(empty_diff_output(¤t_state.state_id))
1234 } else {
1235 None
1236 };
1237 return merge_output_from_report(MergeReportInput {
1238 repo,
1239 machine_contract,
1240 thread: &thread,
1241 preview_report: preview_report.as_ref(),
1242 conflicts: Some(merge_result.conflicts.clone()),
1243 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1244 conflict_count: Some(merge_plan.relation().conflict_count()),
1245 changed_paths: Some(merge_result.conflicts.clone()),
1246 preview_summary,
1247 message: "Merged with conflicts".to_string(),
1248 renames: rename_entries,
1249 directory_renames: dir_rename_entries,
1250 merge_state: None,
1251 fast_forward: false,
1252 preview_only: false,
1253 semantic_changes: top_level_semantic(conflict_diff.as_ref()),
1254 diff: conflict_diff,
1255 git_commit_preview: None,
1256 git_commit: None,
1257 extra_blockers: Vec::new(),
1258 });
1259 }
1260
1261 if no_commit {
1262 let no_commit_path_diff = compute_tree_diff(
1273 repo,
1274 ¤t_state.state_id,
1275 &merge_result.tree,
1276 "<merged-no-commit>",
1277 false,
1278 0,
1279 )
1280 .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1281 let no_commit_changed_paths = diff_changed_paths(&no_commit_path_diff);
1282 let no_commit_diff: Option<DiffReport> = None;
1283 return merge_output_from_report(MergeReportInput {
1284 repo,
1285 machine_contract,
1286 thread: &thread,
1287 preview_report: preview_report.as_ref(),
1288 conflicts: Some(vec![]),
1289 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1290 conflict_count: Some(merge_plan.relation().conflict_count()),
1291 changed_paths: Some(no_commit_changed_paths),
1292 preview_summary,
1293 message: "Merge applied (not committed)".to_string(),
1294 renames: rename_entries,
1295 directory_renames: dir_rename_entries,
1296 merge_state: None,
1297 fast_forward: false,
1298 preview_only: false,
1299 semantic_changes: top_level_semantic(no_commit_diff.as_ref()),
1300 diff: no_commit_diff,
1301 git_commit_preview: None,
1302 git_commit: None,
1303 extra_blockers: Vec::new(),
1304 });
1305 }
1306
1307 let merge_message =
1308 message.unwrap_or_else(|| default_merge_message(repo, thread.as_ref(), track_name));
1309
1310 let attribution = Attribution::human(repo.get_principal()?);
1311 let merge_paths: Vec<String> = if git_commit {
1320 merge_changed_paths(repo, ¤t_state.state_id, &merge_target_id)?
1321 } else {
1322 Vec::new()
1323 };
1324 let mut git_commit_blockers: Vec<String> = Vec::new();
1325 if git_commit {
1326 if let Err(blocked) = git_commit::validate_git_state(repo, &merge_paths) {
1327 git_commit_blockers = blocked.blockers;
1328 }
1329 let extended = validate_git_commit_preconditions_extended(repo.root(), &merge_paths);
1337 git_commit_blockers.extend(extended);
1338 }
1339 if !git_commit_blockers.is_empty() {
1340 let blocked_diff = if with_diff {
1345 Some(empty_diff_output(¤t_state.state_id))
1346 } else {
1347 None
1348 };
1349 return merge_output_from_report(MergeReportInput {
1350 repo,
1351 machine_contract,
1352 thread: &thread,
1353 preview_report: preview_report.as_ref(),
1354 conflicts: Some(vec![]),
1355 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1356 conflict_count: Some(merge_plan.relation().conflict_count()),
1357 changed_paths: Some(Vec::new()),
1358 preview_summary,
1359 message: "Merge blocked: git --git-commit precondition failed".to_string(),
1360 renames: rename_entries,
1361 directory_renames: dir_rename_entries,
1362 merge_state: None,
1363 fast_forward: false,
1364 preview_only: false,
1365 semantic_changes: top_level_semantic(blocked_diff.as_ref()),
1366 diff: blocked_diff,
1367 git_commit_preview: None,
1368 git_commit: None,
1369 extra_blockers: git_commit_blockers,
1370 });
1371 }
1372
1373 let git_branch_before = if git_commit {
1374 Some(
1375 repo.git_overlay_current_branch()?
1376 .unwrap_or_else(|| "HEAD".to_string()),
1377 )
1378 } else {
1379 None
1380 };
1381 let git_oid_before = if git_commit {
1382 git_rev_parse_head(repo.root())
1383 } else {
1384 None
1385 };
1386 let source_git_parent = if git_commit {
1387 source_git_parent_for_thread(repo, track_name, &merge_target_id)?
1388 } else {
1389 None
1390 };
1391
1392 let new_state = repo.snapshot_merge_with_attribution_transaction(
1393 &merge_target_id,
1394 Some(merge_message.clone()),
1395 None,
1396 attribution.clone(),
1397 Some(merge_base_id),
1398 false,
1399 transaction_id,
1400 )?;
1401
1402 if let Some(entry) = &thread_entry {
1403 registry.update_status(&entry.session_id, ActorPresenceStatus::Merged)?;
1404 }
1405 if let Some(thread) = thread.as_mut() {
1406 thread.state = ThreadState::Merged;
1407 thread.merged_state = Some(new_state.state_id.short());
1408 thread.current_state = Some(new_state.state_id.short());
1409 thread.updated_at = chrono::Utc::now();
1410 thread.freshness = ThreadFreshness::Current;
1411 thread_manager.save(thread)?;
1412 }
1413
1414 let mut git_commit_info: Option<GitCommitInfo> = None;
1423 let mut post_snapshot_git_blockers: Vec<String> = Vec::new();
1424 if git_commit {
1425 let commit_message = git_commit::build_commit_message(
1426 &merge_message,
1427 &new_state.state_id.short(),
1428 &attribution,
1429 );
1430 let extra_parents = source_git_parent.clone().into_iter().collect::<Vec<_>>();
1431 match git_commit::write_git_commit(
1432 repo,
1433 &new_state.state_id,
1434 &merge_paths,
1435 &commit_message,
1436 &extra_parents,
1437 ) {
1438 Ok(info) => {
1439 git_commit_info = Some(info.clone());
1440 if let Err(err) = finalize_merge_git_checkpoint(
1441 repo,
1442 &new_state.state_id,
1443 git_branch_before.unwrap_or_else(|| "HEAD".to_string()),
1444 git_oid_before,
1445 &info.sha,
1446 &merge_message,
1447 ) {
1448 tracing::warn!(
1449 error = %err,
1450 state = %new_state.state_id.short(),
1451 git_commit = %info.sha,
1452 "git commit succeeded after Heddle integration, but Git metadata recording failed"
1453 );
1454 post_snapshot_git_blockers.push(format!(
1455 "git commit {} was written for integrated Heddle state {}, but Git metadata recording failed: {}",
1456 info.sha,
1457 new_state.state_id.short(),
1458 err
1459 ));
1460 post_snapshot_git_blockers.push(format!(
1461 "recovery: integrated Heddle state {} and Git commit {} are intact; run `heddle verify` \
1462 and use its primary recovery command before undoing this integration",
1463 new_state.state_id.short(),
1464 info.sha
1465 ));
1466 }
1467 }
1468 Err(err) => {
1469 tracing::warn!(
1470 error = %err,
1471 state = %new_state.state_id.short(),
1472 "git commit failed after the integrated Heddle state was written"
1473 );
1474 post_snapshot_git_blockers.push(format!(
1475 "git commit failed after Heddle integration state {} landed: {}",
1476 new_state.state_id.short(),
1477 err
1478 ));
1479 post_snapshot_git_blockers.push(format!(
1480 "recovery: integrated Heddle state {} is intact; resolve the Git checkout issue \
1481 (identity, locks, or filesystem errors) and run `heddle capture -m \"{}\"` — do NOT re-run the integration",
1482 new_state.state_id.short(),
1483 merge_message
1484 ));
1485 }
1486 }
1487 }
1488
1489 let committed_path_diff = compute_state_diff(
1495 repo,
1496 ¤t_state.state_id,
1497 &new_state.state_id,
1498 with_diff && use_semantic,
1499 if with_diff { 3 } else { 0 },
1500 )
1501 .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1502 let committed_changed_paths = diff_changed_paths(&committed_path_diff);
1503 let committed_diff = with_diff.then_some(committed_path_diff);
1504
1505 let final_message = if post_snapshot_git_blockers.is_empty() {
1506 format!("Merged as {}", new_state.state_id.short())
1507 } else {
1508 format!(
1509 "Merged as {} (heddle); git commit failed",
1510 new_state.state_id.short()
1511 )
1512 };
1513
1514 merge_output_from_report(MergeReportInput {
1515 repo,
1516 machine_contract,
1517 thread: &thread,
1518 preview_report: preview_report.as_ref(),
1519 conflicts: Some(vec![]),
1520 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1521 conflict_count: Some(merge_plan.relation().conflict_count()),
1522 changed_paths: Some(committed_changed_paths),
1523 preview_summary,
1524 message: final_message,
1525 renames: rename_entries,
1526 directory_renames: dir_rename_entries,
1527 merge_state: Some(new_state.state_id.short()),
1528 fast_forward: false,
1529 preview_only: false,
1530 semantic_changes: top_level_semantic(committed_diff.as_ref()),
1531 diff: committed_diff,
1532 git_commit_preview: None,
1533 git_commit: git_commit_info,
1534 extra_blockers: post_snapshot_git_blockers,
1535 })
1536}
1537
1538fn land_local_command(thread_id: &str) -> String {
1539 if thread_id.starts_with('-') {
1540 format!("heddle land --thread -- {thread_id}")
1541 } else {
1542 format!("heddle land --thread {thread_id}")
1543 }
1544}
1545
1546fn land_command_for_thread(repo: &Repository, thread_id: &str) -> String {
1547 let _ = repo;
1549 land_local_command(thread_id)
1550}
1551
1552fn mark_merge_previewed(repo: &Repository, thread_id: &str) -> Result<()> {
1553 let manager = ThreadManager::new(repo.heddle_dir());
1554 let mut thread = manager
1555 .load_id_or_name(thread_id)?
1556 .ok_or_else(|| anyhow!(advice::thread_not_found(thread_id, "mark merge previewed")))?;
1557 thread.integration_policy_result = ThreadIntegrationPolicy {
1558 status: Some("previewed".to_string()),
1559 reason: Some("clean merge preview established land path".to_string()),
1560 manual_resolution_state: thread.integration_policy_result.manual_resolution_state,
1561 conflicts_resolved_manually: thread.integration_policy_result.conflicts_resolved_manually,
1562 };
1563 manager.save(&thread)?;
1564 Ok(())
1565}
1566
1567fn preview_merge_message(
1571 repo: &Repository,
1572 explicit: &Option<String>,
1573 thread: Option<&Thread>,
1574 track_name: &str,
1575) -> String {
1576 if let Some(msg) = explicit.as_ref() {
1577 return msg.clone();
1578 }
1579 default_merge_message(repo, thread, track_name)
1580}
1581
1582fn default_merge_message(repo: &Repository, thread: Option<&Thread>, track_name: &str) -> String {
1583 if let Some(intent) =
1584 thread.and_then(|thread| state_intent(repo, thread.current_state.as_deref()))
1585 && !crate::subject_has_machine_identity(&intent)
1586 {
1587 return intent;
1588 }
1589 let name = if crate::looks_like_machine_identity(track_name) {
1590 "thread"
1591 } else {
1592 track_name
1593 };
1594 thread
1595 .and_then(|thread| thread.task.clone())
1596 .filter(|task| !crate::looks_like_machine_identity(task))
1597 .map(|task| format!("Merge thread '{}' ({task})", name))
1598 .unwrap_or_else(|| format!("Merge thread '{}'", name))
1599}
1600
1601fn merge_preview_message(
1602 thread: Option<&Thread>,
1603 track_name: &str,
1604 conflict_count: usize,
1605 diff_changed_path_count: usize,
1606) -> String {
1607 let subject = thread
1608 .map(|thread| thread.thread.as_str())
1609 .unwrap_or(track_name);
1610 let thread_changed_path_count = thread
1611 .map(|thread| thread.changed_paths.len())
1612 .unwrap_or_default();
1613 let changed_path_count = if thread_changed_path_count == 0 {
1614 diff_changed_path_count
1615 } else {
1616 thread_changed_path_count
1617 }
1618 .max(conflict_count);
1619 if conflict_count > 0 {
1620 format!(
1621 "Would merge {subject} with {conflict_count} conflict(s) across {changed_path_count} changed path(s)"
1622 )
1623 } else {
1624 format!("Would merge {subject} cleanly across {changed_path_count} changed path(s)")
1625 }
1626}
1627
1628fn state_intent(repo: &Repository, state: Option<&str>) -> Option<String> {
1629 let state = state?;
1630 let state_id = repo.resolve_state(state).ok().flatten()?;
1631 let state = repo.store().get_state(&state_id).ok().flatten()?;
1632 state.intent.filter(|intent| !intent.trim().is_empty())
1633}
1634
1635fn source_git_parent_for_thread(
1636 repo: &Repository,
1637 track_name: &str,
1638 merge_target_id: &StateId,
1639) -> Result<Option<String>> {
1640 if repo.capability() != repo::RepositoryCapability::GitOverlay {
1641 return Ok(None);
1642 }
1643 let Some(tip) = repo.git_overlay_branch_tip(track_name)? else {
1644 return Ok(None);
1645 };
1646 let Some(mapped_change) = tip.mapped_state else {
1647 return Ok(None);
1648 };
1649 if mapped_change == *merge_target_id {
1650 return Ok(Some(tip.git_commit));
1651 }
1652 let mut graph = CommitGraphIndex::new(repo);
1653 if graph
1654 .is_ancestor(&mapped_change, merge_target_id)
1655 .unwrap_or(false)
1656 {
1657 return Ok(Some(tip.git_commit));
1658 }
1659 Ok(None)
1660}
1661
1662fn merge_changed_paths(
1671 repo: &Repository,
1672 parent_tip: &StateId,
1673 thread_tip: &StateId,
1674) -> Result<Vec<String>> {
1675 let diff = compute_state_diff(repo, parent_tip, thread_tip, false, 0)?;
1676 let mut out = Vec::with_capacity(diff.changes.len());
1677 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1678 for change in diff.changes {
1679 if seen.insert(change.path.clone()) {
1680 out.push(change.path);
1681 }
1682 }
1683 Ok(out)
1684}
1685
1686fn finalize_merge_git_checkpoint(
1687 repo: &Repository,
1688 state: &StateId,
1689 branch: String,
1690 previous_git_oid: Option<String>,
1691 git_commit: &str,
1692 summary: &str,
1693) -> Result<()> {
1694 repo.record_git_checkpoint(state, git_commit.to_string(), summary.to_string())
1695 .with_context(|| {
1696 format!(
1697 "recording Git checkpoint metadata for merge state {}",
1698 state.short()
1699 )
1700 })?;
1701 let ids = repo
1702 .oplog()
1703 .record_batch_scoped(
1704 vec![OpRecord::GitCheckpoint {
1705 branch,
1706 state: *state,
1707 previous_git_oid,
1708 new_git_oid: git_commit.to_string(),
1709 }],
1710 Some(&repo.op_scope()),
1711 )
1712 .with_context(|| {
1713 format!(
1714 "recording Git checkpoint undo entry for merge state {}",
1715 state.short()
1716 )
1717 })?;
1718 let checkpoint_batch_id = ids
1719 .first()
1720 .copied()
1721 .ok_or_else(|| anyhow!("Git checkpoint undo entry was not recorded"))?;
1722 let merge_batch = find_recent_merge_batch(repo, state)?;
1723 repo.oplog()
1724 .coalesce_batches(merge_batch.id, checkpoint_batch_id)
1725 .with_context(|| {
1726 format!(
1727 "coalescing merge state {} and Git checkpoint {} into one undo batch",
1728 state.short(),
1729 git_commit
1730 )
1731 })?;
1732 Ok(())
1733}
1734
1735fn find_recent_merge_batch(repo: &Repository, state: &StateId) -> Result<OpBatch> {
1736 repo.oplog()
1737 .recent_batches_scoped(12, Some(&repo.op_scope()))?
1738 .into_iter()
1739 .find(|batch| {
1740 batch
1741 .entries
1742 .iter()
1743 .any(|entry| merge_op_targets_state(&entry.operation, state))
1744 })
1745 .ok_or_else(|| {
1746 anyhow!(
1747 "merge state {} landed but its oplog batch was not found",
1748 state.short()
1749 )
1750 })
1751}
1752
1753fn merge_op_targets_state(op: &OpRecord, state: &StateId) -> bool {
1754 match op {
1755 OpRecord::Snapshot { new_state, .. } => new_state == state,
1756 OpRecord::Goto { target, .. } => target == state,
1757 OpRecord::FastForward { post_target_id, .. } => post_target_id == state,
1758 OpRecord::Checkpoint {
1759 state: checkpoint_state,
1760 ..
1761 } => checkpoint_state == state,
1762 OpRecord::ThreadCreate { .. }
1767 | OpRecord::ThreadDelete { .. }
1768 | OpRecord::ThreadUpdate { .. }
1769 | OpRecord::Fork { .. }
1770 | OpRecord::Collapse { .. }
1771 | OpRecord::MarkerCreate { .. }
1772 | OpRecord::MarkerDelete { .. }
1773 | OpRecord::TransactionAbort { .. }
1774 | OpRecord::EphemeralThreadCollapse { .. }
1775 | OpRecord::ConflictResolved { .. }
1776 | OpRecord::TransactionCommit { .. }
1777 | OpRecord::Redact { .. }
1778 | OpRecord::Purge { .. }
1779 | OpRecord::GitCheckpoint { .. }
1780 | OpRecord::RemoteThreadUpdate { .. }
1781 | OpRecord::RemoteThreadDelete { .. }
1782 | OpRecord::UndoRecoveryUpdate { .. }
1783 | OpRecord::StateVisibilitySet { .. }
1784 | OpRecord::StateVisibilityPromote { .. }
1785 | OpRecord::EntryVisibilitySet { .. }
1786 | OpRecord::HeadUpdate { .. } => false,
1787 }
1788}
1789
1790fn git_rev_parse_head(root: &Path) -> Option<String> {
1791 let git = SleyRepository::discover(root).ok()?;
1792 git.head().ok()?.oid.map(|id| id.to_string())
1793}
1794
1795fn validate_git_commit_preconditions_extended(
1821 repo_root: &std::path::Path,
1822 merge_paths: &[String],
1823) -> Vec<String> {
1824 let mut blockers = Vec::new();
1825
1826 if merge_paths.is_empty() {
1827 blockers
1828 .push("integration produced no changed paths — no Git commit is needed".to_string());
1829 }
1830
1831 if !repo_root.join(".git").exists() {
1832 return blockers;
1834 }
1835
1836 blockers
1837}
1838
1839fn empty_diff_output(state_id: &StateId) -> DiffReport {
1844 DiffReport::new(
1845 Some(state_id.short()),
1846 Some(state_id.short()),
1847 Vec::new(),
1848 None,
1849 None,
1850 None,
1851 )
1852}
1853
1854pub fn prepare_dir_for_file_replacement(path: &Path) -> Result<()> {
1872 match fs::remove_dir(path) {
1873 Ok(()) => Ok(()),
1874 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1875 Err(error) if objects::fs_atomic::is_directory_not_empty(&error) => {
1876 let surviving = list_surviving_entries(path)
1877 .unwrap_or_else(|_| vec!["<unable to list>".to_string()]);
1878 let display = if surviving.is_empty() {
1879 "<unknown ignored content>".to_string()
1880 } else {
1881 surviving.join(", ")
1882 };
1883 Err(anyhow!(
1884 "cannot replace directory {} with a file: contains heddle-ignored content ({}) — move or delete those files manually first",
1885 path.display(),
1886 display
1887 ))
1888 }
1889 Err(error) => {
1890 Err(anyhow::Error::from(error)
1891 .context(format!("removing directory {}", path.display())))
1892 }
1893 }
1894}
1895
1896fn list_surviving_entries(path: &Path) -> std::io::Result<Vec<String>> {
1897 let mut names = Vec::new();
1898 for entry in fs::read_dir(path)? {
1899 let entry = entry?;
1900 if let Some(s) = entry.file_name().to_str() {
1901 names.push(s.to_string());
1902 } else {
1903 names.push(entry.file_name().to_string_lossy().into_owned());
1904 }
1905 }
1906 names.sort();
1907 Ok(names)
1908}
1909
1910pub fn bench_find_merge_base(
1911 repo: &Repository,
1912 state_a: &StateId,
1913 state_b: &StateId,
1914) -> Result<Option<StateId>> {
1915 find_merge_base(repo, state_a, state_b)
1916}
1917
1918pub enum ThreeWayMergeOutcome {
1920 Clean {
1923 tree: Tree,
1924 },
1925 Conflicted {
1928 tree: Tree,
1929 paths: Vec<String>,
1930 base: StateId,
1931 },
1932 AlreadyIntegrated {
1936 target: StateId,
1937 },
1938 FastForward {
1939 target: StateId,
1940 },
1941}
1942
1943pub fn try_three_way_merge_between_tips(
1952 parent_repo: &Repository,
1953 current_tip: &StateId,
1954 target_tip: &StateId,
1955 labels: ConflictLabels<'_>,
1956) -> Result<ThreeWayMergeOutcome> {
1957 let mut graph = CommitGraphIndex::new(parent_repo);
1958 let plan =
1959 MergePlan::for_merge_command(parent_repo, &mut graph, current_tip, target_tip, labels)?;
1960 match plan.relation().kind() {
1961 MergeRelationKind::AlreadyUpToDate => Ok(ThreeWayMergeOutcome::AlreadyIntegrated {
1962 target: *target_tip,
1963 }),
1964 MergeRelationKind::FastForward => Ok(ThreeWayMergeOutcome::FastForward {
1965 target: *target_tip,
1966 }),
1967 MergeRelationKind::CleanApply => {
1968 let merge_result = plan
1969 .merge_result()
1970 .ok_or_else(|| anyhow!("Merge plan missing merge_result for CleanApply"))?;
1971 Ok(ThreeWayMergeOutcome::Clean {
1972 tree: merge_result.tree.clone(),
1973 })
1974 }
1975 MergeRelationKind::Conflicted | MergeRelationKind::AlreadyIntegrated => {
1976 let merge_result = plan
1977 .merge_result()
1978 .ok_or_else(|| anyhow!("Merge plan missing merge_result for Conflicted"))?;
1979 let base = plan
1980 .relation()
1981 .merge_base_id()
1982 .ok_or_else(|| anyhow!("Merge base missing from conflicted merge plan"))?;
1983 Ok(ThreeWayMergeOutcome::Conflicted {
1984 tree: merge_result.tree.clone(),
1985 paths: merge_result.conflicts.clone(),
1986 base,
1987 })
1988 }
1989 }
1990}
1991
1992pub fn apply_merged_tree_external(repo: &Repository, tree: &Tree) -> Result<()> {
1997 apply_merged_tree(repo, tree)
1998}
1999
2000pub fn bench_three_way_merge(
2001 repo: &Repository,
2002 base_tree: &Tree,
2003 our_tree: &Tree,
2004 their_tree: &Tree,
2005) -> Result<(Tree, usize, usize, usize)> {
2006 let blob_source = RepositoryMergeBlobSource { repo };
2007 let result = merge_trees(
2008 repo.store(),
2009 &blob_source,
2010 base_tree,
2011 our_tree,
2012 their_tree,
2013 tree_merge_options(ConflictLabels::DEFAULT),
2014 )
2015 .map_err(map_tree_merge_error)?;
2016 Ok((
2017 result.tree,
2018 result.conflicts.len(),
2019 result.renames.len(),
2020 result.directory_renames.len(),
2021 ))
2022}
2023
2024pub fn bench_detect_renames(
2025 store: &impl ObjectStore,
2026 base_tree: &Tree,
2027 branch_tree: &Tree,
2028) -> Result<(usize, RenameMatcherStats)> {
2029 let detection = detect_renames_between_trees(store, base_tree, branch_tree, rename_options())?;
2030 Ok((detection.renames.len(), detection.stats))
2031}
2032
2033fn fast_forward_renames(
2034 repo: &Repository,
2035 from: &StateId,
2036 to: &StateId,
2037) -> Result<(Vec<RenameEntry>, Vec<RenameEntry>)> {
2038 let from_tree = load_state_tree(repo, from)?;
2039 let to_tree = load_state_tree(repo, to)?;
2040 let detection =
2041 detect_renames_between_trees(repo.store(), &from_tree, &to_tree, rename_options())?;
2042
2043 let renames: Vec<RenameEntry> = detection
2044 .renames
2045 .into_iter()
2046 .map(|rename| RenameEntry {
2047 from: rename.from,
2048 to: rename.to,
2049 score: rename.score,
2050 })
2051 .collect();
2052
2053 let directory_renames: Vec<RenameEntry> = detection
2054 .directory_renames
2055 .into_iter()
2056 .map(|rename| RenameEntry {
2057 from: rename.from,
2058 to: rename.to,
2059 score: 1.0,
2060 })
2061 .collect();
2062
2063 Ok((renames, directory_renames))
2064}
2065
2066fn rename_options() -> RenameOptions {
2067 RenameOptions {
2068 semantic_similarity: semantic_similarity_hook(),
2069 ..RenameOptions::default()
2070 }
2071}
2072
2073fn load_state_tree(repo: &Repository, state_id: &StateId) -> Result<Tree> {
2074 let state = repo
2075 .store()
2076 .get_state(state_id)?
2077 .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
2078 repo.store().get_tree(&state.tree)?.ok_or_else(|| {
2079 anyhow!(
2080 "State '{}' references missing tree {}",
2081 state_id.short(),
2082 state.tree
2083 )
2084 })
2085}
2086
2087pub fn build_thread_preview_report(
2088 repo: &Repository,
2089 thread: &mut Thread,
2090 prefer_apply_recommendation: bool,
2091) -> Result<ThreadPreviewReport> {
2092 let mut graph = CommitGraphIndex::new(repo);
2093 build_thread_preview_report_with_graph(
2098 repo,
2099 &mut graph,
2100 thread,
2101 prefer_apply_recommendation,
2102 merge_strategy_for(semantic_merge_enabled(false)),
2103 None,
2104 )
2105}
2106
2107pub struct PreviewTarget<'a> {
2116 pub label: &'a str,
2117 pub state_id: StateId,
2118}
2119
2120fn build_thread_preview_report_with_graph(
2121 repo: &Repository,
2122 graph: &mut CommitGraphIndex<'_>,
2123 thread: &mut Thread,
2124 prefer_apply_recommendation: bool,
2125 strategy: MergeStrategy,
2126 target_override: Option<PreviewTarget<'_>>,
2127) -> Result<ThreadPreviewReport> {
2128 refresh_thread_freshness(repo, thread)?;
2129 let mut conflicts = Vec::new();
2130 let resolved_target: Option<(String, StateId)> = if let Some(ovr) = target_override {
2135 Some((ovr.label.to_string(), ovr.state_id))
2136 } else if let Some(name) = thread.target_thread.as_deref() {
2137 let id = repo
2138 .refs()
2139 .get_thread(&ThreadName::new(name))?
2140 .ok_or_else(|| anyhow!(advice::thread_not_found(name, "merge preview")))?;
2141 Some((name.to_string(), id))
2142 } else {
2143 None
2144 };
2145
2146 let mut preview_changed_paths: Option<Vec<String>> = None;
2147 let merge_relation = if let Some((target_label, target_id)) = resolved_target {
2148 let thread_id = repo
2149 .refs()
2150 .get_thread(&ThreadName::new(&thread.thread))?
2151 .ok_or_else(|| anyhow!(advice::thread_not_found(&thread.thread, "merge preview")))?;
2152 let current_label = format!("CURRENT ({target_label})");
2153 let incoming_label = format!("INCOMING ({})", thread.thread);
2154 let merge_plan = MergePlan::for_thread_preview(
2155 repo,
2156 graph,
2157 &target_id,
2158 &thread_id,
2159 ConflictLabels {
2160 current: ¤t_label,
2161 incoming: &incoming_label,
2162 strategy,
2163 },
2164 )?;
2165 if let Some(merge_result) = merge_plan.merge_result() {
2166 conflicts = merge_result.conflicts.clone();
2167 }
2168 let merge_relation = merge_plan.relation().as_json_value().to_string();
2169 if merge_relation != "already_integrated" {
2170 preview_changed_paths = Some(merge_changed_paths(repo, &target_id, &thread_id)?);
2171 }
2172 merge_relation
2173 } else {
2174 "no_target".to_string()
2175 };
2176
2177 let mut advice =
2178 describe_thread_advice(thread, false, conflicts.len(), prefer_apply_recommendation);
2179 if merge_relation == "already_integrated" {
2180 advice.blockers.clear();
2181 advice.recommended_action.clear();
2182 advice.thread_health = "clean".to_string();
2183 }
2184
2185 let thread_tip = repo
2186 .refs()
2187 .get_thread(&ThreadName::new(&thread.thread))?
2188 .map(|id| id.short());
2189 let manual_resolution_current = thread
2190 .integration_policy_result
2191 .manual_resolution_state
2192 .as_deref()
2193 .zip(thread_tip.as_deref())
2194 .is_some_and(|(resolved, current)| resolved == current);
2195 let conflict_count = if manual_resolution_current {
2196 0
2197 } else {
2198 conflicts.len()
2199 };
2200 let conflicts = if manual_resolution_current {
2201 Vec::new()
2202 } else {
2203 conflicts
2204 };
2205 if manual_resolution_current {
2206 advice.blockers.clear();
2207 advice.recommended_action = land_command_for_thread(repo, &thread.thread);
2208 advice.thread_health = "ready".to_string();
2209 }
2210
2211 let recommended_action = advice.recommended_action;
2212 let all_changed_paths = preview_changed_paths.unwrap_or_else(|| thread.changed_paths.clone());
2213 let changed_path_count = all_changed_paths.len();
2214 let changed_paths = all_changed_paths.into_iter().take(8).collect();
2215 Ok(ThreadPreviewReport {
2216 thread: thread.thread.clone(),
2217 thread_mode: thread.mode.to_string(),
2218 thread_state: thread.state.to_string(),
2219 freshness: thread.freshness.to_string(),
2220 task: thread.task.clone(),
2221 changed_paths,
2222 changed_path_count,
2223 impact_categories: thread
2224 .impact_categories
2225 .iter()
2226 .map(ToString::to_string)
2227 .collect(),
2228 heavy_impact_paths: thread.heavy_impact_paths.clone(),
2229 merge_relation,
2230 conflict_count,
2231 conflicts,
2232 blockers: advice.blockers,
2233 recommended_action_template: action_template(&recommended_action),
2234 recommended_action,
2235 thread_health: advice.thread_health,
2236 })
2237}
2238
2239fn merge_output_from_report(input: MergeReportInput<'_>) -> Result<MergeReport> {
2240 let report_conflicts = input.conflicts.unwrap_or_default();
2241 let diff_changed_paths = input.diff.as_ref().map(diff_changed_paths);
2242 let changed_paths = if let Some(paths) = input.changed_paths {
2243 paths
2244 } else if let Some(thread) = input.thread.as_ref() {
2245 let paths = thread.changed_paths.clone();
2246 if paths.is_empty() {
2247 diff_changed_paths.unwrap_or(paths)
2248 } else {
2249 paths
2250 }
2251 } else {
2252 diff_changed_paths.unwrap_or_default()
2253 };
2254 let changed_path_count = changed_paths.len();
2255 let preview_blockers = input
2269 .preview_report
2270 .map(|report| report.blockers.clone())
2271 .unwrap_or_default();
2272 let preview_warnings: Vec<String> = preview_blockers
2273 .iter()
2274 .filter(|item| !is_real_merge_blocker(item))
2275 .cloned()
2276 .collect();
2277 let mut real_blockers: Vec<String> = if report_conflicts.is_empty() {
2280 Vec::new()
2281 } else {
2282 vec![format!(
2283 "{} path conflict(s) need manual resolution",
2284 report_conflicts.len()
2285 )]
2286 };
2287 real_blockers.extend(input.extra_blockers.iter().cloned());
2288
2289 let status = if !real_blockers.is_empty() {
2290 "blocked"
2291 } else {
2292 "completed"
2293 };
2294 let stale_refresh_action = input.preview_report.and_then(|report| {
2295 (report.freshness == ThreadFreshness::Stale.to_string()).then(|| {
2296 if report.recommended_action.trim().is_empty() {
2297 format!(
2298 "heddle sync --thread {}",
2299 recommended_action_quote(&report.thread)
2300 )
2301 } else {
2302 report.recommended_action.clone()
2303 }
2304 })
2305 });
2306 let recommended_action: Option<String> = if !report_conflicts.is_empty() {
2307 if input.preview_only {
2311 None
2312 } else {
2313 Some("heddle continue".to_string())
2314 }
2315 } else if !input.extra_blockers.is_empty() {
2316 Some(coordination_blocker_recommended_action(
2326 input.merge_state.as_ref(),
2327 ))
2328 } else if input.preview_only
2329 && input.message != "Already up to date"
2330 && stale_refresh_action.is_some()
2331 {
2332 stale_refresh_action
2333 } else if input.preview_only && input.message != "Already up to date" {
2334 input.thread.as_ref().map(|t| land_local_command(&t.thread))
2339 } else {
2340 None
2342 };
2343 let meaningful_merge = status == "completed" && input.message != "Already up to date";
2344 let would_merge = input.preview_only && meaningful_merge;
2345 let applied = !input.preview_only && meaningful_merge;
2346 Ok(MergeReport {
2347 operator: OperatorCommandOutput {
2348 status: status.to_string(),
2349 action: OperatorAction::Merge,
2350 message: input.message,
2351 blockers: real_blockers,
2352 warnings: preview_warnings,
2353 next_action: recommended_action.clone(),
2354 recommended_action: recommended_action.clone(),
2355 },
2356 would_merge,
2357 applied,
2358 fast_forward: input.fast_forward,
2359 preview_only: input.preview_only,
2360 merge_state: input.merge_state,
2361 conflicts: report_conflicts.clone(),
2362 preview_summary: input.preview_summary,
2363 thread_state: input.thread.as_ref().map(|thread| thread.state.to_string()),
2364 freshness: input
2365 .thread
2366 .as_ref()
2367 .map(|thread| thread.freshness.to_string()),
2368 changed_paths,
2369 changed_path_count,
2370 impact_categories: thread_impacts(input.thread),
2371 promotion_suggested: input
2372 .thread
2373 .as_ref()
2374 .map(|thread| thread.promotion_suggested)
2375 .unwrap_or(false),
2376 heavy_impact_paths: thread_heavy_paths(input.thread),
2377 merge_relation: input.merge_relation.or_else(|| {
2378 input
2379 .preview_report
2380 .map(|report| report.merge_relation.clone())
2381 }),
2382 conflict_count: input
2383 .conflict_count
2384 .or_else(|| input.preview_report.map(|report| report.conflict_count))
2385 .unwrap_or(report_conflicts.len()),
2386 thread_health: merge_output_thread_health(input.thread.as_ref(), input.preview_report),
2387 renames: input.renames,
2388 directory_renames: input.directory_renames,
2389 semantic_changes: input.semantic_changes,
2390 diff: input.diff,
2391 git_commit_preview: input.git_commit_preview,
2392 git_commit: input.git_commit,
2393 trust: Some(merge_output_trust(
2394 input.repo,
2395 input.machine_contract,
2396 recommended_action.as_deref(),
2397 )?),
2398 })
2399}
2400
2401fn diff_changed_paths(diff: &DiffReport) -> Vec<String> {
2402 diff.changes
2403 .iter()
2404 .map(|change| change.path.clone())
2405 .collect()
2406}
2407
2408fn diff_with_known_renames(diff: DiffReport, renames: &[RenameEntry]) -> DiffReport {
2409 if renames.is_empty() {
2410 return diff;
2411 }
2412 let DiffReport {
2413 from_state,
2414 to_state,
2415 changes: original_changes,
2416 semantic_changes,
2417 context,
2418 broader_guidance,
2419 ..
2420 } = diff;
2421 let rename_by_new = renames
2422 .iter()
2423 .map(|rename| (rename.to.as_str(), rename.from.as_str()))
2424 .collect::<std::collections::BTreeMap<_, _>>();
2425 let removed_old = renames
2426 .iter()
2427 .map(|rename| rename.from.as_str())
2428 .collect::<std::collections::BTreeSet<_>>();
2429 let mut changes = Vec::with_capacity(original_changes.len());
2430 for mut change in original_changes {
2431 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2432 continue;
2433 }
2434 if change.kind == "added"
2435 && let Some(old_path) = rename_by_new.get(change.path.as_str())
2436 {
2437 change.kind = "renamed".to_string();
2438 change.old_path = Some((*old_path).to_string());
2439 }
2440 changes.push(change);
2441 }
2442 DiffReport::new(
2443 from_state,
2444 to_state,
2445 changes,
2446 semantic_changes,
2447 context,
2448 broader_guidance,
2449 )
2450}
2451
2452fn merge_output_thread_health(
2453 thread: Option<&Thread>,
2454 preview_report: Option<&ThreadPreviewReport>,
2455) -> String {
2456 match thread.map(|thread| &thread.state) {
2457 Some(ThreadState::Merged | ThreadState::Abandoned) => "clean".to_string(),
2458 Some(ThreadState::Blocked) => "blocked".to_string(),
2459 Some(ThreadState::Ready) => "ready".to_string(),
2460 Some(ThreadState::Draft | ThreadState::Active | ThreadState::Promoted) | None => {
2461 preview_report
2462 .map(|report| report.thread_health.clone())
2463 .unwrap_or_else(|| "active".to_string())
2464 }
2465 }
2466}
2467
2468fn coordination_blocker_recommended_action(merge_state: Option<&String>) -> String {
2469 if merge_state.is_some() {
2470 "heddle capture -m \"...\"".to_string()
2471 } else {
2472 "heddle status".to_string()
2473 }
2474}
2475
2476fn merge_output_trust(
2477 repo: &Repository,
2478 machine_contract: &MachineContractInput,
2479 recommended_action: Option<&str>,
2480) -> Result<RepositoryVerificationState> {
2481 let mut trust = trust_state(repo, machine_contract)?;
2482 if let Some(action) = recommended_action {
2483 override_trust_recommended_action(&mut trust, action);
2484 }
2485 Ok(trust)
2486}
2487
2488fn worktree_status_options(config: Option<&repo::RepoConfig>) -> repo::WorktreeStatusOptions {
2489 repo::resolve_worktree_status_options(None, config)
2490}
2491
2492fn worktree_dirty(repo: &Repository, options: &repo::WorktreeStatusOptions) -> Result<bool> {
2493 if repo.current_state()?.is_none()
2494 && let Some(status) = repo.git_overlay_worktree_status()?
2495 {
2496 return Ok(!status.is_clean());
2497 }
2498 let tree = match repo.current_state()? {
2499 Some(state) => repo.require_tree(&state.tree)?,
2500 None => Tree::new(),
2501 };
2502 let status = repo.compare_worktree_cached_with_options(&tree, options)?;
2503 Ok(!status.is_clean())
2504}
2505
2506fn worktree_dirty_paths(
2507 repo: &Repository,
2508 options: &repo::WorktreeStatusOptions,
2509) -> Result<Vec<String>> {
2510 let status = if repo.current_state()?.is_none()
2511 && let Some(status) = repo.git_overlay_worktree_status()?
2512 {
2513 status
2514 } else {
2515 let tree = match repo.current_state()? {
2516 Some(state) => repo.require_tree(&state.tree)?,
2517 None => Tree::new(),
2518 };
2519 repo.compare_worktree_cached_with_options(&tree, options)?
2520 };
2521
2522 let mut paths = Vec::new();
2523 paths.extend(status.modified);
2524 paths.extend(status.added);
2525 paths.extend(status.deleted);
2526 paths.sort();
2527 paths.dedup();
2528 Ok(paths
2529 .into_iter()
2530 .map(|path| path.display().to_string())
2531 .collect())
2532}
2533
2534fn source_thread_uncaptured_work(
2535 target_repo: &Repository,
2536 thread: &Thread,
2537) -> Result<Option<SourceThreadUncapturedWork>> {
2538 if thread.execution_path.as_os_str().is_empty()
2539 || thread.execution_path == *target_repo.root()
2540 || !thread.execution_path.exists()
2541 || !thread.execution_path.join(".heddle").exists()
2542 {
2543 return Ok(None);
2544 }
2545
2546 let source_repo = Repository::open(&thread.execution_path)?;
2547 let options = worktree_status_options(Some(source_repo.config()));
2548 if !worktree_dirty(&source_repo, &options)? {
2549 return Ok(None);
2550 }
2551
2552 Ok(Some(SourceThreadUncapturedWork {
2553 checkout_path: thread.execution_path.display().to_string(),
2554 dirty_paths: worktree_dirty_paths(&source_repo, &options)?,
2555 }))
2556}
2557
2558#[allow(dead_code)] fn uncaptured_path_summary(paths: &[String]) -> String {
2560 if paths.is_empty() {
2561 return "uncaptured worktree paths".to_string();
2562 }
2563 let shown = paths
2564 .iter()
2565 .take(12)
2566 .cloned()
2567 .collect::<Vec<_>>()
2568 .join(", ");
2569 let overflow = paths.len().saturating_sub(12);
2570 if overflow == 0 {
2571 format!("uncaptured path(s): {shown}")
2572 } else {
2573 format!("uncaptured path(s): {shown}, and {overflow} more")
2574 }
2575}
2576
2577fn recommended_action_quote(value: &str) -> String {
2578 let safe = !value.is_empty()
2579 && value
2580 .bytes()
2581 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
2582 if safe {
2583 value.to_string()
2584 } else {
2585 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
2586 }
2587}
2588
2589fn merge_blocked_by_trust_output(
2590 thread: &Option<Thread>,
2591 preview_report: Option<&ThreadPreviewReport>,
2592 trust: RepositoryVerificationState,
2593 preview_only: bool,
2594 merge_relation: Option<String>,
2595) -> MergeReport {
2596 MergeReport {
2597 operator: OperatorCommandOutput::blocked_by_repository_verification(
2598 OperatorAction::Merge,
2599 trust_blocked_merge_message(&trust, preview_only),
2600 &trust,
2601 ),
2602 would_merge: false,
2603 applied: false,
2604 fast_forward: false,
2605 preview_only,
2606 merge_state: None,
2607 conflicts: Vec::new(),
2608 preview_summary: Vec::new(),
2609 thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
2610 freshness: thread.as_ref().map(|thread| thread.freshness.to_string()),
2611 changed_paths: thread_paths(thread),
2612 changed_path_count: thread_path_count(thread),
2613 impact_categories: thread_impacts(thread),
2614 promotion_suggested: thread
2615 .as_ref()
2616 .map(|thread| thread.promotion_suggested)
2617 .unwrap_or(false),
2618 heavy_impact_paths: thread_heavy_paths(thread),
2619 merge_relation: merge_relation
2620 .or_else(|| preview_report.map(|report| report.merge_relation.clone())),
2621 conflict_count: 0,
2622 thread_health: trust.status.clone(),
2623 renames: Vec::new(),
2624 directory_renames: Vec::new(),
2625 semantic_changes: None,
2626 diff: None,
2627 git_commit_preview: None,
2628 git_commit: None,
2629 trust: Some(trust),
2630 }
2631}
2632
2633fn merge_freshness_preflight_output(
2634 repo: &Repository,
2635 machine_contract: &MachineContractInput,
2636 thread: &Option<Thread>,
2637 preview_report: Option<&ThreadPreviewReport>,
2638 preview_only: bool,
2639) -> Result<Option<MergeReport>> {
2640 if thread
2641 .as_ref()
2642 .is_some_and(|thread| thread.state == ThreadState::Merged)
2643 {
2644 return Ok(None);
2645 }
2646 let Some(report) =
2647 preview_report.filter(|report| report.freshness == ThreadFreshness::Stale.to_string())
2648 else {
2649 return Ok(None);
2650 };
2651 Ok(Some(stale_thread_merge_blocked_output(
2652 repo,
2653 machine_contract,
2654 thread,
2655 report,
2656 preview_only,
2657 )?))
2658}
2659
2660fn stale_thread_merge_blocked_output(
2661 repo: &Repository,
2662 machine_contract: &MachineContractInput,
2663 thread: &Option<Thread>,
2664 preview_report: &ThreadPreviewReport,
2665 preview_only: bool,
2666) -> Result<MergeReport> {
2667 let recommended_action = if preview_report.recommended_action.trim().is_empty() {
2668 format!(
2669 "heddle sync --thread {}",
2670 recommended_action_quote(&preview_report.thread)
2671 )
2672 } else {
2673 preview_report.recommended_action.clone()
2674 };
2675 let blockers = if preview_report.blockers.is_empty() {
2676 vec![format!(
2677 "Thread '{}' is stale against '{}'",
2678 preview_report.thread,
2679 thread
2680 .as_ref()
2681 .and_then(|thread| thread.target_thread.as_deref())
2682 .unwrap_or("its target thread")
2683 )]
2684 } else {
2685 preview_report.blockers.clone()
2686 };
2687 let conflict_suffix = if preview_report.conflict_count > 0 {
2688 format!(
2689 " and has {} path conflict(s)",
2690 preview_report.conflict_count
2691 )
2692 } else {
2693 String::new()
2694 };
2695
2696 Ok(MergeReport {
2697 operator: OperatorCommandOutput {
2698 status: "blocked".to_string(),
2699 action: OperatorAction::Merge,
2700 message: format!(
2701 "Thread '{}' is stale{}; merge {}did not run",
2702 preview_report.thread,
2703 conflict_suffix,
2704 if preview_only { "preview " } else { "" }
2705 ),
2706 blockers,
2707 warnings: Vec::new(),
2708 next_action: Some(recommended_action.clone()),
2709 recommended_action: Some(recommended_action.clone()),
2710 },
2711 would_merge: false,
2712 applied: false,
2713 fast_forward: false,
2714 preview_only,
2715 merge_state: None,
2716 conflicts: preview_report.conflicts.clone(),
2717 preview_summary: build_stale_preview_summary(preview_report),
2718 thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
2719 freshness: Some(preview_report.freshness.clone()),
2720 changed_paths: preview_report.changed_paths.clone(),
2721 changed_path_count: preview_report.changed_path_count,
2722 impact_categories: preview_report.impact_categories.clone(),
2723 promotion_suggested: !preview_report.heavy_impact_paths.is_empty(),
2724 heavy_impact_paths: preview_report.heavy_impact_paths.clone(),
2725 merge_relation: Some(preview_report.merge_relation.clone()),
2726 conflict_count: preview_report.conflict_count,
2727 thread_health: "blocked".to_string(),
2728 renames: Vec::new(),
2729 directory_renames: Vec::new(),
2730 semantic_changes: None,
2731 diff: None,
2732 git_commit_preview: None,
2733 git_commit: None,
2734 trust: Some(merge_output_trust(
2735 repo,
2736 machine_contract,
2737 Some(&recommended_action),
2738 )?),
2739 })
2740}
2741
2742fn override_trust_recommended_action(
2743 trust: &mut RepositoryVerificationState,
2744 action: impl Into<String>,
2745) {
2746 let action = action.into();
2747 trust.recommended_action_template = action_template(&action);
2748 trust.recommended_action = action.clone();
2749 if let Some(check) = trust
2750 .checks
2751 .iter_mut()
2752 .find(|check| check.name == "Workflow")
2753 {
2754 check.recommended_action_template = action_template(&action);
2755 check.recommended_action = Some(action);
2756 }
2757}
2758
2759fn trust_blocks_merge_preview(trust: &RepositoryVerificationState) -> bool {
2760 trust
2761 .checks
2762 .iter()
2763 .any(|check| !check.clean && matches!(check.name.as_str(), "Mapping" | "Operation"))
2764}
2765
2766fn trust_blocked_merge_message(trust: &RepositoryVerificationState, preview_only: bool) -> String {
2767 if preview_only {
2768 format!(
2769 "Repository verification is blocked; merge preview did not run: {}",
2770 trust.summary
2771 )
2772 } else {
2773 format!(
2774 "Repository verification is blocked; merge did not run: {}",
2775 trust.summary
2776 )
2777 }
2778}
2779
2780fn preview_list(paths: &[String], total: usize) -> String {
2781 const LIMIT: usize = 5;
2782 if paths.is_empty() {
2783 return "none".to_string();
2784 }
2785 let shown = paths
2786 .iter()
2787 .take(LIMIT)
2788 .cloned()
2789 .collect::<Vec<_>>()
2790 .join(", ");
2791 if total > LIMIT {
2792 format!("{shown} (+{} more)", total - LIMIT)
2793 } else {
2794 shown
2795 }
2796}
2797
2798fn is_real_merge_blocker(advisory: &str) -> bool {
2799 let lower = advisory.to_lowercase();
2800 lower.contains("path conflict")
2801}
2802
2803fn thread_paths(thread: &Option<Thread>) -> Vec<String> {
2804 thread
2805 .as_ref()
2806 .map(|thread| thread.changed_paths.clone())
2807 .unwrap_or_default()
2808}
2809
2810fn thread_path_count(thread: &Option<Thread>) -> usize {
2811 thread
2812 .as_ref()
2813 .map(|thread| thread.changed_paths.len())
2814 .unwrap_or(0)
2815}
2816
2817fn thread_impacts(thread: &Option<Thread>) -> Vec<String> {
2818 thread
2819 .as_ref()
2820 .map(|thread| {
2821 thread
2822 .impact_categories
2823 .iter()
2824 .map(ToString::to_string)
2825 .collect::<Vec<_>>()
2826 })
2827 .unwrap_or_default()
2828}
2829
2830fn thread_heavy_paths(thread: &Option<Thread>) -> Vec<String> {
2831 thread
2832 .as_ref()
2833 .map(|thread| thread.heavy_impact_paths.clone())
2834 .unwrap_or_default()
2835}
2836
2837fn build_preview_summary(report: Option<&ThreadPreviewReport>) -> Vec<String> {
2838 let mut lines = Vec::new();
2839 if let Some(report) = report {
2840 let real_blockers = report
2841 .blockers
2842 .iter()
2843 .filter(|blocker| is_real_merge_blocker(blocker))
2844 .cloned()
2845 .collect::<Vec<_>>();
2846 if !real_blockers.is_empty() {
2847 lines.push(format!("blocked: {}", real_blockers.join("; ")));
2848 }
2849 lines.push(format!(
2850 "checkout: {}",
2851 thread_mode_summary(&report.thread_mode)
2852 ));
2853 lines.push(format!("sync: {}", report.freshness));
2854 if let Some(task) = &report.task {
2855 lines.push(format!("task: {}", task));
2856 }
2857 if !report.changed_paths.is_empty() {
2858 lines.push(format!(
2859 "changed paths: {}",
2860 report.changed_paths.join(", ")
2861 ));
2862 }
2863 if !report.impact_categories.is_empty() {
2864 lines.push(format!(
2865 "impact categories: {}",
2866 report.impact_categories.join(", ")
2867 ));
2868 }
2869 if !report.heavy_impact_paths.is_empty() {
2870 lines.push(format!(
2871 "heavy-impact change: {} — review broader impact before merging",
2872 preview_list(&report.heavy_impact_paths, report.heavy_impact_paths.len(),)
2873 ));
2874 }
2875 lines.push(format!(
2876 "merge type: {}",
2877 merge_relation_summary(&report.merge_relation)
2878 ));
2879 if report.conflict_count > 0 {
2880 lines.push(format!(
2881 "conflicts: {} path conflict(s)",
2882 report.conflict_count
2883 ));
2884 }
2885 }
2886 lines
2887}
2888
2889fn build_stale_preview_summary(report: &ThreadPreviewReport) -> Vec<String> {
2890 let mut lines = Vec::new();
2891 if !report.blockers.is_empty() {
2892 lines.push(format!("blocked: {}", report.blockers.join("; ")));
2893 }
2894 lines.push(format!(
2895 "checkout: {}",
2896 thread_mode_summary(&report.thread_mode)
2897 ));
2898 lines.push(format!("sync: {}", report.freshness));
2899 if let Some(task) = &report.task {
2900 lines.push(format!("task: {}", task));
2901 }
2902 if !report.changed_paths.is_empty() {
2903 lines.push(format!(
2904 "changed paths: {}",
2905 report.changed_paths.join(", ")
2906 ));
2907 }
2908 if !report.impact_categories.is_empty() {
2909 lines.push(format!(
2910 "impact categories: {}",
2911 report.impact_categories.join(", ")
2912 ));
2913 }
2914 if !report.heavy_impact_paths.is_empty() {
2915 lines.push(format!(
2916 "heavy-impact change: {} — review broader impact before merging",
2917 preview_list(&report.heavy_impact_paths, report.heavy_impact_paths.len(),)
2918 ));
2919 }
2920 lines.push(format!(
2921 "merge type: {}",
2922 merge_relation_summary(&report.merge_relation)
2923 ));
2924 if report.conflict_count > 0 {
2925 lines.push(format!(
2926 "conflicts: {} path conflict(s)",
2927 report.conflict_count
2928 ));
2929 }
2930 lines
2931}
2932
2933fn thread_mode_summary(mode: &str) -> &str {
2934 match mode {
2935 "solid" => "main checkout",
2936 "materialized" => "disk checkout",
2937 "virtualized" => "virtual checkout",
2938 other => other,
2939 }
2940}
2941
2942fn merge_relation_summary(result: &str) -> String {
2943 result.replace('_', "-")
2944}
2945
2946#[cfg(test)]
2947mod tests {
2948 use super::*;
2949
2950 #[test]
2970 fn merge_strategy_is_decided_once_preview_equals_apply() {
2971 for no_semantic in [false, true] {
2973 let plan = MergeAttemptPlan::decide(no_semantic);
2974 let semantic_active = plan.strategy() == MergeStrategy::Semantic;
2975 assert_eq!(
2976 semantic_active,
2977 plan.use_semantic(),
2978 "MergeAttemptPlan strategy and use_semantic must agree (no_semantic={no_semantic})"
2979 );
2980 assert_eq!(
2981 plan.strategy(),
2982 merge_strategy_for(semantic_merge_enabled(no_semantic)),
2983 "decide() must select the same strategy the legacy derivation would"
2984 );
2985 }
2986
2987 let source = include_str!("mod.rs");
2994 let body = source
2995 .split_once("pub fn merge_thread_into_current_with_machine_contract(")
2996 .expect("merge_thread_into_current_with_machine_contract must exist")
2997 .1
2998 .split_once("\nfn mark_merge_previewed(")
2999 .expect(
3000 "merge_thread_into_current_with_machine_contract must be delimited by mark_merge_previewed",
3001 )
3002 .0;
3003 let decide_calls = body.matches("MergeAttemptPlan::decide(").count();
3004 assert_eq!(
3005 decide_calls, 1,
3006 "merge_thread_into_current must decide the merge strategy exactly once \
3007 (found {decide_calls} MergeAttemptPlan::decide call sites)"
3008 );
3009 assert!(
3010 !body.contains("merge_strategy_for(use_semantic)"),
3011 "preview and apply must consume the single MergeAttemptPlan, not re-derive \
3012 the strategy via merge_strategy_for(use_semantic)"
3013 );
3014 }
3015
3016 #[test]
3017 fn merge_in_progress_refusal_uses_typed_recovery_advice() {
3018 let err = advice::merge_already_in_progress();
3019 let objects::HeddleError::Recovery(details) = err else {
3020 panic!("expected recovery error");
3021 };
3022
3023 assert_eq!(details.kind, "merge_already_in_progress");
3024 assert!(details.error.contains("merge is already in progress"));
3025 assert!(details.hint.contains("heddle continue"));
3026 assert!(details.preserved.contains("left unchanged"));
3027 }
3028
3029 #[test]
3034 fn prepare_dir_for_file_replacement_removes_empty_directory() {
3035 let dir = tempfile::TempDir::new().unwrap();
3036 let target = dir.path().join("entry");
3037 fs::create_dir(&target).unwrap();
3038
3039 prepare_dir_for_file_replacement(&target).expect("empty dir is removable");
3040
3041 assert!(
3042 !target.exists(),
3043 "empty directory must be removed so a file can take its place"
3044 );
3045 }
3046
3047 #[test]
3053 fn prepare_dir_for_file_replacement_errors_on_non_empty_directory() {
3054 let dir = tempfile::TempDir::new().unwrap();
3055 let target = dir.path().join("entry");
3056 fs::create_dir(&target).unwrap();
3057 fs::create_dir(target.join("node_modules")).unwrap();
3061 fs::write(target.join("node_modules").join("dep.js"), "ignored").unwrap();
3062
3063 let err = prepare_dir_for_file_replacement(&target)
3064 .expect_err("non-empty dir must error rather than silently delete");
3065 let msg = err.to_string();
3066 assert!(
3067 msg.contains("cannot replace directory"),
3068 "missing 'cannot replace directory' phrase: {msg}"
3069 );
3070 assert!(
3071 msg.contains("heddle-ignored content"),
3072 "missing 'heddle-ignored content' phrase: {msg}"
3073 );
3074 assert!(
3075 msg.contains("node_modules"),
3076 "error must list the offending entry: {msg}"
3077 );
3078 assert!(
3082 target.join("node_modules").join("dep.js").exists(),
3083 "ignored content must NOT be deleted by the failure path"
3084 );
3085 }
3086
3087 #[test]
3090 fn prepare_dir_for_file_replacement_tolerates_missing_path() {
3091 let dir = tempfile::TempDir::new().unwrap();
3092 let target = dir.path().join("entry");
3093 prepare_dir_for_file_replacement(&target).expect("missing dir is a no-op, not an error");
3096 }
3097
3098 #[test]
3104 fn extended_validation_does_not_require_git_cli_identity() {
3105 use std::process::Command;
3106
3107 let dir = tempfile::TempDir::new().unwrap();
3108 let status = Command::new("git")
3110 .arg("-C")
3111 .arg(dir.path())
3112 .args(["init", "--quiet"])
3113 .status()
3114 .expect("git must be on PATH for the native Git validation test");
3115 assert!(
3116 status.success(),
3117 "git init must succeed for the test fixture"
3118 );
3119 let blockers =
3120 validate_git_commit_preconditions_extended(dir.path(), &["dummy.txt".to_string()]);
3121 assert!(
3122 blockers.is_empty(),
3123 "native Git commit writing should not require a Git CLI/config identity; Heddle can author from captured principal: {blockers:?}"
3124 );
3125 }
3126
3127 #[test]
3132 fn extended_validation_flags_empty_changed_paths() {
3133 let dir = tempfile::TempDir::new().unwrap();
3134 let blockers = validate_git_commit_preconditions_extended(dir.path(), &[]);
3135 assert!(
3136 blockers
3137 .iter()
3138 .any(|b| b.contains("integration produced no changed paths")),
3139 "empty merge_paths must surface as a blocker: {blockers:?}"
3140 );
3141 }
3142
3143 #[test]
3148 fn extended_validation_skips_identity_check_when_no_git_dir() {
3149 let dir = tempfile::TempDir::new().unwrap();
3150 let blockers = validate_git_commit_preconditions_extended(dir.path(), &["a".to_string()]);
3151 assert!(
3156 !blockers.iter().any(|b| b.contains("git user.name")),
3157 "must not report identity blockers without a git overlay: {blockers:?}"
3158 );
3159 assert!(
3160 !blockers.iter().any(|b| b.contains("git user.email")),
3161 "must not report identity blockers without a git overlay: {blockers:?}"
3162 );
3163 }
3164
3165 #[test]
3166 fn coordination_blocker_recommendations_are_machine_actions() {
3167 let merge_state = "hs-landed123".to_string();
3168 let post_snapshot = coordination_blocker_recommended_action(Some(&merge_state));
3169 assert_eq!(post_snapshot, "heddle capture -m \"...\"");
3170 assert!(
3171 action_template(&post_snapshot).is_some(),
3172 "commit placeholder should carry a fillable template"
3173 );
3174
3175 let pre_snapshot = coordination_blocker_recommended_action(None);
3176 assert_eq!(pre_snapshot, "heddle status");
3177 assert!(
3178 action_template(&pre_snapshot).is_some(),
3179 "status action should carry a template"
3180 );
3181 for action in [post_snapshot, pre_snapshot] {
3182 assert!(
3183 !action.contains("resolve git state")
3184 && !action.contains("see blockers")
3185 && !action.contains("do NOT"),
3186 "recommended actions must be Heddle commands/templates, not prose: {action}"
3187 );
3188 }
3189 }
3190
3191 #[test]
3192 fn empty_diff_output_is_self_consistent_and_serializes() {
3193 let id = objects::object::StateId::from_bytes([69; 32]);
3194 let out = empty_diff_output(&id);
3195
3196 assert_eq!(out.from_state.as_deref(), Some(id.short()).as_deref());
3197 assert_eq!(out.to_state.as_deref(), Some(id.short()).as_deref());
3198 assert!(
3199 out.changes.is_empty(),
3200 "empty_diff_output must report no changes — that's the whole point"
3201 );
3202 assert!(out.semantic_changes.is_none());
3203
3204 let json = serde_json::to_value(&out).unwrap();
3205 assert_eq!(
3206 json["changes"].as_array().unwrap().len(),
3207 0,
3208 "`changes` array must serialize as empty, not be omitted"
3209 );
3210 assert_eq!(
3211 json["from_state"], json["to_state"],
3212 "self-loop semantics: from == to when no change landed"
3213 );
3214 }
3215}