Skip to main content

verbs/merge/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Merge orchestration facade (planning + apply).
3//!
4//! This module owns merge **planning**, tree **application**, and the
5//! `merge_thread` / `merge_thread_into_current` operator report path
6//! (ADR-0040 X-ops). The `heddle-merge` crate remains the text/tree merge
7//! algorithm engine.
8//!
9//! CLI responsibilities left outside this module:
10//! - clap parsing, hooks, current-state bootstrap (`ensure_current_state`)
11//! - text/json render, `--repo` action scoping, exit-code mapping
12//!
13//! Deferred operator-family follow-ups (same ADR table):
14//! - **rebase / cherry-pick**: still CLI-owned (`commands/rebase/*`,
15//!   `cherry_pick.rs`); extract preflight+apply after merge settles.
16//! - **undo / redo apply**: still CLI-owned (`undo_apply/*`); atomic
17//!   per-effect applier + git-checkpoint coordination is tightly coupled
18//!   to CLI RecoveryAdvice and git-projection helpers — extract next wave.
19
20use 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
66/// CLI merge planning must hydrate partial-clone blobs before content merge.
67/// The engine stays repository-free and only asks this boundary for bytes.
68struct 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/// Operator action discriminator for merge reports (wire-compatible with CLI).
103#[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/// Operator command output embedded in merge reports.
155#[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    // "" means "no action selected" internally; the wire contract is null
281    // (HeddleCo/heddle#645) — the boundary walker rejects raw empties.
282    #[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    /// Per-symbol deltas produced by the semantic driver
321    /// (function_renamed, function_added, function_deleted,
322    /// signature_changed, etc.). Present when semantic merge is active so
323    /// agents can detect that semantic analysis ran and act on the
324    /// rename/symbol mapping programmatically without parsing the
325    /// line-by-line `diff` payload. Absent (not `null`) when
326    /// `--no-semantic` is set or the build lacks semantic support.
327    /// An empty array means "semantic ran but found no symbol-level
328    /// deltas" (e.g. non-source files or a no-op fast-forward).
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub semantic_changes: Option<Vec<SemanticChangeEntry>>,
331    /// Diff between the parent's tip and the thread's tip. Populated
332    /// only when the caller passes `--with-diff`. On a successful
333    /// non-preview merge the from/to are the pre-merge parent tip and
334    /// the thread tip — i.e. the change set that just landed.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub diff: Option<DiffReport>,
337    /// Preview of the git commit that *would* be written if the user
338    /// re-ran without `--preview`. Populated only with
339    /// `--git-commit --preview`.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub git_commit_preview: Option<GitCommitPreview>,
342    /// Real git commit written by `--git-commit` on a non-preview
343    /// merge. Populated only after a successful, non-conflict merge
344    /// when `--git-commit` was set.
345    #[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    /// Real machine-contract coverage, injected by the CLI shell so the
356    /// operator report's trust classification matches `heddle verify`
357    /// (contract gaps stay visible instead of degrading to `not_checked`).
358    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 contributed by post-merge coordination steps
376    /// (e.g. `--git-commit` failing on dirty git state). Merged into
377    /// the operator's final `blockers` list and force `status` to
378    /// `"blocked"` even when the heddle merge itself completed.
379    extra_blockers: Vec<String>,
380    /// Top-level mirror of `diff.semantic_changes`. Threaded through
381    /// `MergeReportInput` so every return path sets it consistently
382    /// (instead of relying on each call site to remember). See the
383    /// field doc on `MergeReport::semantic_changes`.
384    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/// Strategy + target decided **once per merge attempt** and reused by
465/// preview, refresh, apply, and diff (HeddleCo/heddle#503).
466///
467/// Before this seam existed, `merge_thread_into_current` computed the
468/// content-merge strategy independently for the preview report and for
469/// the apply `MergePlan` (two separate `merge_strategy_for(use_semantic)`
470/// calls). They agreed only by construction — and a Codex r13 P2 finding
471/// documented a real preview-vs-actual divergence regression caused by
472/// exactly that kind of duplicated, drift-prone strategy state. Routing
473/// every consumer through one decision makes "preview == apply" an
474/// invariant the type system enforces rather than a discipline each call
475/// site must remember.
476#[derive(Clone, Copy, Debug, PartialEq, Eq)]
477pub struct MergeAttemptPlan {
478    strategy: MergeStrategy,
479    /// Whether semantic analysis feeds the `--with-diff` / symbol-delta
480    /// payload. Derived from the same `use_semantic` decision as
481    /// `strategy`, so the diff path can't drift from the content-merge
482    /// path either.
483    use_semantic: bool,
484}
485
486impl MergeAttemptPlan {
487    /// Decide the strategy for one `heddle merge` invocation. This is the
488    /// single point where `no_semantic` becomes a `MergeStrategy`; every
489    /// downstream consumer reads it back off the plan.
490    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    /// The content-merge strategy for this attempt. Consumed identically
499    /// by the preview report's 3-way merge and the apply `MergePlan`, so
500    /// the two cannot select different strategies.
501    pub(crate) fn strategy(&self) -> MergeStrategy {
502        self.strategy
503    }
504
505    /// Whether semantic analysis is active for this attempt's diff
506    /// payload. Mirrors `strategy() == Semantic`.
507    pub(crate) fn use_semantic(&self) -> bool {
508        self.use_semantic
509    }
510}
511
512#[allow(clippy::too_many_arguments)]
513/// Facade options for [`merge_thread`] / [`merge_thread_into_current`].
514#[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
525/// Merge `opts.track_name` into the repository's current HEAD.
526pub 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// Back-compat entrypoint: wraps the machine-contract-aware variant with a
543// default (not_checked) contract. The arg count reflects the merge surface;
544// the contract-aware sibling carries one more.
545#[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/// Contract-aware entry point. The CLI shell passes the real machine-contract
570/// coverage (from the command catalog) so the operator report's trust
571/// classification matches `heddle verify`; embedders that lack a command
572/// catalog fall through the default (`not_checked`) via the wrapper above.
573#[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/// Merge entrypoint with an optional caller-provided transaction identity.
600/// Land persists this identity before integration so the exact committed batch
601/// remains discoverable across every crash boundary.
602#[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    // Strategy + diff-semantics decided ONCE per merge attempt
619    // (HeddleCo/heddle#503). Preview, refresh, apply, and diff all read
620    // their strategy back off this single plan instead of re-deriving it
621    // — so the preview can't pick a different content-merge strategy than
622    // the apply path, which is the documented Codex r13 divergence class.
623    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(&current_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    // Codex r13 P2: the preview report's content-merge strategy must
677    // match the strategy the actual merge plan (below) will use, so
678    // the `preview_summary` lines don't contradict the real outcome
679    // (e.g. reporting `conflicts: 1 path conflict(s)` on a structural
680    // reshape that semantic resolves cleanly). Both now read the SAME
681    // decision off `attempt` (#503), so they cannot diverge.
682    let current_thread = repo
683        .current_lane()?
684        .unwrap_or_else(|| "detached".to_string());
685    // heddle#144: the inner preview report MUST compute its 3-way merge
686    // against the actual destination of *this* merge (the operator's
687    // current HEAD), not `thread.target_thread`. Otherwise running
688    // `heddle merge A` from a thread B whose tip diverges from A's
689    // recorded target (often `main`) yields a preview whose
690    // `preview_summary` line claims one outcome while the apply path
691    // produces another.
692    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: &current_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        &current_state.state_id,
732        &merge_target_id,
733        ConflictLabels {
734            current: &current_label,
735            incoming: &incoming_label,
736            strategy: attempt.strategy(),
737        },
738    )?;
739
740    // Helper for the `--with-diff` payload. Each branch picks the right
741    // (from, to) once it knows what actually landed — see the per-branch
742    // calls below. Pre-fix, the function computed a single
743    // `current..merge_target` diff up-front and reused it everywhere; that
744    // payload is wrong for non-fast-forward 3-way merges (it can include
745    // removals of current-branch edits that the merge actually preserves)
746    // and for `AlreadyUpToDate` (it can be non-empty when nothing landed).
747    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    // heddle#153: surface per-symbol deltas at the top level so agents
754    // can detect that semantic analysis ran and act on the rename
755    // mapping without digging into `diff.semantic_changes`. We derive
756    // this from the (already-computed) diff payload when both semantic
757    // merge and `--with-diff` are active; without `--with-diff` the
758    // diff isn't computed at all, so there's nothing to mirror. Use
759    // `Some(vec![])` (not `None`) on the with-diff+semantic path even
760    // when the driver found no symbol changes, so consumers can branch
761    // on field presence to detect "semantic mode honored".
762    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        // Already-up-to-date means the merge doesn't write anything — the
784        // current state already contains the target. The honest diff is
785        // empty; producing `current..target` would make the JSON falsely
786        // claim a change landed.
787        let already_up_to_date_diff = if with_diff {
788            Some(empty_diff_output(&current_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        // Use the parent↔thread-tip diff as the source of truth for
818        // which paths the merge writes — see `merge_changed_paths` for
819        // why thread.changed_paths can't be relied on here.
820        let ff_paths = merge_changed_paths(repo, &current_state.state_id, &merge_target_id)?;
821
822        // FF: current..target IS the change set that lands. Compute once
823        // and reuse for any per-branch return below.
824        let (ff_renames, ff_directory_renames) =
825            fast_forward_renames(repo, &current_state.state_id, &merge_target_id)?;
826        let ff_diff = diff_for(&current_state.state_id, &merge_target_id)?
827            .map(|diff| diff_with_known_renames(diff, &ff_renames));
828
829        // Pre-flight `--git-commit` validation (real merge only). On
830        // preview we skip the dirty-tree check — the operator hasn't
831        // committed to landing anything yet, just wants to see the
832        // would-be commit message.
833        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            // Fail loudly *before* advancing heddle state.
843            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            // Preserve attached-HEAD semantics on fast-forward: if HEAD is
890            // attached to a thread, advance that thread's ref so
891            // `heddle merge X` from inside thread Y leaves Y pointing at
892            // the integrated state. See `Repository::fast_forward_attached`
893            // and the regression test
894            // `merge_fast_forward_advances_current_thread`.
895            //
896            // We perform the FF *without recording* an `OpRecord::Goto`
897            // and then explicitly record `OpRecord::FastForward` so
898            // both ends of the FF are captured. r1 (heddle#99) added the
899            // variant to fix stranded-ref-on-undo. r2 added
900            // `post_target_id` so redo replays the recorded SHA instead
901            // of re-resolving `source_thread → tip` at apply time —
902            // closes Codex's non-determinism finding on PR #109.
903            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                            &current_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                // FF advances heddle to `merge_target_id` (the thread
947                // tip). Use that as the `Merge-State` trailer — there's
948                // no synthetic merge state on a fast-forward.
949                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            // Preview path: render the would-be commit message.
976            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                // Fast-forward never has conflicts, so anything in
1063                // the preview-stage `blockers` list is advisory. The
1064                // operation either advanced state (apply path) or
1065                // would advance state (preview path) — either way
1066                // these belong in `warnings`, not `blockers`.
1067                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        // For `--git-commit --preview`, render the would-be commit
1139        // message so the operator can review it before re-running
1140        // without `--preview`. We can't surface a real `Merge-State`
1141        // change-id (no merge state has been written yet) — emit the
1142        // placeholder `<pending>` and let real-mode produce the final
1143        // trailer once the merge state exists.
1144        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, &current_state.state_id, &merge_target_id)?,
1153            })
1154        } else {
1155            None
1156        };
1157        // 3-way preview diff: report the computed merge tree, not
1158        // `current..merge_target`. The source-tip diff can show
1159        // deletions of files that only exist on the destination branch
1160        // even though a real 3-way merge would preserve them.
1161        let preview_path_diff = compute_tree_diff(
1162            repo,
1163            &current_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        // Conflicted merge: the merge wrote a partial tree containing
1227        // conflict markers. Reporting either `current..target` or
1228        // `current..merge_result.tree` here would be misleading — the
1229        // user must resolve before any well-defined diff exists. Empty
1230        // diff is the honest signal.
1231        let conflict_diff = if with_diff {
1232            Some(empty_diff_output(&current_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        // 3-way clean merge, not committed. The actual change set is
1262        // `current_tree..merge_result.tree`, but the merged tree isn't
1263        // yet a committed `State` — `compute_state_diff` can't run, and
1264        // the public `DiffReport`/`FileChange` constructor surface goes
1265        // through a private module we can't import here. Document the
1266        // gap honestly: when the operator passes `--with-diff` together
1267        // with `--no-commit`, surface `None`; the diff materializes on
1268        // the post-snapshot path. Re-running without `--no-commit` (or
1269        // running `heddle diff` against the new state) recovers the
1270        // full payload.
1271        let no_commit_path_diff = compute_tree_diff(
1272            repo,
1273            &current_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    // If `--git-commit` is set, validate git state *before* writing
1311    // the heddle merge state. That way a dirty git tree can't leave us
1312    // with a half-coordinated outcome (heddle merged, git rejected).
1313    //
1314    // Derive paths from the parent↔thread-tip diff rather than
1315    // `thread.changed_paths`: thread metadata is lazily refreshed and
1316    // can be empty in synthetic / lightweight setups, but the diff is
1317    // ground truth for what the merge actually wrote.
1318    let merge_paths: Vec<String> = if git_commit {
1319        merge_changed_paths(repo, &current_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        // Extended pre-flight: check anything else we can dry-run before
1329        // writing heddle state. The original `validate_git_state` covers
1330        // dirty-tree and detached-HEAD; this catches missing commit
1331        // identity and missing changed paths — both produce
1332        // post-snapshot failures that leave heddle advanced and git
1333        // uncommitted. Fail closed BEFORE `snapshot_merge_with_attribution`
1334        // runs.
1335        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        // Surface as a `blocked` outcome — heddle hasn't committed
1340        // anything yet, so the operator can fix git and retry without
1341        // any cleanup. Empty diff: nothing landed, so nothing to
1342        // describe.
1343        let blocked_diff = if with_diff {
1344            Some(empty_diff_output(&current_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    // Heddle has advanced. If `--git-commit` is set we attempt the git
1414    // commit now — but we DON'T `?`-propagate a failure. Up-front
1415    // validation already drained every dry-runnable failure mode; what
1416    // remains (hooks rejecting, identity rotated mid-call, concurrent
1417    // index lock, FS errors) we surface as a structured `blocked`
1418    // outcome with a precise recovery hint pointing at the intact
1419    // heddle merge state. The operator can resolve git and re-run
1420    // `git commit` manually without losing the merge.
1421    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    // 3-way committed merge: `new_state` is the actual landed state.
1489    // Compute the diff from current → new_state so the JSON describes
1490    // the change set the user can audit, NOT `current..merge_target`
1491    // which can include removals of current-branch edits the merge
1492    // preserved.
1493    let committed_path_diff = compute_state_diff(
1494        repo,
1495        &current_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    // Core cannot resolve remotes via CLI remote helpers; prefer local land.
1547    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
1566/// Build a stand-in commit message for `--git-commit --preview` output.
1567/// Mirrors the real-mode logic in the apply path but doesn't allocate
1568/// a heddle merge state — used only for the preview surface.
1569fn 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
1654/// Derive the set of paths the merge will touch by diffing the
1655/// parent's tip against the thread's tip. Used to drive
1656/// `--git-commit` staging precisely (no `git add -A`) and to
1657/// distinguish related vs. unrelated dirt during precondition checks.
1658///
1659/// Returns the changed paths (added, modified, deleted), preserving
1660/// diff-output order. Renames surface as a from→to pair so both sides
1661/// land in the commit.
1662fn 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        // These records don't advance HEAD/thread to the merge state the merge
1755        // flow tracks.
1756        // Enumerated explicitly (no wildcard) so a new state-advancing variant
1757        // must be considered as a possible merge target here (heddle#354 r9).
1758        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
1786/// Extended pre-flight for `--git-commit`. Catches dry-runnable failure
1787/// modes that `validate_git_state` doesn't cover, so they surface as
1788/// pre-snapshot blockers rather than post-snapshot panics that leave
1789/// heddle advanced while git is uncommitted:
1790///
1791/// - **Empty changed-paths set.** `write_git_commit` errors when the
1792///   merge produced no paths to commit (`refusing to write an empty
1793///   git commit`); detect that pre-snapshot.
1794///
1795/// Heddle writes Git commits with native plumbing and can author them
1796/// from Heddle's captured principal when Git config is absent, so
1797/// `user.name`/`user.email` are not preflight requirements.
1798///
1799/// Hooks (`pre-commit`, `commit-msg`) intentionally aren't dry-run here
1800/// — they have side effects, and a strict dry-run would change semantics
1801/// vs. the real commit. If those reject, the caller surfaces an
1802/// actionable recovery hint pointing at the intact heddle merge state.
1803///
1804/// Strategy chosen: option (a) from the spec — extend up-front
1805/// validation and accept that the residual unvalidated failure modes
1806/// (hooks, race conditions, FS errors) require a recovery hint rather
1807/// than a rollback. Option (b) — explicit rollback of the heddle merge
1808/// — would introduce undo semantics that don't compose well with the
1809/// oplog: a partial rollback hand-rolled here can leave the oplog
1810/// pointing at a state that no longer matches the worktree.
1811fn 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        // `validate_git_state` already reports this; don't double-report.
1824        return blockers;
1825    }
1826
1827    blockers
1828}
1829
1830/// Empty `DiffReport` keyed at the given change-id. Used for return paths
1831/// that didn't actually advance state (already-up-to-date, conflicted,
1832/// pre-snapshot blocked) so the JSON honestly reports "no change set
1833/// landed" instead of pointing at an arbitrary parent..target diff.
1834fn 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
1845/// Shared dir → file type-change handler for merge and cherry-pick.
1846///
1847/// Called *after* `remove_tracked_descendants*` has stripped the directory's
1848/// tracked content. Two outcomes:
1849///
1850/// - The directory is now empty → `fs::remove_dir(path)` so the subsequent
1851///   `materialize_blob` call can write a regular file at this path. Without
1852///   this step `materialize_blob` fails with a kernel "Is a directory"
1853///   error because its `remove_file(dest)` precondition can only clear
1854///   files and symlinks.
1855/// - The directory still holds heddle-ignored content (`.git/`, `target/`,
1856///   `node_modules/`, …) → return a clear, actionable error naming the
1857///   surviving entries. We do NOT silently delete heddle-ignored content
1858///   to make a type-change land; that would defeat the entire reason
1859///   tracked-descendants removal exists.
1860///
1861/// `path` must already be confirmed to exist as a directory by the caller.
1862pub 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
1909/// Result of trying a 3-way merge between two thread tips.
1910pub enum ThreeWayMergeOutcome {
1911    /// Clean tree with no conflicts. Tree is allocated in the
1912    /// `parent_repo` object store.
1913    Clean {
1914        tree: Tree,
1915    },
1916    /// Conflicts exist. `tree` is the partial merge tree containing
1917    /// conflict markers, and `paths` lists the conflicting path strings.
1918    Conflicted {
1919        tree: Tree,
1920        paths: Vec<String>,
1921        base: StateId,
1922    },
1923    /// Already-integrated or fast-forward — caller can take a
1924    /// simpler advance path. The contained `target` is the tip the
1925    /// caller should advance to.
1926    AlreadyIntegrated {
1927        target: StateId,
1928    },
1929    FastForward {
1930        target: StateId,
1931    },
1932}
1933
1934/// Compute a 3-way merge between two thread tips without applying
1935/// it. Used by `heddle thread refresh` to fall back to merge-style
1936/// reasoning when the commit-by-commit rebase replay would block on
1937/// an intermediate state but the final trees actually merge cleanly.
1938///
1939/// `parent_repo` is where merge bases / commit graph are queried;
1940/// the returned `Tree` is allocated in that store and the caller is
1941/// responsible for applying it to a worktree and snapshotting.
1942pub 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
1983/// Apply a pre-computed merged tree to the given repo's worktree.
1984/// Re-export of the internal helper so callers outside the merge
1985/// module (notably `thread_cmd::refresh_thread`) can converge on the
1986/// same tree-application path the merge command uses.
1987pub 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    // External callers (`heddle sync`, `heddle land`, `heddle ready`)
2085    // route through the same default merge strategy as `heddle merge`.
2086    // The merge command path can still opt out by passing an explicit
2087    // strategy to `_with_graph`.
2088    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
2098/// Caller-supplied override for the destination side of the preview's
2099/// 3-way merge. When `Some`, the inner preview MUST compute against
2100/// this `(label, state_id)` instead of `thread.target_thread`. Used by
2101/// `merge_thread_into_current` so the preview matches the actual merge
2102/// — `heddle merge A` from thread B merges A → B, but A's
2103/// `target_thread` is whatever A was created from (often `main`), so
2104/// without an override the inner report computes A → main and
2105/// contradicts the real outcome (heddle#144).
2106pub 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    // Resolve the destination side. Prefer the caller's override (the
2122    // merge command supplies the actual current HEAD); otherwise fall
2123    // back to `thread.target_thread` for callers like `ready` / `sync` /
2124    // `land` that don't carry an explicit merge destination.
2125    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: &current_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    // The preview-stage "blockers" list mixes two kinds of items:
2247    //   1) Real blockers — things that actually prevent the merge from
2248    //      advancing state (e.g. unresolved conflicts).
2249    //   2) Recommendations — non-blocking nudges like "promotion
2250    //      recommended for environment breadth". The merge can and
2251    //      does proceed when these are present; surfacing them as
2252    //      `blockers` while also setting `merge_state` produces the
2253    //      contradictory shape `status: "blocked"` + non-null
2254    //      `merge_state` + `thread_state: "merged"`.
2255    //
2256    // The schema rule is: `blockers` only when `status == "blocked"`
2257    // and the operation did NOT advance state. Everything else moves
2258    // to `warnings`.
2259    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    // The only "real" blocker in the merge flow is unresolved
2269    // conflicts. Stale/promotion/etc. are advisory.
2270    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        // Apply path with conflicts → tell the operator how to
2299        // resolve. Preview path with conflicts → no actionable
2300        // command (the operator must pick a strategy first).
2301        if input.preview_only {
2302            None
2303        } else {
2304            Some("heddle continue".to_string())
2305        }
2306    } else if !input.extra_blockers.is_empty() {
2307        // Coordination blocker. Two shapes:
2308        //   1. Pre-snapshot (`merge_state` is None): typical
2309        //      `--git-commit` precondition failure. Nothing landed; surface
2310        //      status rather than a self-loop back into this merge command.
2311        //   2. Post-snapshot (`merge_state` is Some): `git commit`
2312        //      itself failed AFTER heddle advanced. Re-running
2313        //      `heddle merge` would noop; the safe recovery is the
2314        //      shared checkpoint template, which records the landed
2315        //      Heddle state in Git after the checkout issue is fixed.
2316        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        // Clean preview: the actionable next step is the human landing
2326        // command. `land` keeps capture, merge, checkpoint, push, and
2327        // verification in one loop, so the preview does not bounce users back
2328        // to the lower-level merge apply command.
2329        input.thread.as_ref().map(|t| land_local_command(&t.id))
2330    } else {
2331        // Clean apply: nothing to do.
2332        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)] // retained for richer RecoveryDetails copy in a later polish pass
2550fn 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    /// Regression-lock for HeddleCo/heddle#503 (guards the documented
2942    /// Codex r13 preview-vs-actual divergence class).
2943    ///
2944    /// Strategy is decided **once per merge attempt** via
2945    /// `MergeAttemptPlan::decide`; preview and apply both read the
2946    /// strategy back off that single plan. This test pins two things:
2947    ///
2948    /// 1. The decision is internally consistent — `strategy()` and
2949    ///    `use_semantic()` never disagree (a `Semantic` strategy with
2950    ///    `use_semantic == false`, or vice versa, would let the diff
2951    ///    payload contradict the content merge).
2952    /// 2. `merge_thread_into_current` no longer recomputes the strategy
2953    ///    independently for preview and apply. We enforce this at the
2954    ///    source level: the body must contain exactly ONE
2955    ///    `MergeAttemptPlan::decide(` call and ZERO bare
2956    ///    `merge_strategy_for(use_semantic)` call sites — the old
2957    ///    duplicated form the issue flagged. If a future edit
2958    ///    reintroduces a second, drift-prone strategy decision, this
2959    ///    assertion fails.
2960    #[test]
2961    fn merge_strategy_is_decided_once_preview_equals_apply() {
2962        // (1) The decision object is self-consistent for both flags.
2963        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        // (2) Source-level invariant: the merge-attempt flow decides the
2979        // strategy exactly once and never re-derives it independently for
2980        // preview vs apply. The preview report and the apply MergePlan
2981        // must consume the SAME plan, so there is one `decide(` call and
2982        // no surviving `merge_strategy_for(use_semantic)` call sites in
2983        // `merge_thread_into_current`.
2984        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    /// Empty directory case: `prepare_dir_for_file_replacement` removes
3021    /// it so the materializer can write a regular file at the same path.
3022    /// Without this step, `materialize_blob` blows up deep in the
3023    /// materializer with a "Is a directory" I/O error.
3024    #[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    /// Non-empty directory case (heddle-ignored content remains): the
3039    /// helper must error with an actionable message naming the offending
3040    /// content. Silently deleting heddle-ignored content to make a
3041    /// type-change land would defeat the entire reason
3042    /// `remove_tracked_descendants` exists.
3043    #[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        // Simulate heddle-ignored content (e.g. `target/`, `node_modules/`)
3049        // that `remove_tracked_descendants_with_source` left in place
3050        // because it isn't in the source tree.
3051        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        // Content must survive the failed call — the helper is
3070        // load-bearing precisely because it does NOT touch ignored
3071        // content.
3072        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    /// Missing-path case: a NotFound error is harmless — the path is
3079    /// already gone, so the materializer can write the new file freely.
3080    #[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        // Don't create it.
3085
3086        prepare_dir_for_file_replacement(&target).expect("missing dir is a no-op, not an error");
3087    }
3088
3089    /// `empty_diff_output` is the schema-honest payload for return paths
3090    /// where heddle didn't actually advance state (already-up-to-date,
3091    /// conflicted, pre-snapshot blocked). The shape must round-trip as
3092    /// JSON cleanly: both `from_state` and `to_state` are populated with
3093    /// the same change-id and `changes` is an empty array.
3094    #[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        // Initialize a git repo with no user.name.
3100        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    /// Empty merge-paths case: `write_git_commit` rejects an empty
3119    /// integration commit inside `git_commit.rs`, which only
3120    /// surfaces AFTER `snapshot_merge_with_attribution` has advanced
3121    /// heddle. The up-front check catches it before snapshot.
3122    #[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    /// Negative case: when the directory isn't a git repo, the
3135    /// extended check returns early without spurious identity blockers
3136    /// (the existing `validate_git_state` reports the "no git
3137    /// repository" blocker; the extended check shouldn't double-report).
3138    #[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        // Only the `merge_paths.is_empty()` check fires before the
3143        // `.git` short-circuit; with non-empty paths it should be
3144        // empty (the absent-`.git` check is `validate_git_state`'s
3145        // job).
3146        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}