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