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