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, FacetKind, StateId, ThreadName, Tree},
30 store::ObjectStore,
31};
32use oplog::{OpBatch, OpLogBackend, OpLogRecorder, OpRecord};
33use refs::Head;
34use repo::{
35 ActorPresenceStatus, ActorPresenceStore, CommitGraphIndex, Repository, Thread, ThreadFreshness,
36 ThreadIntegrationPolicy, ThreadManager, ThreadState, describe_thread_advice, find_merge_base,
37 refresh_thread_freshness,
38};
39use schemars::JsonSchema;
40use serde::{Serialize, Serializer, ser::SerializeStruct};
41use sley::Repository as SleyRepository;
42
43use crate::{
44 ActionTemplate, DiffReport, SemanticChangeEntry, compute_state_diff, compute_tree_diff,
45 verify::{
46 MachineContractInput, RepositoryVerificationState, action_template,
47 build_repository_verification_state_with_machine_contract, serialize_empty_action_as_null,
48 },
49};
50
51mod advice;
52mod apply;
53mod git_commit;
54mod plan;
55mod relation;
56mod structured;
57mod worktree_safety;
58
59pub use apply::apply_merged_tree;
60pub use git_commit::{GitCommitInfo, GitCommitPreview};
61pub use 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 FacetKind::SourceHistory
528 .require_land()
529 .map_err(|kind| anyhow!("{kind} cannot be landed onto source history"))?;
530 merge_thread_into_current(
531 repo,
532 &opts.track_name,
533 opts.message,
534 opts.no_commit,
535 opts.preview,
536 opts.with_diff,
537 opts.no_semantic,
538 opts.git_commit,
539 )
540}
541
542#[allow(clippy::too_many_arguments)]
546pub fn merge_thread_into_current(
547 repo: &Repository,
548 track_name: &str,
549 message: Option<String>,
550 no_commit: bool,
551 preview: bool,
552 with_diff: bool,
553 no_semantic: bool,
554 git_commit: bool,
555) -> Result<MergeReport> {
556 merge_thread_into_current_with_machine_contract(
557 repo,
558 track_name,
559 message,
560 no_commit,
561 preview,
562 with_diff,
563 no_semantic,
564 git_commit,
565 &MachineContractInput::default(),
566 )
567}
568
569#[allow(clippy::too_many_arguments)]
574pub fn merge_thread_into_current_with_machine_contract(
575 repo: &Repository,
576 track_name: &str,
577 message: Option<String>,
578 no_commit: bool,
579 preview: bool,
580 with_diff: bool,
581 no_semantic: bool,
582 git_commit: bool,
583 machine_contract: &MachineContractInput,
584) -> Result<MergeReport> {
585 merge_thread_into_current_transactional(
586 repo,
587 track_name,
588 message,
589 no_commit,
590 preview,
591 with_diff,
592 no_semantic,
593 git_commit,
594 machine_contract,
595 None,
596 )
597}
598
599#[allow(clippy::too_many_arguments)]
603pub fn merge_thread_into_current_transactional(
604 repo: &Repository,
605 track_name: &str,
606 message: Option<String>,
607 no_commit: bool,
608 preview: bool,
609 with_diff: bool,
610 no_semantic: bool,
611 git_commit: bool,
612 machine_contract: &MachineContractInput,
613 transaction_id: Option<&str>,
614) -> Result<MergeReport> {
615 FacetKind::SourceHistory
616 .require_land()
617 .map_err(|kind| anyhow!("{kind} cannot be landed onto source history"))?;
618 let attempt = MergeAttemptPlan::decide(no_semantic);
624 let use_semantic = attempt.use_semantic();
625 let registry = ActorPresenceStore::new(repo.heddle_dir());
626 let thread_manager = ThreadManager::new(repo.heddle_dir());
627 let mut thread = thread_manager.find_by_thread(track_name)?;
628 if let Some(ref mut thread) = thread {
629 refresh_thread_freshness(repo, thread)?;
630 }
631 let thread_entry = registry
632 .list()?
633 .into_iter()
634 .filter(|entry| entry.thread == track_name)
635 .max_by_key(|entry| entry.started_at);
636
637 let merge_manager = repo.merge_state_manager();
638 if merge_manager.is_merge_in_progress() {
639 return Err(anyhow!(advice::merge_already_in_progress()));
640 }
641
642 if preview {
643 ensure_worktree_clean(repo, "merge")?;
644 }
645
646 if preview {
647 let trust = trust_state(repo, machine_contract)?;
648 if trust_blocks_merge_preview(&trust) {
649 return Ok(merge_blocked_by_trust_output(
650 &thread, None, trust, preview, None,
651 ));
652 }
653 }
654
655 let merge_target_id = repo
656 .refs()
657 .get_thread(&ThreadName::new(track_name))?
658 .ok_or_else(|| anyhow!(advice::thread_not_found(track_name, "merge")))?;
659
660 let current_change = repo
661 .current_state()?
662 .map(|state| state.state_id)
663 .ok_or_else(|| {
664 anyhow!(
665 "No current state to merge into; capture or bootstrap the repository before merging"
666 )
667 })?;
668 let current_state = repo
669 .store()
670 .get_state(¤t_change)?
671 .ok_or_else(|| anyhow!("Current state not found"))?;
672
673 ensure_worktree_clean(repo, "merge")?;
674
675 let mut graph = CommitGraphIndex::new(repo);
676 let current_thread = repo
683 .current_lane()?
684 .unwrap_or_else(|| "detached".to_string());
685 let preview_report = match thread.as_mut() {
693 Some(thread) => Some(build_thread_preview_report_with_graph(
694 repo,
695 &mut graph,
696 thread,
697 preview,
698 attempt.strategy(),
699 Some(PreviewTarget {
700 label: ¤t_thread,
701 state_id: current_state.state_id,
702 }),
703 )?),
704 None => None,
705 };
706 if let Some(thread) = thread.as_ref()
707 && let Some(uncaptured) = source_thread_uncaptured_work(repo, thread)?
708 {
709 return Err(anyhow!(advice::source_thread_uncaptured_work(
710 &thread.id,
711 &uncaptured.checkout_path,
712 &uncaptured.dirty_paths,
713 preview,
714 )));
715 }
716 if let Some(output) = merge_freshness_preflight_output(
717 repo,
718 machine_contract,
719 &thread,
720 preview_report.as_ref(),
721 preview,
722 )? {
723 return Ok(output);
724 }
725 let preview_summary = build_preview_summary(preview_report.as_ref());
726 let current_label = format!("CURRENT ({current_thread})");
727 let incoming_label = format!("INCOMING ({track_name})");
728 let merge_plan = MergePlan::for_merge_command(
729 repo,
730 &mut graph,
731 ¤t_state.state_id,
732 &merge_target_id,
733 ConflictLabels {
734 current: ¤t_label,
735 incoming: &incoming_label,
736 strategy: attempt.strategy(),
737 },
738 )?;
739
740 let diff_for = |from: &StateId, to: &StateId| -> Result<Option<DiffReport>> {
748 if !with_diff {
749 return Ok(None);
750 }
751 Ok(Some(compute_state_diff(repo, from, to, use_semantic, 3)?))
752 };
753 let top_level_semantic = |diff: Option<&DiffReport>| -> Option<Vec<SemanticChangeEntry>> {
763 if !use_semantic || !with_diff {
764 return None;
765 }
766 Some(
767 diff.and_then(|d| d.semantic_changes.clone())
768 .unwrap_or_default(),
769 )
770 };
771
772 if merge_plan.relation().kind() == MergeRelationKind::AlreadyUpToDate {
773 let trust = trust_state(repo, machine_contract)?;
774 if !trust.verified {
775 return Ok(merge_blocked_by_trust_output(
776 &thread,
777 preview_report.as_ref(),
778 trust,
779 preview,
780 Some(merge_plan.relation().as_json_value().to_string()),
781 ));
782 }
783 let already_up_to_date_diff = if with_diff {
788 Some(empty_diff_output(¤t_state.state_id))
789 } else {
790 None
791 };
792 return merge_output_from_report(MergeReportInput {
793 repo,
794 machine_contract,
795 thread: &thread,
796 preview_report: preview_report.as_ref(),
797 conflicts: Some(vec![]),
798 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
799 conflict_count: Some(0),
800 changed_paths: Some(Vec::new()),
801 preview_summary: vec![],
802 message: "Already up to date".to_string(),
803 renames: vec![],
804 directory_renames: vec![],
805 merge_state: None,
806 fast_forward: false,
807 preview_only: preview,
808 semantic_changes: top_level_semantic(already_up_to_date_diff.as_ref()),
809 diff: already_up_to_date_diff,
810 git_commit_preview: None,
811 git_commit: None,
812 extra_blockers: Vec::new(),
813 });
814 }
815
816 if merge_plan.relation().kind() == MergeRelationKind::FastForward {
817 let ff_paths = merge_changed_paths(repo, ¤t_state.state_id, &merge_target_id)?;
821
822 let (ff_renames, ff_directory_renames) =
825 fast_forward_renames(repo, ¤t_state.state_id, &merge_target_id)?;
826 let ff_diff = diff_for(¤t_state.state_id, &merge_target_id)?
827 .map(|diff| diff_with_known_renames(diff, &ff_renames));
828
829 let mut git_commit_blockers: Vec<String> = Vec::new();
834 if git_commit
835 && !preview
836 && let Err(blocked) = git_commit::validate_git_state(repo, &ff_paths)
837 {
838 git_commit_blockers = blocked.blockers;
839 }
840
841 if !git_commit_blockers.is_empty() {
842 return merge_output_from_report(MergeReportInput {
844 repo,
845 machine_contract,
846 thread: &thread,
847 preview_report: preview_report.as_ref(),
848 conflicts: Some(vec![]),
849 merge_relation: Some("fast_forward".to_string()),
850 conflict_count: Some(0),
851 changed_paths: Some(ff_paths.clone()),
852 preview_summary,
853 message: "Fast-forward blocked: --git-commit precondition failed".to_string(),
854 renames: ff_renames,
855 directory_renames: ff_directory_renames,
856 merge_state: None,
857 fast_forward: false,
858 preview_only: preview,
859 semantic_changes: top_level_semantic(ff_diff.as_ref()),
860 diff: ff_diff,
861 git_commit_preview: None,
862 git_commit: None,
863 extra_blockers: git_commit_blockers,
864 });
865 }
866
867 let git_branch_before = if git_commit && !preview {
868 Some(
869 repo.git_overlay_current_branch()?
870 .unwrap_or_else(|| "HEAD".to_string()),
871 )
872 } else {
873 None
874 };
875 let git_oid_before = if git_commit && !preview {
876 git_rev_parse_head(repo.root())
877 } else {
878 None
879 };
880 let source_git_parent = if git_commit {
881 source_git_parent_for_thread(repo, track_name, &merge_target_id)?
882 } else {
883 None
884 };
885 let mut git_commit_preview_payload: Option<GitCommitPreview> = None;
886 let mut git_commit_info: Option<GitCommitInfo> = None;
887
888 if !preview {
889 if let Some(transaction_id) = transaction_id {
904 repo.fast_forward_attached_transactional(
905 track_name,
906 &merge_target_id,
907 transaction_id,
908 )?;
909 } else {
910 let head_before_ff = repo.head_ref()?;
911 repo.fast_forward_attached_without_record(&merge_target_id)?;
912 match &head_before_ff {
913 Head::Attached {
914 thread: target_thread,
915 } => {
916 repo.oplog().record_fast_forward(
917 &ThreadName::new(track_name),
918 target_thread,
919 ¤t_state.state_id,
920 &merge_target_id,
921 Some(&repo.op_scope()),
922 )?;
923 }
924 Head::Detached { state } => {
925 repo.oplog().record_goto(
926 &merge_target_id,
927 Some(state),
928 Some(&repo.op_scope()),
929 )?;
930 }
931 }
932 }
933 if let Some(entry) = &thread_entry {
934 registry.update_status(&entry.session_id, ActorPresenceStatus::Merged)?;
935 }
936 if let Some(thread) = thread.as_mut() {
937 thread.state = ThreadState::Merged;
938 thread.merged_state = Some(merge_target_id.short());
939 thread.current_state = Some(merge_target_id.short());
940 thread.updated_at = chrono::Utc::now();
941 thread.freshness = ThreadFreshness::Current;
942 thread_manager.save(thread)?;
943 }
944
945 if git_commit {
946 let attribution = Attribution::human(repo.get_principal()?);
950 let ff_message = preview_merge_message(repo, &message, thread.as_ref(), track_name);
951 let commit_message = git_commit::build_commit_message(
952 &ff_message,
953 &merge_target_id.short(),
954 &attribution,
955 );
956 let extra_parents = source_git_parent.clone().into_iter().collect::<Vec<_>>();
957 let info = git_commit::write_git_commit(
958 repo,
959 &merge_target_id,
960 &ff_paths,
961 &commit_message,
962 &extra_parents,
963 )?;
964 finalize_merge_git_checkpoint(
965 repo,
966 &merge_target_id,
967 git_branch_before.unwrap_or_else(|| "HEAD".to_string()),
968 git_oid_before,
969 &info.sha,
970 &ff_message,
971 )?;
972 git_commit_info = Some(info);
973 }
974 } else if git_commit {
975 let attribution = Attribution::human(repo.get_principal()?);
977 let ff_message = preview_merge_message(repo, &message, thread.as_ref(), track_name);
978 let preview_msg = git_commit::build_commit_message(
979 &ff_message,
980 &merge_target_id.short(),
981 &attribution,
982 );
983 git_commit_preview_payload = Some(GitCommitPreview {
984 message: preview_msg,
985 files: ff_paths.clone(),
986 });
987 }
988 let output_changed_paths = ff_paths.clone();
989 let output_changed_path_count = output_changed_paths.len();
990
991 let recommended_action = if preview {
992 if let Some(thread) = thread.as_ref() {
993 if thread.state == ThreadState::Ready {
994 mark_merge_previewed(repo, &thread.id)?;
995 }
996 if let Some(report) = preview_report.as_ref()
997 && !report.blockers.is_empty()
998 && !report.recommended_action.trim().is_empty()
999 && report
1000 .blockers
1001 .iter()
1002 .any(|blocker| is_real_merge_blocker(blocker))
1003 {
1004 Some(report.recommended_action.clone())
1005 } else {
1006 Some(land_local_command(&thread.id))
1007 }
1008 } else {
1009 None
1010 }
1011 } else {
1012 None
1013 };
1014 return Ok(MergeReport {
1015 operator: OperatorCommandOutput {
1016 status: if preview { "preview" } else { "completed" }.to_string(),
1017 action: OperatorAction::Merge,
1018 message: match (preview, git_commit, repo.head_ref()?) {
1019 (true, true, Head::Attached { thread }) => {
1020 format!(
1021 "Would advance {} to {} and write a Git checkpoint commit",
1022 thread,
1023 merge_target_id.short()
1024 )
1025 }
1026 (true, true, Head::Detached { .. }) => {
1027 format!(
1028 "Would advance to {} and write a Git checkpoint commit",
1029 merge_target_id.short()
1030 )
1031 }
1032 (false, true, Head::Attached { thread }) => {
1033 format!(
1034 "Advanced {} to {} and wrote a Git checkpoint commit",
1035 thread,
1036 merge_target_id.short()
1037 )
1038 }
1039 (false, true, Head::Detached { .. }) => {
1040 format!(
1041 "Advanced to {} and wrote a Git checkpoint commit",
1042 merge_target_id.short()
1043 )
1044 }
1045 (true, false, Head::Attached { thread }) => {
1046 format!(
1047 "Would fast-forward {} to {}",
1048 thread,
1049 merge_target_id.short()
1050 )
1051 }
1052 (true, false, Head::Detached { .. }) => {
1053 format!("Would fast-forward to {}", merge_target_id.short())
1054 }
1055 (false, false, Head::Attached { thread }) => {
1056 format!("Fast-forwarded {} to {}", thread, merge_target_id.short())
1057 }
1058 (false, false, Head::Detached { .. }) => {
1059 format!("Fast-forwarded to {}", merge_target_id.short())
1060 }
1061 },
1062 blockers: Vec::new(),
1068 warnings: preview_report
1069 .as_ref()
1070 .map(|r| r.blockers.clone())
1071 .unwrap_or_default(),
1072 next_action: recommended_action.clone(),
1073 recommended_action: recommended_action.clone(),
1074 },
1075 would_merge: preview,
1076 applied: !preview,
1077 fast_forward: true,
1078 preview_only: preview,
1079 merge_state: (!preview).then(|| merge_target_id.short()),
1080 conflicts: vec![],
1081 preview_summary,
1082 thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
1083 freshness: thread.as_ref().map(|thread| thread.freshness.to_string()),
1084 changed_paths: output_changed_paths,
1085 changed_path_count: output_changed_path_count,
1086 impact_categories: thread_impacts(&thread),
1087 promotion_suggested: thread
1088 .as_ref()
1089 .map(|thread| thread.promotion_suggested)
1090 .unwrap_or(false),
1091 heavy_impact_paths: thread_heavy_paths(&thread),
1092 merge_relation: Some("fast_forward".to_string()),
1093 conflict_count: 0,
1094 thread_health: merge_output_thread_health(thread.as_ref(), preview_report.as_ref()),
1095 renames: ff_renames,
1096 directory_renames: ff_directory_renames,
1097 semantic_changes: top_level_semantic(ff_diff.as_ref()),
1098 diff: ff_diff,
1099 git_commit_preview: git_commit_preview_payload,
1100 git_commit: git_commit_info,
1101 trust: Some({
1102 let mut trust = trust_state(repo, machine_contract)?;
1103 if let Some(action) = recommended_action.as_ref() {
1104 override_trust_recommended_action(&mut trust, action.clone());
1105 }
1106 trust
1107 }),
1108 });
1109 }
1110
1111 let merge_base_id = merge_plan
1112 .relation()
1113 .merge_base_id()
1114 .ok_or_else(|| anyhow!("Merge base missing from merge plan"))?;
1115 let merge_result = merge_plan
1116 .merge_result()
1117 .ok_or_else(|| anyhow!("Merge result missing from merge plan"))?;
1118 let rename_entries: Vec<RenameEntry> = merge_result
1119 .renames
1120 .iter()
1121 .map(|rename| RenameEntry {
1122 from: rename.from.clone(),
1123 to: rename.to.clone(),
1124 score: rename.score,
1125 })
1126 .collect();
1127 let dir_rename_entries: Vec<RenameEntry> = merge_result
1128 .directory_renames
1129 .iter()
1130 .map(|rename| RenameEntry {
1131 from: rename.from.clone(),
1132 to: rename.to.clone(),
1133 score: 1.0,
1134 })
1135 .collect();
1136
1137 if preview {
1138 let git_commit_preview = if git_commit && merge_result.conflicts.is_empty() {
1145 let preview_message =
1146 preview_merge_message(repo, &message, thread.as_ref(), track_name);
1147 let attribution = Attribution::human(repo.get_principal()?);
1148 let preview_msg =
1149 git_commit::build_commit_message(&preview_message, "<pending>", &attribution);
1150 Some(GitCommitPreview {
1151 message: preview_msg,
1152 files: merge_changed_paths(repo, ¤t_state.state_id, &merge_target_id)?,
1153 })
1154 } else {
1155 None
1156 };
1157 let preview_path_diff = compute_tree_diff(
1162 repo,
1163 ¤t_state.state_id,
1164 &merge_result.tree,
1165 "<merged-preview>",
1166 with_diff && use_semantic,
1167 if with_diff { 3 } else { 0 },
1168 )
1169 .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1170 let preview_changed_paths = diff_changed_paths(&preview_path_diff);
1171 let preview_diff = with_diff.then_some(preview_path_diff);
1172 if merge_result.conflicts.is_empty()
1173 && thread
1174 .as_ref()
1175 .is_some_and(|thread| thread.state == ThreadState::Ready)
1176 && let Some(thread) = thread.as_ref()
1177 {
1178 mark_merge_previewed(repo, &thread.id)?;
1179 }
1180 return merge_output_from_report(MergeReportInput {
1181 repo,
1182 machine_contract,
1183 thread: &thread,
1184 preview_report: preview_report.as_ref(),
1185 conflicts: Some(merge_result.conflicts.clone()),
1186 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1187 conflict_count: Some(merge_plan.relation().conflict_count()),
1188 changed_paths: Some(preview_changed_paths.clone()),
1189 preview_summary,
1190 message: merge_preview_message(
1191 thread.as_ref(),
1192 track_name,
1193 merge_result.conflicts.len(),
1194 preview_changed_paths.len(),
1195 ),
1196 renames: rename_entries.clone(),
1197 directory_renames: dir_rename_entries.clone(),
1198 merge_state: None,
1199 fast_forward: false,
1200 preview_only: true,
1201 semantic_changes: top_level_semantic(preview_diff.as_ref()),
1202 diff: preview_diff,
1203 git_commit_preview,
1204 git_commit: None,
1205 extra_blockers: Vec::new(),
1206 });
1207 }
1208
1209 apply_merged_tree(repo, &merge_result.tree)?;
1210
1211 if !merge_result.conflicts.is_empty() {
1212 let structured_conflicts = merge_plan
1213 .structured_conflicts()
1214 .map(|payload| -> Result<ContentHash> {
1215 let bytes = payload.encode()?;
1216 Ok(repo.store().put_blob(&Blob::new(bytes))?)
1217 })
1218 .transpose()?;
1219 merge_manager.start(
1220 current_state.state_id,
1221 merge_target_id,
1222 Some(merge_base_id),
1223 merge_result.conflicts.clone(),
1224 structured_conflicts,
1225 )?;
1226 let conflict_diff = if with_diff {
1232 Some(empty_diff_output(¤t_state.state_id))
1233 } else {
1234 None
1235 };
1236 return merge_output_from_report(MergeReportInput {
1237 repo,
1238 machine_contract,
1239 thread: &thread,
1240 preview_report: preview_report.as_ref(),
1241 conflicts: Some(merge_result.conflicts.clone()),
1242 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1243 conflict_count: Some(merge_plan.relation().conflict_count()),
1244 changed_paths: Some(merge_result.conflicts.clone()),
1245 preview_summary,
1246 message: "Merged with conflicts".to_string(),
1247 renames: rename_entries,
1248 directory_renames: dir_rename_entries,
1249 merge_state: None,
1250 fast_forward: false,
1251 preview_only: false,
1252 semantic_changes: top_level_semantic(conflict_diff.as_ref()),
1253 diff: conflict_diff,
1254 git_commit_preview: None,
1255 git_commit: None,
1256 extra_blockers: Vec::new(),
1257 });
1258 }
1259
1260 if no_commit {
1261 let no_commit_path_diff = compute_tree_diff(
1272 repo,
1273 ¤t_state.state_id,
1274 &merge_result.tree,
1275 "<merged-no-commit>",
1276 false,
1277 0,
1278 )
1279 .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1280 let no_commit_changed_paths = diff_changed_paths(&no_commit_path_diff);
1281 let no_commit_diff: Option<DiffReport> = None;
1282 return merge_output_from_report(MergeReportInput {
1283 repo,
1284 machine_contract,
1285 thread: &thread,
1286 preview_report: preview_report.as_ref(),
1287 conflicts: Some(vec![]),
1288 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1289 conflict_count: Some(merge_plan.relation().conflict_count()),
1290 changed_paths: Some(no_commit_changed_paths),
1291 preview_summary,
1292 message: "Merge applied (not committed)".to_string(),
1293 renames: rename_entries,
1294 directory_renames: dir_rename_entries,
1295 merge_state: None,
1296 fast_forward: false,
1297 preview_only: false,
1298 semantic_changes: top_level_semantic(no_commit_diff.as_ref()),
1299 diff: no_commit_diff,
1300 git_commit_preview: None,
1301 git_commit: None,
1302 extra_blockers: Vec::new(),
1303 });
1304 }
1305
1306 let merge_message =
1307 message.unwrap_or_else(|| default_merge_message(repo, thread.as_ref(), track_name));
1308
1309 let attribution = Attribution::human(repo.get_principal()?);
1310 let merge_paths: Vec<String> = if git_commit {
1319 merge_changed_paths(repo, ¤t_state.state_id, &merge_target_id)?
1320 } else {
1321 Vec::new()
1322 };
1323 let mut git_commit_blockers: Vec<String> = Vec::new();
1324 if git_commit {
1325 if let Err(blocked) = git_commit::validate_git_state(repo, &merge_paths) {
1326 git_commit_blockers = blocked.blockers;
1327 }
1328 let extended = validate_git_commit_preconditions_extended(repo.root(), &merge_paths);
1336 git_commit_blockers.extend(extended);
1337 }
1338 if !git_commit_blockers.is_empty() {
1339 let blocked_diff = if with_diff {
1344 Some(empty_diff_output(¤t_state.state_id))
1345 } else {
1346 None
1347 };
1348 return merge_output_from_report(MergeReportInput {
1349 repo,
1350 machine_contract,
1351 thread: &thread,
1352 preview_report: preview_report.as_ref(),
1353 conflicts: Some(vec![]),
1354 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1355 conflict_count: Some(merge_plan.relation().conflict_count()),
1356 changed_paths: Some(Vec::new()),
1357 preview_summary,
1358 message: "Merge blocked: git --git-commit precondition failed".to_string(),
1359 renames: rename_entries,
1360 directory_renames: dir_rename_entries,
1361 merge_state: None,
1362 fast_forward: false,
1363 preview_only: false,
1364 semantic_changes: top_level_semantic(blocked_diff.as_ref()),
1365 diff: blocked_diff,
1366 git_commit_preview: None,
1367 git_commit: None,
1368 extra_blockers: git_commit_blockers,
1369 });
1370 }
1371
1372 let git_branch_before = if git_commit {
1373 Some(
1374 repo.git_overlay_current_branch()?
1375 .unwrap_or_else(|| "HEAD".to_string()),
1376 )
1377 } else {
1378 None
1379 };
1380 let git_oid_before = if git_commit {
1381 git_rev_parse_head(repo.root())
1382 } else {
1383 None
1384 };
1385 let source_git_parent = if git_commit {
1386 source_git_parent_for_thread(repo, track_name, &merge_target_id)?
1387 } else {
1388 None
1389 };
1390
1391 let new_state = repo.snapshot_merge_with_attribution_transaction(
1392 &merge_target_id,
1393 Some(merge_message.clone()),
1394 None,
1395 attribution.clone(),
1396 Some(merge_base_id),
1397 false,
1398 transaction_id,
1399 )?;
1400
1401 if let Some(entry) = &thread_entry {
1402 registry.update_status(&entry.session_id, ActorPresenceStatus::Merged)?;
1403 }
1404 if let Some(thread) = thread.as_mut() {
1405 thread.state = ThreadState::Merged;
1406 thread.merged_state = Some(new_state.state_id.short());
1407 thread.current_state = Some(new_state.state_id.short());
1408 thread.updated_at = chrono::Utc::now();
1409 thread.freshness = ThreadFreshness::Current;
1410 thread_manager.save(thread)?;
1411 }
1412
1413 let mut git_commit_info: Option<GitCommitInfo> = None;
1422 let mut post_snapshot_git_blockers: Vec<String> = Vec::new();
1423 if git_commit {
1424 let commit_message = git_commit::build_commit_message(
1425 &merge_message,
1426 &new_state.state_id.short(),
1427 &attribution,
1428 );
1429 let extra_parents = source_git_parent.clone().into_iter().collect::<Vec<_>>();
1430 match git_commit::write_git_commit(
1431 repo,
1432 &new_state.state_id,
1433 &merge_paths,
1434 &commit_message,
1435 &extra_parents,
1436 ) {
1437 Ok(info) => {
1438 git_commit_info = Some(info.clone());
1439 if let Err(err) = finalize_merge_git_checkpoint(
1440 repo,
1441 &new_state.state_id,
1442 git_branch_before.unwrap_or_else(|| "HEAD".to_string()),
1443 git_oid_before,
1444 &info.sha,
1445 &merge_message,
1446 ) {
1447 tracing::warn!(
1448 error = %err,
1449 state = %new_state.state_id.short(),
1450 git_commit = %info.sha,
1451 "git commit succeeded after Heddle integration, but Git metadata recording failed"
1452 );
1453 post_snapshot_git_blockers.push(format!(
1454 "git commit {} was written for integrated Heddle state {}, but Git metadata recording failed: {}",
1455 info.sha,
1456 new_state.state_id.short(),
1457 err
1458 ));
1459 post_snapshot_git_blockers.push(format!(
1460 "recovery: integrated Heddle state {} and Git commit {} are intact; run `heddle verify` \
1461 and use its primary recovery command before undoing this integration",
1462 new_state.state_id.short(),
1463 info.sha
1464 ));
1465 }
1466 }
1467 Err(err) => {
1468 tracing::warn!(
1469 error = %err,
1470 state = %new_state.state_id.short(),
1471 "git commit failed after the integrated Heddle state was written"
1472 );
1473 post_snapshot_git_blockers.push(format!(
1474 "git commit failed after Heddle integration state {} landed: {}",
1475 new_state.state_id.short(),
1476 err
1477 ));
1478 post_snapshot_git_blockers.push(format!(
1479 "recovery: integrated Heddle state {} is intact; resolve the Git checkout issue \
1480 (identity, locks, or filesystem errors) and run `heddle commit -m \"{}\"` — do NOT re-run the integration",
1481 new_state.state_id.short(),
1482 merge_message
1483 ));
1484 }
1485 }
1486 }
1487
1488 let committed_path_diff = compute_state_diff(
1494 repo,
1495 ¤t_state.state_id,
1496 &new_state.state_id,
1497 with_diff && use_semantic,
1498 if with_diff { 3 } else { 0 },
1499 )
1500 .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1501 let committed_changed_paths = diff_changed_paths(&committed_path_diff);
1502 let committed_diff = with_diff.then_some(committed_path_diff);
1503
1504 let final_message = if post_snapshot_git_blockers.is_empty() {
1505 format!("Merged as {}", new_state.state_id.short())
1506 } else {
1507 format!(
1508 "Merged as {} (heddle); git commit failed",
1509 new_state.state_id.short()
1510 )
1511 };
1512
1513 merge_output_from_report(MergeReportInput {
1514 repo,
1515 machine_contract,
1516 thread: &thread,
1517 preview_report: preview_report.as_ref(),
1518 conflicts: Some(vec![]),
1519 merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1520 conflict_count: Some(merge_plan.relation().conflict_count()),
1521 changed_paths: Some(committed_changed_paths),
1522 preview_summary,
1523 message: final_message,
1524 renames: rename_entries,
1525 directory_renames: dir_rename_entries,
1526 merge_state: Some(new_state.state_id.short()),
1527 fast_forward: false,
1528 preview_only: false,
1529 semantic_changes: top_level_semantic(committed_diff.as_ref()),
1530 diff: committed_diff,
1531 git_commit_preview: None,
1532 git_commit: git_commit_info,
1533 extra_blockers: post_snapshot_git_blockers,
1534 })
1535}
1536
1537fn land_local_command(thread_id: &str) -> String {
1538 if thread_id.starts_with('-') {
1539 format!("heddle land --thread -- {thread_id}")
1540 } else {
1541 format!("heddle land --thread {thread_id}")
1542 }
1543}
1544
1545fn land_command_for_thread(repo: &Repository, thread_id: &str) -> String {
1546 let _ = repo;
1548 land_local_command(thread_id)
1549}
1550
1551fn mark_merge_previewed(repo: &Repository, thread_id: &str) -> Result<()> {
1552 let manager = ThreadManager::new(repo.heddle_dir());
1553 let mut thread = manager
1554 .load(thread_id)?
1555 .ok_or_else(|| anyhow!(advice::thread_not_found(thread_id, "mark merge previewed")))?;
1556 thread.integration_policy_result = ThreadIntegrationPolicy {
1557 status: Some("previewed".to_string()),
1558 reason: Some("clean merge preview established land path".to_string()),
1559 manual_resolution_state: thread.integration_policy_result.manual_resolution_state,
1560 conflicts_resolved_manually: thread.integration_policy_result.conflicts_resolved_manually,
1561 };
1562 manager.save(&thread)?;
1563 Ok(())
1564}
1565
1566fn preview_merge_message(
1570 repo: &Repository,
1571 explicit: &Option<String>,
1572 thread: Option<&Thread>,
1573 track_name: &str,
1574) -> String {
1575 if let Some(msg) = explicit.as_ref() {
1576 return msg.clone();
1577 }
1578 default_merge_message(repo, thread, track_name)
1579}
1580
1581fn default_merge_message(repo: &Repository, thread: Option<&Thread>, track_name: &str) -> String {
1582 if let Some(intent) =
1583 thread.and_then(|thread| state_intent(repo, thread.current_state.as_deref()))
1584 {
1585 return intent;
1586 }
1587 thread
1588 .and_then(|thread| thread.task.clone())
1589 .map(|task| format!("Merge thread '{}' ({task})", track_name))
1590 .unwrap_or_else(|| format!("Merge thread '{}'", track_name))
1591}
1592
1593fn merge_preview_message(
1594 thread: Option<&Thread>,
1595 track_name: &str,
1596 conflict_count: usize,
1597 diff_changed_path_count: usize,
1598) -> String {
1599 let subject = thread
1600 .map(|thread| thread.id.as_str())
1601 .unwrap_or(track_name);
1602 let thread_changed_path_count = thread
1603 .map(|thread| thread.changed_paths.len())
1604 .unwrap_or_default();
1605 let changed_path_count = if thread_changed_path_count == 0 {
1606 diff_changed_path_count
1607 } else {
1608 thread_changed_path_count
1609 }
1610 .max(conflict_count);
1611 if conflict_count > 0 {
1612 format!(
1613 "Would merge {subject} with {conflict_count} conflict(s) across {changed_path_count} changed path(s)"
1614 )
1615 } else {
1616 format!("Would merge {subject} cleanly across {changed_path_count} changed path(s)")
1617 }
1618}
1619
1620fn state_intent(repo: &Repository, state: Option<&str>) -> Option<String> {
1621 let state = state?;
1622 let state_id = repo.resolve_state(state).ok().flatten()?;
1623 let state = repo.store().get_state(&state_id).ok().flatten()?;
1624 state.intent.filter(|intent| !intent.trim().is_empty())
1625}
1626
1627fn source_git_parent_for_thread(
1628 repo: &Repository,
1629 track_name: &str,
1630 merge_target_id: &StateId,
1631) -> Result<Option<String>> {
1632 if repo.capability() != repo::RepositoryCapability::GitOverlay {
1633 return Ok(None);
1634 }
1635 let Some(tip) = repo.git_overlay_branch_tip(track_name)? else {
1636 return Ok(None);
1637 };
1638 let Some(mapped_change) = tip.mapped_state else {
1639 return Ok(None);
1640 };
1641 if mapped_change == *merge_target_id {
1642 return Ok(Some(tip.git_commit));
1643 }
1644 let mut graph = CommitGraphIndex::new(repo);
1645 if graph
1646 .is_ancestor(&mapped_change, merge_target_id)
1647 .unwrap_or(false)
1648 {
1649 return Ok(Some(tip.git_commit));
1650 }
1651 Ok(None)
1652}
1653
1654fn merge_changed_paths(
1663 repo: &Repository,
1664 parent_tip: &StateId,
1665 thread_tip: &StateId,
1666) -> Result<Vec<String>> {
1667 let diff = compute_state_diff(repo, parent_tip, thread_tip, false, 0)?;
1668 let mut out = Vec::with_capacity(diff.changes.len());
1669 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1670 for change in diff.changes {
1671 if seen.insert(change.path.clone()) {
1672 out.push(change.path);
1673 }
1674 }
1675 Ok(out)
1676}
1677
1678fn finalize_merge_git_checkpoint(
1679 repo: &Repository,
1680 state: &StateId,
1681 branch: String,
1682 previous_git_oid: Option<String>,
1683 git_commit: &str,
1684 summary: &str,
1685) -> Result<()> {
1686 repo.record_git_checkpoint(state, git_commit.to_string(), summary.to_string())
1687 .with_context(|| {
1688 format!(
1689 "recording Git checkpoint metadata for merge state {}",
1690 state.short()
1691 )
1692 })?;
1693 let ids = repo
1694 .oplog()
1695 .record_batch_scoped(
1696 vec![OpRecord::GitCheckpoint {
1697 branch,
1698 state: *state,
1699 previous_git_oid,
1700 new_git_oid: git_commit.to_string(),
1701 }],
1702 Some(&repo.op_scope()),
1703 )
1704 .with_context(|| {
1705 format!(
1706 "recording Git checkpoint undo entry for merge state {}",
1707 state.short()
1708 )
1709 })?;
1710 let checkpoint_batch_id = ids
1711 .first()
1712 .copied()
1713 .ok_or_else(|| anyhow!("Git checkpoint undo entry was not recorded"))?;
1714 let merge_batch = find_recent_merge_batch(repo, state)?;
1715 repo.oplog()
1716 .coalesce_batches(merge_batch.id, checkpoint_batch_id)
1717 .with_context(|| {
1718 format!(
1719 "coalescing merge state {} and Git checkpoint {} into one undo batch",
1720 state.short(),
1721 git_commit
1722 )
1723 })?;
1724 Ok(())
1725}
1726
1727fn find_recent_merge_batch(repo: &Repository, state: &StateId) -> Result<OpBatch> {
1728 repo.oplog()
1729 .recent_batches_scoped(12, Some(&repo.op_scope()))?
1730 .into_iter()
1731 .find(|batch| {
1732 batch
1733 .entries
1734 .iter()
1735 .any(|entry| merge_op_targets_state(&entry.operation, state))
1736 })
1737 .ok_or_else(|| {
1738 anyhow!(
1739 "merge state {} landed but its oplog batch was not found",
1740 state.short()
1741 )
1742 })
1743}
1744
1745fn merge_op_targets_state(op: &OpRecord, state: &StateId) -> bool {
1746 match op {
1747 OpRecord::Snapshot { new_state, .. } => new_state == state,
1748 OpRecord::Goto { target, .. } => target == state,
1749 OpRecord::FastForward { post_target_id, .. } => post_target_id == state,
1750 OpRecord::Checkpoint {
1751 state: checkpoint_state,
1752 ..
1753 } => checkpoint_state == state,
1754 OpRecord::ThreadCreate { .. }
1759 | OpRecord::ThreadDelete { .. }
1760 | OpRecord::ThreadUpdate { .. }
1761 | OpRecord::Fork { .. }
1762 | OpRecord::Collapse { .. }
1763 | OpRecord::MarkerCreate { .. }
1764 | OpRecord::MarkerDelete { .. }
1765 | OpRecord::TransactionAbort { .. }
1766 | OpRecord::EphemeralThreadCollapse { .. }
1767 | OpRecord::ConflictResolved { .. }
1768 | OpRecord::TransactionCommit { .. }
1769 | OpRecord::Redact { .. }
1770 | OpRecord::Purge { .. }
1771 | OpRecord::GitCheckpoint { .. }
1772 | OpRecord::RemoteThreadUpdate { .. }
1773 | OpRecord::RemoteThreadDelete { .. }
1774 | OpRecord::UndoRecoveryUpdate { .. }
1775 | OpRecord::StateVisibilitySet { .. }
1776 | OpRecord::StateVisibilityPromote { .. }
1777 | OpRecord::HeadUpdate { .. } => false,
1778 }
1779}
1780
1781fn git_rev_parse_head(root: &Path) -> Option<String> {
1782 let git = SleyRepository::discover(root).ok()?;
1783 git.head().ok()?.oid.map(|id| id.to_string())
1784}
1785
1786fn validate_git_commit_preconditions_extended(
1812 repo_root: &std::path::Path,
1813 merge_paths: &[String],
1814) -> Vec<String> {
1815 let mut blockers = Vec::new();
1816
1817 if merge_paths.is_empty() {
1818 blockers
1819 .push("integration produced no changed paths — no Git commit is needed".to_string());
1820 }
1821
1822 if !repo_root.join(".git").exists() {
1823 return blockers;
1825 }
1826
1827 blockers
1828}
1829
1830fn empty_diff_output(state_id: &StateId) -> DiffReport {
1835 DiffReport::new(
1836 Some(state_id.short()),
1837 Some(state_id.short()),
1838 Vec::new(),
1839 None,
1840 None,
1841 None,
1842 )
1843}
1844
1845pub fn prepare_dir_for_file_replacement(path: &Path) -> Result<()> {
1863 match fs::remove_dir(path) {
1864 Ok(()) => Ok(()),
1865 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1866 Err(error) if objects::fs_atomic::is_directory_not_empty(&error) => {
1867 let surviving = list_surviving_entries(path)
1868 .unwrap_or_else(|_| vec!["<unable to list>".to_string()]);
1869 let display = if surviving.is_empty() {
1870 "<unknown ignored content>".to_string()
1871 } else {
1872 surviving.join(", ")
1873 };
1874 Err(anyhow!(
1875 "cannot replace directory {} with a file: contains heddle-ignored content ({}) — move or delete those files manually first",
1876 path.display(),
1877 display
1878 ))
1879 }
1880 Err(error) => {
1881 Err(anyhow::Error::from(error)
1882 .context(format!("removing directory {}", path.display())))
1883 }
1884 }
1885}
1886
1887fn list_surviving_entries(path: &Path) -> std::io::Result<Vec<String>> {
1888 let mut names = Vec::new();
1889 for entry in fs::read_dir(path)? {
1890 let entry = entry?;
1891 if let Some(s) = entry.file_name().to_str() {
1892 names.push(s.to_string());
1893 } else {
1894 names.push(entry.file_name().to_string_lossy().into_owned());
1895 }
1896 }
1897 names.sort();
1898 Ok(names)
1899}
1900
1901pub fn bench_find_merge_base(
1902 repo: &Repository,
1903 state_a: &StateId,
1904 state_b: &StateId,
1905) -> Result<Option<StateId>> {
1906 find_merge_base(repo, state_a, state_b)
1907}
1908
1909pub enum ThreeWayMergeOutcome {
1911 Clean {
1914 tree: Tree,
1915 },
1916 Conflicted {
1919 tree: Tree,
1920 paths: Vec<String>,
1921 base: StateId,
1922 },
1923 AlreadyIntegrated {
1927 target: StateId,
1928 },
1929 FastForward {
1930 target: StateId,
1931 },
1932}
1933
1934pub fn try_three_way_merge_between_tips(
1943 parent_repo: &Repository,
1944 current_tip: &StateId,
1945 target_tip: &StateId,
1946 labels: ConflictLabels<'_>,
1947) -> Result<ThreeWayMergeOutcome> {
1948 let mut graph = CommitGraphIndex::new(parent_repo);
1949 let plan =
1950 MergePlan::for_merge_command(parent_repo, &mut graph, current_tip, target_tip, labels)?;
1951 match plan.relation().kind() {
1952 MergeRelationKind::AlreadyUpToDate => Ok(ThreeWayMergeOutcome::AlreadyIntegrated {
1953 target: *target_tip,
1954 }),
1955 MergeRelationKind::FastForward => Ok(ThreeWayMergeOutcome::FastForward {
1956 target: *target_tip,
1957 }),
1958 MergeRelationKind::CleanApply => {
1959 let merge_result = plan
1960 .merge_result()
1961 .ok_or_else(|| anyhow!("Merge plan missing merge_result for CleanApply"))?;
1962 Ok(ThreeWayMergeOutcome::Clean {
1963 tree: merge_result.tree.clone(),
1964 })
1965 }
1966 MergeRelationKind::Conflicted | MergeRelationKind::AlreadyIntegrated => {
1967 let merge_result = plan
1968 .merge_result()
1969 .ok_or_else(|| anyhow!("Merge plan missing merge_result for Conflicted"))?;
1970 let base = plan
1971 .relation()
1972 .merge_base_id()
1973 .ok_or_else(|| anyhow!("Merge base missing from conflicted merge plan"))?;
1974 Ok(ThreeWayMergeOutcome::Conflicted {
1975 tree: merge_result.tree.clone(),
1976 paths: merge_result.conflicts.clone(),
1977 base,
1978 })
1979 }
1980 }
1981}
1982
1983pub fn apply_merged_tree_external(repo: &Repository, tree: &Tree) -> Result<()> {
1988 apply_merged_tree(repo, tree)
1989}
1990
1991pub fn bench_three_way_merge(
1992 repo: &Repository,
1993 base_tree: &Tree,
1994 our_tree: &Tree,
1995 their_tree: &Tree,
1996) -> Result<(Tree, usize, usize, usize)> {
1997 let blob_source = RepositoryMergeBlobSource { repo };
1998 let result = merge_trees(
1999 repo.store(),
2000 &blob_source,
2001 base_tree,
2002 our_tree,
2003 their_tree,
2004 tree_merge_options(ConflictLabels::DEFAULT),
2005 )
2006 .map_err(map_tree_merge_error)?;
2007 Ok((
2008 result.tree,
2009 result.conflicts.len(),
2010 result.renames.len(),
2011 result.directory_renames.len(),
2012 ))
2013}
2014
2015pub fn bench_detect_renames(
2016 store: &impl ObjectStore,
2017 base_tree: &Tree,
2018 branch_tree: &Tree,
2019) -> Result<(usize, RenameMatcherStats)> {
2020 let detection = detect_renames_between_trees(store, base_tree, branch_tree, rename_options())?;
2021 Ok((detection.renames.len(), detection.stats))
2022}
2023
2024fn fast_forward_renames(
2025 repo: &Repository,
2026 from: &StateId,
2027 to: &StateId,
2028) -> Result<(Vec<RenameEntry>, Vec<RenameEntry>)> {
2029 let from_tree = load_state_tree(repo, from)?;
2030 let to_tree = load_state_tree(repo, to)?;
2031 let detection =
2032 detect_renames_between_trees(repo.store(), &from_tree, &to_tree, rename_options())?;
2033
2034 let renames: Vec<RenameEntry> = detection
2035 .renames
2036 .into_iter()
2037 .map(|rename| RenameEntry {
2038 from: rename.from,
2039 to: rename.to,
2040 score: rename.score,
2041 })
2042 .collect();
2043
2044 let directory_renames: Vec<RenameEntry> = detection
2045 .directory_renames
2046 .into_iter()
2047 .map(|rename| RenameEntry {
2048 from: rename.from,
2049 to: rename.to,
2050 score: 1.0,
2051 })
2052 .collect();
2053
2054 Ok((renames, directory_renames))
2055}
2056
2057fn rename_options() -> RenameOptions {
2058 RenameOptions {
2059 semantic_similarity: semantic_similarity_hook(),
2060 ..RenameOptions::default()
2061 }
2062}
2063
2064fn load_state_tree(repo: &Repository, state_id: &StateId) -> Result<Tree> {
2065 let state = repo
2066 .store()
2067 .get_state(state_id)?
2068 .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
2069 repo.store().get_tree(&state.tree)?.ok_or_else(|| {
2070 anyhow!(
2071 "State '{}' references missing tree {}",
2072 state_id.short(),
2073 state.tree
2074 )
2075 })
2076}
2077
2078pub fn build_thread_preview_report(
2079 repo: &Repository,
2080 thread: &mut Thread,
2081 prefer_apply_recommendation: bool,
2082) -> Result<ThreadPreviewReport> {
2083 let mut graph = CommitGraphIndex::new(repo);
2084 build_thread_preview_report_with_graph(
2089 repo,
2090 &mut graph,
2091 thread,
2092 prefer_apply_recommendation,
2093 merge_strategy_for(semantic_merge_enabled(false)),
2094 None,
2095 )
2096}
2097
2098pub struct PreviewTarget<'a> {
2107 pub label: &'a str,
2108 pub state_id: StateId,
2109}
2110
2111fn build_thread_preview_report_with_graph(
2112 repo: &Repository,
2113 graph: &mut CommitGraphIndex<'_>,
2114 thread: &mut Thread,
2115 prefer_apply_recommendation: bool,
2116 strategy: MergeStrategy,
2117 target_override: Option<PreviewTarget<'_>>,
2118) -> Result<ThreadPreviewReport> {
2119 refresh_thread_freshness(repo, thread)?;
2120 let mut conflicts = Vec::new();
2121 let resolved_target: Option<(String, StateId)> = if let Some(ovr) = target_override {
2126 Some((ovr.label.to_string(), ovr.state_id))
2127 } else if let Some(name) = thread.target_thread.as_deref() {
2128 let id = repo
2129 .refs()
2130 .get_thread(&ThreadName::new(name))?
2131 .ok_or_else(|| anyhow!(advice::thread_not_found(name, "merge preview")))?;
2132 Some((name.to_string(), id))
2133 } else {
2134 None
2135 };
2136
2137 let mut preview_changed_paths: Option<Vec<String>> = None;
2138 let merge_relation = if let Some((target_label, target_id)) = resolved_target {
2139 let thread_id = repo
2140 .refs()
2141 .get_thread(&ThreadName::new(&thread.thread))?
2142 .ok_or_else(|| anyhow!(advice::thread_not_found(&thread.thread, "merge preview")))?;
2143 let current_label = format!("CURRENT ({target_label})");
2144 let incoming_label = format!("INCOMING ({})", thread.thread);
2145 let merge_plan = MergePlan::for_thread_preview(
2146 repo,
2147 graph,
2148 &target_id,
2149 &thread_id,
2150 ConflictLabels {
2151 current: ¤t_label,
2152 incoming: &incoming_label,
2153 strategy,
2154 },
2155 )?;
2156 if let Some(merge_result) = merge_plan.merge_result() {
2157 conflicts = merge_result.conflicts.clone();
2158 }
2159 let merge_relation = merge_plan.relation().as_json_value().to_string();
2160 if merge_relation != "already_integrated" {
2161 preview_changed_paths = Some(merge_changed_paths(repo, &target_id, &thread_id)?);
2162 }
2163 merge_relation
2164 } else {
2165 "no_target".to_string()
2166 };
2167
2168 let mut advice =
2169 describe_thread_advice(thread, false, conflicts.len(), prefer_apply_recommendation);
2170 if merge_relation == "already_integrated" {
2171 advice.blockers.clear();
2172 advice.recommended_action.clear();
2173 advice.thread_health = "clean".to_string();
2174 }
2175
2176 let thread_tip = repo
2177 .refs()
2178 .get_thread(&ThreadName::new(&thread.thread))?
2179 .map(|id| id.short());
2180 let manual_resolution_current = thread
2181 .integration_policy_result
2182 .manual_resolution_state
2183 .as_deref()
2184 .zip(thread_tip.as_deref())
2185 .is_some_and(|(resolved, current)| resolved == current);
2186 let conflict_count = if manual_resolution_current {
2187 0
2188 } else {
2189 conflicts.len()
2190 };
2191 let conflicts = if manual_resolution_current {
2192 Vec::new()
2193 } else {
2194 conflicts
2195 };
2196 if manual_resolution_current {
2197 advice.blockers.clear();
2198 advice.recommended_action = land_command_for_thread(repo, &thread.id);
2199 advice.thread_health = "ready".to_string();
2200 }
2201
2202 let recommended_action = advice.recommended_action;
2203 let all_changed_paths = preview_changed_paths.unwrap_or_else(|| thread.changed_paths.clone());
2204 let changed_path_count = all_changed_paths.len();
2205 let changed_paths = all_changed_paths.into_iter().take(8).collect();
2206 Ok(ThreadPreviewReport {
2207 thread: thread.id.clone(),
2208 thread_mode: thread.mode.to_string(),
2209 thread_state: thread.state.to_string(),
2210 freshness: thread.freshness.to_string(),
2211 task: thread.task.clone(),
2212 changed_paths,
2213 changed_path_count,
2214 impact_categories: thread
2215 .impact_categories
2216 .iter()
2217 .map(ToString::to_string)
2218 .collect(),
2219 heavy_impact_paths: thread.heavy_impact_paths.clone(),
2220 merge_relation,
2221 conflict_count,
2222 conflicts,
2223 blockers: advice.blockers,
2224 recommended_action_template: action_template(&recommended_action),
2225 recommended_action,
2226 thread_health: advice.thread_health,
2227 })
2228}
2229
2230fn merge_output_from_report(input: MergeReportInput<'_>) -> Result<MergeReport> {
2231 let report_conflicts = input.conflicts.unwrap_or_default();
2232 let diff_changed_paths = input.diff.as_ref().map(diff_changed_paths);
2233 let changed_paths = if let Some(paths) = input.changed_paths {
2234 paths
2235 } else if let Some(thread) = input.thread.as_ref() {
2236 let paths = thread.changed_paths.clone();
2237 if paths.is_empty() {
2238 diff_changed_paths.unwrap_or(paths)
2239 } else {
2240 paths
2241 }
2242 } else {
2243 diff_changed_paths.unwrap_or_default()
2244 };
2245 let changed_path_count = changed_paths.len();
2246 let preview_blockers = input
2260 .preview_report
2261 .map(|report| report.blockers.clone())
2262 .unwrap_or_default();
2263 let preview_warnings: Vec<String> = preview_blockers
2264 .iter()
2265 .filter(|item| !is_real_merge_blocker(item))
2266 .cloned()
2267 .collect();
2268 let mut real_blockers: Vec<String> = if report_conflicts.is_empty() {
2271 Vec::new()
2272 } else {
2273 vec![format!(
2274 "{} path conflict(s) need manual resolution",
2275 report_conflicts.len()
2276 )]
2277 };
2278 real_blockers.extend(input.extra_blockers.iter().cloned());
2279
2280 let status = if !real_blockers.is_empty() {
2281 "blocked"
2282 } else {
2283 "completed"
2284 };
2285 let stale_refresh_action = input.preview_report.and_then(|report| {
2286 (report.freshness == ThreadFreshness::Stale.to_string()).then(|| {
2287 if report.recommended_action.trim().is_empty() {
2288 format!(
2289 "heddle sync --thread {}",
2290 recommended_action_quote(&report.thread)
2291 )
2292 } else {
2293 report.recommended_action.clone()
2294 }
2295 })
2296 });
2297 let recommended_action: Option<String> = if !report_conflicts.is_empty() {
2298 if input.preview_only {
2302 None
2303 } else {
2304 Some("heddle continue".to_string())
2305 }
2306 } else if !input.extra_blockers.is_empty() {
2307 Some(coordination_blocker_recommended_action(
2317 input.merge_state.as_ref(),
2318 ))
2319 } else if input.preview_only
2320 && input.message != "Already up to date"
2321 && stale_refresh_action.is_some()
2322 {
2323 stale_refresh_action
2324 } else if input.preview_only && input.message != "Already up to date" {
2325 input.thread.as_ref().map(|t| land_local_command(&t.id))
2330 } else {
2331 None
2333 };
2334 let meaningful_merge = status == "completed" && input.message != "Already up to date";
2335 let would_merge = input.preview_only && meaningful_merge;
2336 let applied = !input.preview_only && meaningful_merge;
2337 Ok(MergeReport {
2338 operator: OperatorCommandOutput {
2339 status: status.to_string(),
2340 action: OperatorAction::Merge,
2341 message: input.message,
2342 blockers: real_blockers,
2343 warnings: preview_warnings,
2344 next_action: recommended_action.clone(),
2345 recommended_action: recommended_action.clone(),
2346 },
2347 would_merge,
2348 applied,
2349 fast_forward: input.fast_forward,
2350 preview_only: input.preview_only,
2351 merge_state: input.merge_state,
2352 conflicts: report_conflicts.clone(),
2353 preview_summary: input.preview_summary,
2354 thread_state: input.thread.as_ref().map(|thread| thread.state.to_string()),
2355 freshness: input
2356 .thread
2357 .as_ref()
2358 .map(|thread| thread.freshness.to_string()),
2359 changed_paths,
2360 changed_path_count,
2361 impact_categories: thread_impacts(input.thread),
2362 promotion_suggested: input
2363 .thread
2364 .as_ref()
2365 .map(|thread| thread.promotion_suggested)
2366 .unwrap_or(false),
2367 heavy_impact_paths: thread_heavy_paths(input.thread),
2368 merge_relation: input.merge_relation.or_else(|| {
2369 input
2370 .preview_report
2371 .map(|report| report.merge_relation.clone())
2372 }),
2373 conflict_count: input
2374 .conflict_count
2375 .or_else(|| input.preview_report.map(|report| report.conflict_count))
2376 .unwrap_or(report_conflicts.len()),
2377 thread_health: merge_output_thread_health(input.thread.as_ref(), input.preview_report),
2378 renames: input.renames,
2379 directory_renames: input.directory_renames,
2380 semantic_changes: input.semantic_changes,
2381 diff: input.diff,
2382 git_commit_preview: input.git_commit_preview,
2383 git_commit: input.git_commit,
2384 trust: Some(merge_output_trust(
2385 input.repo,
2386 input.machine_contract,
2387 recommended_action.as_deref(),
2388 )?),
2389 })
2390}
2391
2392fn diff_changed_paths(diff: &DiffReport) -> Vec<String> {
2393 diff.changes
2394 .iter()
2395 .map(|change| change.path.clone())
2396 .collect()
2397}
2398
2399fn diff_with_known_renames(diff: DiffReport, renames: &[RenameEntry]) -> DiffReport {
2400 if renames.is_empty() {
2401 return diff;
2402 }
2403 let DiffReport {
2404 from_state,
2405 to_state,
2406 changes: original_changes,
2407 semantic_changes,
2408 context,
2409 broader_guidance,
2410 ..
2411 } = diff;
2412 let rename_by_new = renames
2413 .iter()
2414 .map(|rename| (rename.to.as_str(), rename.from.as_str()))
2415 .collect::<std::collections::BTreeMap<_, _>>();
2416 let removed_old = renames
2417 .iter()
2418 .map(|rename| rename.from.as_str())
2419 .collect::<std::collections::BTreeSet<_>>();
2420 let mut changes = Vec::with_capacity(original_changes.len());
2421 for mut change in original_changes {
2422 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2423 continue;
2424 }
2425 if change.kind == "added"
2426 && let Some(old_path) = rename_by_new.get(change.path.as_str())
2427 {
2428 change.kind = "renamed".to_string();
2429 change.old_path = Some((*old_path).to_string());
2430 }
2431 changes.push(change);
2432 }
2433 DiffReport::new(
2434 from_state,
2435 to_state,
2436 changes,
2437 semantic_changes,
2438 context,
2439 broader_guidance,
2440 )
2441}
2442
2443fn merge_output_thread_health(
2444 thread: Option<&Thread>,
2445 preview_report: Option<&ThreadPreviewReport>,
2446) -> String {
2447 match thread.map(|thread| &thread.state) {
2448 Some(ThreadState::Merged | ThreadState::Abandoned) => "clean".to_string(),
2449 Some(ThreadState::Blocked) => "blocked".to_string(),
2450 Some(ThreadState::Ready) => "ready".to_string(),
2451 Some(ThreadState::Draft | ThreadState::Active | ThreadState::Promoted) | None => {
2452 preview_report
2453 .map(|report| report.thread_health.clone())
2454 .unwrap_or_else(|| "active".to_string())
2455 }
2456 }
2457}
2458
2459fn coordination_blocker_recommended_action(merge_state: Option<&String>) -> String {
2460 if merge_state.is_some() {
2461 "heddle capture -m \"...\"".to_string()
2462 } else {
2463 "heddle status".to_string()
2464 }
2465}
2466
2467fn merge_output_trust(
2468 repo: &Repository,
2469 machine_contract: &MachineContractInput,
2470 recommended_action: Option<&str>,
2471) -> Result<RepositoryVerificationState> {
2472 let mut trust = trust_state(repo, machine_contract)?;
2473 if let Some(action) = recommended_action {
2474 override_trust_recommended_action(&mut trust, action);
2475 }
2476 Ok(trust)
2477}
2478
2479fn worktree_status_options(config: Option<&repo::RepoConfig>) -> repo::WorktreeStatusOptions {
2480 repo::resolve_worktree_status_options(None, config)
2481}
2482
2483fn worktree_dirty(repo: &Repository, options: &repo::WorktreeStatusOptions) -> Result<bool> {
2484 if repo.current_state()?.is_none()
2485 && let Some(status) = repo.git_overlay_worktree_status()?
2486 {
2487 return Ok(!status.is_clean());
2488 }
2489 let tree = match repo.current_state()? {
2490 Some(state) => repo.require_tree(&state.tree)?,
2491 None => Tree::new(),
2492 };
2493 let status = repo.compare_worktree_cached_with_options(&tree, options)?;
2494 Ok(!status.is_clean())
2495}
2496
2497fn worktree_dirty_paths(
2498 repo: &Repository,
2499 options: &repo::WorktreeStatusOptions,
2500) -> Result<Vec<String>> {
2501 let status = if repo.current_state()?.is_none()
2502 && let Some(status) = repo.git_overlay_worktree_status()?
2503 {
2504 status
2505 } else {
2506 let tree = match repo.current_state()? {
2507 Some(state) => repo.require_tree(&state.tree)?,
2508 None => Tree::new(),
2509 };
2510 repo.compare_worktree_cached_with_options(&tree, options)?
2511 };
2512
2513 let mut paths = Vec::new();
2514 paths.extend(status.modified);
2515 paths.extend(status.added);
2516 paths.extend(status.deleted);
2517 paths.sort();
2518 paths.dedup();
2519 Ok(paths
2520 .into_iter()
2521 .map(|path| path.display().to_string())
2522 .collect())
2523}
2524
2525fn source_thread_uncaptured_work(
2526 target_repo: &Repository,
2527 thread: &Thread,
2528) -> Result<Option<SourceThreadUncapturedWork>> {
2529 if thread.execution_path.as_os_str().is_empty()
2530 || thread.execution_path == *target_repo.root()
2531 || !thread.execution_path.exists()
2532 || !thread.execution_path.join(".heddle").exists()
2533 {
2534 return Ok(None);
2535 }
2536
2537 let source_repo = Repository::open(&thread.execution_path)?;
2538 let options = worktree_status_options(Some(source_repo.config()));
2539 if !worktree_dirty(&source_repo, &options)? {
2540 return Ok(None);
2541 }
2542
2543 Ok(Some(SourceThreadUncapturedWork {
2544 checkout_path: thread.execution_path.display().to_string(),
2545 dirty_paths: worktree_dirty_paths(&source_repo, &options)?,
2546 }))
2547}
2548
2549#[allow(dead_code)] fn uncaptured_path_summary(paths: &[String]) -> String {
2551 if paths.is_empty() {
2552 return "uncaptured worktree paths".to_string();
2553 }
2554 let shown = paths
2555 .iter()
2556 .take(12)
2557 .cloned()
2558 .collect::<Vec<_>>()
2559 .join(", ");
2560 let overflow = paths.len().saturating_sub(12);
2561 if overflow == 0 {
2562 format!("uncaptured path(s): {shown}")
2563 } else {
2564 format!("uncaptured path(s): {shown}, and {overflow} more")
2565 }
2566}
2567
2568fn recommended_action_quote(value: &str) -> String {
2569 let safe = !value.is_empty()
2570 && value
2571 .bytes()
2572 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
2573 if safe {
2574 value.to_string()
2575 } else {
2576 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
2577 }
2578}
2579
2580fn merge_blocked_by_trust_output(
2581 thread: &Option<Thread>,
2582 preview_report: Option<&ThreadPreviewReport>,
2583 trust: RepositoryVerificationState,
2584 preview_only: bool,
2585 merge_relation: Option<String>,
2586) -> MergeReport {
2587 MergeReport {
2588 operator: OperatorCommandOutput::blocked_by_repository_verification(
2589 OperatorAction::Merge,
2590 trust_blocked_merge_message(&trust, preview_only),
2591 &trust,
2592 ),
2593 would_merge: false,
2594 applied: false,
2595 fast_forward: false,
2596 preview_only,
2597 merge_state: None,
2598 conflicts: Vec::new(),
2599 preview_summary: Vec::new(),
2600 thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
2601 freshness: thread.as_ref().map(|thread| thread.freshness.to_string()),
2602 changed_paths: thread_paths(thread),
2603 changed_path_count: thread_path_count(thread),
2604 impact_categories: thread_impacts(thread),
2605 promotion_suggested: thread
2606 .as_ref()
2607 .map(|thread| thread.promotion_suggested)
2608 .unwrap_or(false),
2609 heavy_impact_paths: thread_heavy_paths(thread),
2610 merge_relation: merge_relation
2611 .or_else(|| preview_report.map(|report| report.merge_relation.clone())),
2612 conflict_count: 0,
2613 thread_health: trust.status.clone(),
2614 renames: Vec::new(),
2615 directory_renames: Vec::new(),
2616 semantic_changes: None,
2617 diff: None,
2618 git_commit_preview: None,
2619 git_commit: None,
2620 trust: Some(trust),
2621 }
2622}
2623
2624fn merge_freshness_preflight_output(
2625 repo: &Repository,
2626 machine_contract: &MachineContractInput,
2627 thread: &Option<Thread>,
2628 preview_report: Option<&ThreadPreviewReport>,
2629 preview_only: bool,
2630) -> Result<Option<MergeReport>> {
2631 if thread
2632 .as_ref()
2633 .is_some_and(|thread| thread.state == ThreadState::Merged)
2634 {
2635 return Ok(None);
2636 }
2637 let Some(report) =
2638 preview_report.filter(|report| report.freshness == ThreadFreshness::Stale.to_string())
2639 else {
2640 return Ok(None);
2641 };
2642 Ok(Some(stale_thread_merge_blocked_output(
2643 repo,
2644 machine_contract,
2645 thread,
2646 report,
2647 preview_only,
2648 )?))
2649}
2650
2651fn stale_thread_merge_blocked_output(
2652 repo: &Repository,
2653 machine_contract: &MachineContractInput,
2654 thread: &Option<Thread>,
2655 preview_report: &ThreadPreviewReport,
2656 preview_only: bool,
2657) -> Result<MergeReport> {
2658 let recommended_action = if preview_report.recommended_action.trim().is_empty() {
2659 format!(
2660 "heddle sync --thread {}",
2661 recommended_action_quote(&preview_report.thread)
2662 )
2663 } else {
2664 preview_report.recommended_action.clone()
2665 };
2666 let blockers = if preview_report.blockers.is_empty() {
2667 vec![format!(
2668 "Thread '{}' is stale against '{}'",
2669 preview_report.thread,
2670 thread
2671 .as_ref()
2672 .and_then(|thread| thread.target_thread.as_deref())
2673 .unwrap_or("its target thread")
2674 )]
2675 } else {
2676 preview_report.blockers.clone()
2677 };
2678 let conflict_suffix = if preview_report.conflict_count > 0 {
2679 format!(
2680 " and has {} path conflict(s)",
2681 preview_report.conflict_count
2682 )
2683 } else {
2684 String::new()
2685 };
2686
2687 Ok(MergeReport {
2688 operator: OperatorCommandOutput {
2689 status: "blocked".to_string(),
2690 action: OperatorAction::Merge,
2691 message: format!(
2692 "Thread '{}' is stale{}; merge {}did not run",
2693 preview_report.thread,
2694 conflict_suffix,
2695 if preview_only { "preview " } else { "" }
2696 ),
2697 blockers,
2698 warnings: Vec::new(),
2699 next_action: Some(recommended_action.clone()),
2700 recommended_action: Some(recommended_action.clone()),
2701 },
2702 would_merge: false,
2703 applied: false,
2704 fast_forward: false,
2705 preview_only,
2706 merge_state: None,
2707 conflicts: preview_report.conflicts.clone(),
2708 preview_summary: build_stale_preview_summary(preview_report),
2709 thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
2710 freshness: Some(preview_report.freshness.clone()),
2711 changed_paths: preview_report.changed_paths.clone(),
2712 changed_path_count: preview_report.changed_path_count,
2713 impact_categories: preview_report.impact_categories.clone(),
2714 promotion_suggested: !preview_report.heavy_impact_paths.is_empty(),
2715 heavy_impact_paths: preview_report.heavy_impact_paths.clone(),
2716 merge_relation: Some(preview_report.merge_relation.clone()),
2717 conflict_count: preview_report.conflict_count,
2718 thread_health: "blocked".to_string(),
2719 renames: Vec::new(),
2720 directory_renames: Vec::new(),
2721 semantic_changes: None,
2722 diff: None,
2723 git_commit_preview: None,
2724 git_commit: None,
2725 trust: Some(merge_output_trust(
2726 repo,
2727 machine_contract,
2728 Some(&recommended_action),
2729 )?),
2730 })
2731}
2732
2733fn override_trust_recommended_action(
2734 trust: &mut RepositoryVerificationState,
2735 action: impl Into<String>,
2736) {
2737 let action = action.into();
2738 trust.recommended_action_template = action_template(&action);
2739 trust.recommended_action = action.clone();
2740 if let Some(check) = trust
2741 .checks
2742 .iter_mut()
2743 .find(|check| check.name == "Workflow")
2744 {
2745 check.recommended_action_template = action_template(&action);
2746 check.recommended_action = Some(action);
2747 }
2748}
2749
2750fn trust_blocks_merge_preview(trust: &RepositoryVerificationState) -> bool {
2751 trust
2752 .checks
2753 .iter()
2754 .any(|check| !check.clean && matches!(check.name.as_str(), "Mapping" | "Operation"))
2755}
2756
2757fn trust_blocked_merge_message(trust: &RepositoryVerificationState, preview_only: bool) -> String {
2758 if preview_only {
2759 format!(
2760 "Repository verification is blocked; merge preview did not run: {}",
2761 trust.summary
2762 )
2763 } else {
2764 format!(
2765 "Repository verification is blocked; merge did not run: {}",
2766 trust.summary
2767 )
2768 }
2769}
2770
2771fn preview_list(paths: &[String], total: usize) -> String {
2772 const LIMIT: usize = 5;
2773 if paths.is_empty() {
2774 return "none".to_string();
2775 }
2776 let shown = paths
2777 .iter()
2778 .take(LIMIT)
2779 .cloned()
2780 .collect::<Vec<_>>()
2781 .join(", ");
2782 if total > LIMIT {
2783 format!("{shown} (+{} more)", total - LIMIT)
2784 } else {
2785 shown
2786 }
2787}
2788
2789fn is_real_merge_blocker(advisory: &str) -> bool {
2790 let lower = advisory.to_lowercase();
2791 lower.contains("path conflict")
2792}
2793
2794fn thread_paths(thread: &Option<Thread>) -> Vec<String> {
2795 thread
2796 .as_ref()
2797 .map(|thread| thread.changed_paths.clone())
2798 .unwrap_or_default()
2799}
2800
2801fn thread_path_count(thread: &Option<Thread>) -> usize {
2802 thread
2803 .as_ref()
2804 .map(|thread| thread.changed_paths.len())
2805 .unwrap_or(0)
2806}
2807
2808fn thread_impacts(thread: &Option<Thread>) -> Vec<String> {
2809 thread
2810 .as_ref()
2811 .map(|thread| {
2812 thread
2813 .impact_categories
2814 .iter()
2815 .map(ToString::to_string)
2816 .collect::<Vec<_>>()
2817 })
2818 .unwrap_or_default()
2819}
2820
2821fn thread_heavy_paths(thread: &Option<Thread>) -> Vec<String> {
2822 thread
2823 .as_ref()
2824 .map(|thread| thread.heavy_impact_paths.clone())
2825 .unwrap_or_default()
2826}
2827
2828fn build_preview_summary(report: Option<&ThreadPreviewReport>) -> Vec<String> {
2829 let mut lines = Vec::new();
2830 if let Some(report) = report {
2831 let real_blockers = report
2832 .blockers
2833 .iter()
2834 .filter(|blocker| is_real_merge_blocker(blocker))
2835 .cloned()
2836 .collect::<Vec<_>>();
2837 if !real_blockers.is_empty() {
2838 lines.push(format!("blocked: {}", real_blockers.join("; ")));
2839 }
2840 lines.push(format!(
2841 "checkout: {}",
2842 thread_mode_summary(&report.thread_mode)
2843 ));
2844 lines.push(format!("sync: {}", report.freshness));
2845 if let Some(task) = &report.task {
2846 lines.push(format!("task: {}", task));
2847 }
2848 if !report.changed_paths.is_empty() {
2849 lines.push(format!(
2850 "changed paths: {}",
2851 report.changed_paths.join(", ")
2852 ));
2853 }
2854 if !report.impact_categories.is_empty() {
2855 lines.push(format!(
2856 "impact categories: {}",
2857 report.impact_categories.join(", ")
2858 ));
2859 }
2860 if !report.heavy_impact_paths.is_empty() {
2861 lines.push(format!(
2862 "heavy-impact change: {} — review broader impact before merging",
2863 preview_list(&report.heavy_impact_paths, report.heavy_impact_paths.len(),)
2864 ));
2865 }
2866 lines.push(format!(
2867 "merge type: {}",
2868 merge_relation_summary(&report.merge_relation)
2869 ));
2870 if report.conflict_count > 0 {
2871 lines.push(format!(
2872 "conflicts: {} path conflict(s)",
2873 report.conflict_count
2874 ));
2875 }
2876 }
2877 lines
2878}
2879
2880fn build_stale_preview_summary(report: &ThreadPreviewReport) -> Vec<String> {
2881 let mut lines = Vec::new();
2882 if !report.blockers.is_empty() {
2883 lines.push(format!("blocked: {}", report.blockers.join("; ")));
2884 }
2885 lines.push(format!(
2886 "checkout: {}",
2887 thread_mode_summary(&report.thread_mode)
2888 ));
2889 lines.push(format!("sync: {}", report.freshness));
2890 if let Some(task) = &report.task {
2891 lines.push(format!("task: {}", task));
2892 }
2893 if !report.changed_paths.is_empty() {
2894 lines.push(format!(
2895 "changed paths: {}",
2896 report.changed_paths.join(", ")
2897 ));
2898 }
2899 if !report.impact_categories.is_empty() {
2900 lines.push(format!(
2901 "impact categories: {}",
2902 report.impact_categories.join(", ")
2903 ));
2904 }
2905 if !report.heavy_impact_paths.is_empty() {
2906 lines.push(format!(
2907 "heavy-impact change: {} — review broader impact before merging",
2908 preview_list(&report.heavy_impact_paths, report.heavy_impact_paths.len(),)
2909 ));
2910 }
2911 lines.push(format!(
2912 "merge type: {}",
2913 merge_relation_summary(&report.merge_relation)
2914 ));
2915 if report.conflict_count > 0 {
2916 lines.push(format!(
2917 "conflicts: {} path conflict(s)",
2918 report.conflict_count
2919 ));
2920 }
2921 lines
2922}
2923
2924fn thread_mode_summary(mode: &str) -> &str {
2925 match mode {
2926 "solid" => "main checkout",
2927 "materialized" => "disk checkout",
2928 "virtualized" => "virtual checkout",
2929 other => other,
2930 }
2931}
2932
2933fn merge_relation_summary(result: &str) -> String {
2934 result.replace('_', "-")
2935}
2936
2937#[cfg(test)]
2938mod tests {
2939 use super::*;
2940
2941 #[test]
2961 fn merge_strategy_is_decided_once_preview_equals_apply() {
2962 for no_semantic in [false, true] {
2964 let plan = MergeAttemptPlan::decide(no_semantic);
2965 let semantic_active = plan.strategy() == MergeStrategy::Semantic;
2966 assert_eq!(
2967 semantic_active,
2968 plan.use_semantic(),
2969 "MergeAttemptPlan strategy and use_semantic must agree (no_semantic={no_semantic})"
2970 );
2971 assert_eq!(
2972 plan.strategy(),
2973 merge_strategy_for(semantic_merge_enabled(no_semantic)),
2974 "decide() must select the same strategy the legacy derivation would"
2975 );
2976 }
2977
2978 let source = include_str!("mod.rs");
2985 let body = source
2986 .split_once("pub fn merge_thread_into_current_with_machine_contract(")
2987 .expect("merge_thread_into_current_with_machine_contract must exist")
2988 .1
2989 .split_once("\nfn mark_merge_previewed(")
2990 .expect(
2991 "merge_thread_into_current_with_machine_contract must be delimited by mark_merge_previewed",
2992 )
2993 .0;
2994 let decide_calls = body.matches("MergeAttemptPlan::decide(").count();
2995 assert_eq!(
2996 decide_calls, 1,
2997 "merge_thread_into_current must decide the merge strategy exactly once \
2998 (found {decide_calls} MergeAttemptPlan::decide call sites)"
2999 );
3000 assert!(
3001 !body.contains("merge_strategy_for(use_semantic)"),
3002 "preview and apply must consume the single MergeAttemptPlan, not re-derive \
3003 the strategy via merge_strategy_for(use_semantic)"
3004 );
3005 }
3006
3007 #[test]
3008 fn merge_in_progress_refusal_uses_typed_recovery_advice() {
3009 let err = advice::merge_already_in_progress();
3010 let objects::HeddleError::Recovery(details) = err else {
3011 panic!("expected recovery error");
3012 };
3013
3014 assert_eq!(details.kind, "merge_already_in_progress");
3015 assert!(details.error.contains("merge is already in progress"));
3016 assert!(details.hint.contains("heddle continue"));
3017 assert!(details.preserved.contains("left unchanged"));
3018 }
3019
3020 #[test]
3025 fn prepare_dir_for_file_replacement_removes_empty_directory() {
3026 let dir = tempfile::TempDir::new().unwrap();
3027 let target = dir.path().join("entry");
3028 fs::create_dir(&target).unwrap();
3029
3030 prepare_dir_for_file_replacement(&target).expect("empty dir is removable");
3031
3032 assert!(
3033 !target.exists(),
3034 "empty directory must be removed so a file can take its place"
3035 );
3036 }
3037
3038 #[test]
3044 fn prepare_dir_for_file_replacement_errors_on_non_empty_directory() {
3045 let dir = tempfile::TempDir::new().unwrap();
3046 let target = dir.path().join("entry");
3047 fs::create_dir(&target).unwrap();
3048 fs::create_dir(target.join("node_modules")).unwrap();
3052 fs::write(target.join("node_modules").join("dep.js"), "ignored").unwrap();
3053
3054 let err = prepare_dir_for_file_replacement(&target)
3055 .expect_err("non-empty dir must error rather than silently delete");
3056 let msg = err.to_string();
3057 assert!(
3058 msg.contains("cannot replace directory"),
3059 "missing 'cannot replace directory' phrase: {msg}"
3060 );
3061 assert!(
3062 msg.contains("heddle-ignored content"),
3063 "missing 'heddle-ignored content' phrase: {msg}"
3064 );
3065 assert!(
3066 msg.contains("node_modules"),
3067 "error must list the offending entry: {msg}"
3068 );
3069 assert!(
3073 target.join("node_modules").join("dep.js").exists(),
3074 "ignored content must NOT be deleted by the failure path"
3075 );
3076 }
3077
3078 #[test]
3081 fn prepare_dir_for_file_replacement_tolerates_missing_path() {
3082 let dir = tempfile::TempDir::new().unwrap();
3083 let target = dir.path().join("entry");
3084 prepare_dir_for_file_replacement(&target).expect("missing dir is a no-op, not an error");
3087 }
3088
3089 #[test]
3095 fn extended_validation_does_not_require_git_cli_identity() {
3096 use std::process::Command;
3097
3098 let dir = tempfile::TempDir::new().unwrap();
3099 let status = Command::new("git")
3101 .arg("-C")
3102 .arg(dir.path())
3103 .args(["init", "--quiet"])
3104 .status()
3105 .expect("git must be on PATH for the native Git validation test");
3106 assert!(
3107 status.success(),
3108 "git init must succeed for the test fixture"
3109 );
3110 let blockers =
3111 validate_git_commit_preconditions_extended(dir.path(), &["dummy.txt".to_string()]);
3112 assert!(
3113 blockers.is_empty(),
3114 "native Git commit writing should not require a Git CLI/config identity; Heddle can author from captured principal: {blockers:?}"
3115 );
3116 }
3117
3118 #[test]
3123 fn extended_validation_flags_empty_changed_paths() {
3124 let dir = tempfile::TempDir::new().unwrap();
3125 let blockers = validate_git_commit_preconditions_extended(dir.path(), &[]);
3126 assert!(
3127 blockers
3128 .iter()
3129 .any(|b| b.contains("integration produced no changed paths")),
3130 "empty merge_paths must surface as a blocker: {blockers:?}"
3131 );
3132 }
3133
3134 #[test]
3139 fn extended_validation_skips_identity_check_when_no_git_dir() {
3140 let dir = tempfile::TempDir::new().unwrap();
3141 let blockers = validate_git_commit_preconditions_extended(dir.path(), &["a".to_string()]);
3142 assert!(
3147 !blockers.iter().any(|b| b.contains("git user.name")),
3148 "must not report identity blockers without a git overlay: {blockers:?}"
3149 );
3150 assert!(
3151 !blockers.iter().any(|b| b.contains("git user.email")),
3152 "must not report identity blockers without a git overlay: {blockers:?}"
3153 );
3154 }
3155
3156 #[test]
3157 fn coordination_blocker_recommendations_are_machine_actions() {
3158 let merge_state = "hs-landed123".to_string();
3159 let post_snapshot = coordination_blocker_recommended_action(Some(&merge_state));
3160 assert_eq!(post_snapshot, "heddle capture -m \"...\"");
3161 assert!(
3162 action_template(&post_snapshot).is_some(),
3163 "commit placeholder should carry a fillable template"
3164 );
3165
3166 let pre_snapshot = coordination_blocker_recommended_action(None);
3167 assert_eq!(pre_snapshot, "heddle status");
3168 assert!(
3169 action_template(&pre_snapshot).is_some(),
3170 "status action should carry a template"
3171 );
3172 for action in [post_snapshot, pre_snapshot] {
3173 assert!(
3174 !action.contains("resolve git state")
3175 && !action.contains("see blockers")
3176 && !action.contains("do NOT"),
3177 "recommended actions must be Heddle commands/templates, not prose: {action}"
3178 );
3179 }
3180 }
3181
3182 #[test]
3183 fn empty_diff_output_is_self_consistent_and_serializes() {
3184 let id = objects::object::StateId::from_bytes([69; 32]);
3185 let out = empty_diff_output(&id);
3186
3187 assert_eq!(out.from_state.as_deref(), Some(id.short()).as_deref());
3188 assert_eq!(out.to_state.as_deref(), Some(id.short()).as_deref());
3189 assert!(
3190 out.changes.is_empty(),
3191 "empty_diff_output must report no changes — that's the whole point"
3192 );
3193 assert!(out.semantic_changes.is_none());
3194
3195 let json = serde_json::to_value(&out).unwrap();
3196 assert_eq!(
3197 json["changes"].as_array().unwrap().len(),
3198 0,
3199 "`changes` array must serialize as empty, not be omitted"
3200 );
3201 assert_eq!(
3202 json["from_state"], json["to_state"],
3203 "self-loop semantics: from == to when no change landed"
3204 );
3205 }
3206}