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