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    MergeBlobSource, MergeError, MergeOptions as EngineMergeOptions, MergeStrategy,
25    RenameMatcherStats, RenameOptions, SemanticMergeFn, SemanticSimilarityFn,
26    detect_renames_between_trees, merge_trees,
27};
28use objects::{
29    object::{Attribution, Blob, ContentHash, FacetKind, StateId, ThreadName, Tree},
30    store::ObjectStore,
31};
32use oplog::{OpBatch, OpLogBackend, OpLogRecorder, OpRecord};
33use refs::Head;
34use repo::{
35    ActorPresenceStatus, ActorPresenceStore, CommitGraphIndex, Repository, Thread, ThreadFreshness,
36    ThreadIntegrationPolicy, ThreadManager, ThreadState, describe_thread_advice, find_merge_base,
37    refresh_thread_freshness,
38};
39use schemars::JsonSchema;
40use serde::{Serialize, Serializer, ser::SerializeStruct};
41use sley::Repository as SleyRepository;
42
43use crate::{
44    ActionTemplate, DiffReport, SemanticChangeEntry, compute_state_diff, compute_tree_diff,
45    verify::{
46        MachineContractInput, RepositoryVerificationState, action_template,
47        build_repository_verification_state_with_machine_contract, serialize_empty_action_as_null,
48    },
49};
50
51mod advice;
52mod apply;
53mod git_commit;
54mod plan;
55mod relation;
56mod structured;
57mod worktree_safety;
58
59pub use apply::apply_merged_tree;
60pub use git_commit::{GitCommitInfo, GitCommitPreview};
61pub use merge::ConflictLabels;
62pub use plan::MergePlan;
63pub use relation::{MergeRelation, MergeRelationKind};
64pub use structured::build_conflict_payload;
65pub use worktree_safety::ensure_worktree_clean;
66
67/// CLI merge planning must hydrate partial-clone blobs before content merge.
68/// The engine stays repository-free and only asks this boundary for bytes.
69struct RepositoryMergeBlobSource<'repo> {
70    repo: &'repo Repository,
71}
72
73impl MergeBlobSource for RepositoryMergeBlobSource<'_> {
74    fn load_blob(&self, hash: &ContentHash, _path: &str) -> Result<Vec<u8>> {
75        Ok(self.repo.require_blob(hash)?.content().to_vec())
76    }
77}
78
79pub(crate) fn map_tree_merge_error(error: anyhow::Error) -> anyhow::Error {
80    match error.downcast_ref::<MergeError>() {
81        Some(MergeError::RepositoryIntegrity {
82            error,
83            unsafe_condition,
84            would_change,
85            preserved,
86        }) => anyhow!(advice::merge_integrity_refusal(
87            error.clone(),
88            unsafe_condition.clone(),
89            would_change.clone(),
90            preserved.clone(),
91        )),
92        None => error,
93    }
94}
95
96#[derive(Clone, Debug, Serialize)]
97pub struct RenameEntry {
98    pub from: String,
99    pub to: String,
100    pub score: f64,
101}
102
103/// Operator action discriminator for merge reports (wire-compatible with CLI).
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum OperatorAction {
106    Abort,
107    Bisect,
108    CherryPick,
109    #[default]
110    Continue,
111    Land,
112    Merge,
113    Ready,
114    Rebase,
115    Revert,
116    Sync,
117    ThreadCleanup,
118    ThreadDrop,
119    ThreadPromote,
120    ThreadRefresh,
121    ThreadResolve,
122}
123
124impl OperatorAction {
125    pub const fn wire_value(self) -> &'static str {
126        match self {
127            Self::Abort => "abort",
128            Self::Bisect => "bisect",
129            Self::CherryPick => "cherry-pick",
130            Self::Continue => "continue",
131            Self::Land => "land",
132            Self::Merge => "merge",
133            Self::Ready => "ready",
134            Self::Rebase => "rebase",
135            Self::Revert => "revert",
136            Self::Sync => "sync",
137            Self::ThreadCleanup => "thread_cleanup",
138            Self::ThreadDrop => "thread_drop",
139            Self::ThreadPromote => "thread_promote",
140            Self::ThreadRefresh => "thread_refresh",
141            Self::ThreadResolve => "thread_resolve",
142        }
143    }
144}
145
146impl Serialize for OperatorAction {
147    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
148    where
149        S: Serializer,
150    {
151        serializer.serialize_str(self.wire_value())
152    }
153}
154
155/// Operator command output embedded in merge reports.
156#[derive(Debug, Clone, Default)]
157pub struct OperatorCommandOutput {
158    pub status: String,
159    pub action: OperatorAction,
160    pub message: String,
161    pub blockers: Vec<String>,
162    pub warnings: Vec<String>,
163    pub next_action: Option<String>,
164    pub recommended_action: Option<String>,
165}
166
167impl OperatorCommandOutput {
168    fn serialize_with_output_kind<S>(
169        &self,
170        serializer: S,
171        output_kind: OperatorAction,
172    ) -> std::result::Result<S::Ok, S::Error>
173    where
174        S: Serializer,
175    {
176        let next_action = self.next_action.as_deref().filter(|a| !a.trim().is_empty());
177        let recommended_action = self
178            .recommended_action
179            .as_deref()
180            .filter(|a| !a.trim().is_empty());
181        let next_action_template = next_action.and_then(action_template);
182        let recommended_action_template = recommended_action.and_then(action_template);
183
184        let mut len = 8;
185        if !self.blockers.is_empty() {
186            len += 1;
187        }
188        if !self.warnings.is_empty() {
189            len += 1;
190        }
191
192        let mut state = serializer.serialize_struct("OperatorCommandOutput", len)?;
193        state.serialize_field("output_kind", &output_kind.wire_value())?;
194        state.serialize_field("status", &self.status)?;
195        state.serialize_field("action", &self.action)?;
196        state.serialize_field("message", &self.message)?;
197        if !self.blockers.is_empty() {
198            state.serialize_field("blockers", &self.blockers)?;
199        }
200        if !self.warnings.is_empty() {
201            state.serialize_field("warnings", &self.warnings)?;
202        }
203        state.serialize_field("next_action", &next_action)?;
204        state.serialize_field("next_action_template", &next_action_template)?;
205        state.serialize_field("recommended_action", &recommended_action)?;
206        state.serialize_field("recommended_action_template", &recommended_action_template)?;
207        state.end()
208    }
209}
210
211impl Serialize for OperatorCommandOutput {
212    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
213    where
214        S: Serializer,
215    {
216        self.serialize_with_output_kind(serializer, self.action)
217    }
218}
219
220impl OperatorCommandOutput {
221    pub fn blocked_by_repository_verification(
222        action: OperatorAction,
223        message: impl Into<String>,
224        trust: &RepositoryVerificationState,
225    ) -> Self {
226        let recommended_action = repository_verification_primary_command(trust);
227        Self {
228            status: "blocked".to_string(),
229            action,
230            message: message.into(),
231            blockers: repository_verification_blockers(trust),
232            warnings: Vec::new(),
233            next_action: Some(recommended_action.clone()),
234            recommended_action: Some(recommended_action),
235        }
236    }
237}
238
239fn repository_verification_primary_command(trust: &RepositoryVerificationState) -> String {
240    if trust.recommended_action.trim().is_empty() {
241        "heddle verify".to_string()
242    } else {
243        trust.recommended_action.clone()
244    }
245}
246
247fn repository_verification_blockers(trust: &RepositoryVerificationState) -> Vec<String> {
248    trust
249        .checks
250        .iter()
251        .filter(|check| !check.clean)
252        .map(|check| format!("{}: {}", check.name, check.summary))
253        .collect()
254}
255
256fn trust_state(
257    repo: &Repository,
258    machine_contract: &MachineContractInput,
259) -> Result<RepositoryVerificationState> {
260    Ok(build_repository_verification_state_with_machine_contract(
261        repo,
262        machine_contract,
263    )?)
264}
265
266#[derive(Clone, Debug, Serialize, JsonSchema)]
267pub struct ThreadPreviewReport {
268    pub thread: String,
269    pub thread_mode: String,
270    pub thread_state: String,
271    pub freshness: String,
272    pub task: Option<String>,
273    pub changed_paths: Vec<String>,
274    pub changed_path_count: usize,
275    pub impact_categories: Vec<String>,
276    pub heavy_impact_paths: Vec<String>,
277    pub merge_relation: String,
278    pub conflicts: Vec<String>,
279    pub conflict_count: usize,
280    pub blockers: Vec<String>,
281    // "" means "no action selected" internally; the wire contract is null
282    // (HeddleCo/heddle#645) — the boundary walker rejects raw empties.
283    #[serde(serialize_with = "serialize_empty_action_as_null")]
284    #[schemars(with = "Option<String>")]
285    pub recommended_action: String,
286    pub recommended_action_template: Option<ActionTemplate>,
287    pub thread_health: String,
288}
289
290impl ThreadPreviewReport {
291    pub fn refresh_recommended_action_metadata(&mut self) {
292        self.recommended_action_template = action_template(&self.recommended_action);
293    }
294}
295
296#[derive(Clone, Debug, Serialize)]
297pub struct MergeReport {
298    #[serde(flatten)]
299    pub operator: OperatorCommandOutput,
300    pub would_merge: bool,
301    pub applied: bool,
302    pub fast_forward: bool,
303    pub preview_only: bool,
304    pub merge_state: Option<String>,
305    pub conflicts: Vec<String>,
306    pub preview_summary: Vec<String>,
307    pub thread_state: Option<String>,
308    pub freshness: Option<String>,
309    pub changed_paths: Vec<String>,
310    pub changed_path_count: usize,
311    pub impact_categories: Vec<String>,
312    pub promotion_suggested: bool,
313    pub heavy_impact_paths: Vec<String>,
314    pub merge_relation: Option<String>,
315    pub conflict_count: usize,
316    pub thread_health: String,
317    #[serde(skip_serializing_if = "Vec::is_empty")]
318    pub renames: Vec<RenameEntry>,
319    #[serde(skip_serializing_if = "Vec::is_empty")]
320    pub directory_renames: Vec<RenameEntry>,
321    /// Per-symbol deltas produced by the semantic driver
322    /// (function_renamed, function_added, function_deleted,
323    /// signature_changed, etc.). Present when semantic merge is active so
324    /// agents can detect that semantic analysis ran and act on the
325    /// rename/symbol mapping programmatically without parsing the
326    /// line-by-line `diff` payload. Absent (not `null`) when
327    /// `--no-semantic` is set or the build lacks semantic support.
328    /// An empty array means "semantic ran but found no symbol-level
329    /// deltas" (e.g. non-source files or a no-op fast-forward).
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub semantic_changes: Option<Vec<SemanticChangeEntry>>,
332    /// Diff between the parent's tip and the thread's tip. Populated
333    /// only when the caller passes `--with-diff`. On a successful
334    /// non-preview merge the from/to are the pre-merge parent tip and
335    /// the thread tip — i.e. the change set that just landed.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub diff: Option<DiffReport>,
338    /// Preview of the git commit that *would* be written if the user
339    /// re-ran without `--preview`. Populated only with
340    /// `--git-commit --preview`.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub git_commit_preview: Option<GitCommitPreview>,
343    /// Real git commit written by `--git-commit` on a non-preview
344    /// merge. Populated only after a successful, non-conflict merge
345    /// when `--git-commit` was set.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub git_commit: Option<GitCommitInfo>,
348    #[serde(skip_serializing)]
349    #[serde(skip_serializing_if = "Option::is_none")]
350    #[serde(rename = "verification")]
351    pub trust: Option<RepositoryVerificationState>,
352}
353
354struct MergeReportInput<'a> {
355    repo: &'a Repository,
356    /// Real machine-contract coverage, injected by the CLI shell so the
357    /// operator report's trust classification matches `heddle verify`
358    /// (contract gaps stay visible instead of degrading to `not_checked`).
359    machine_contract: &'a MachineContractInput,
360    thread: &'a Option<Thread>,
361    preview_report: Option<&'a ThreadPreviewReport>,
362    conflicts: Option<Vec<String>>,
363    merge_relation: Option<String>,
364    conflict_count: Option<usize>,
365    changed_paths: Option<Vec<String>>,
366    preview_summary: Vec<String>,
367    message: String,
368    renames: Vec<RenameEntry>,
369    directory_renames: Vec<RenameEntry>,
370    merge_state: Option<String>,
371    fast_forward: bool,
372    preview_only: bool,
373    diff: Option<DiffReport>,
374    git_commit_preview: Option<GitCommitPreview>,
375    git_commit: Option<GitCommitInfo>,
376    /// Extra blockers contributed by post-merge coordination steps
377    /// (e.g. `--git-commit` failing on dirty git state). Merged into
378    /// the operator's final `blockers` list and force `status` to
379    /// `"blocked"` even when the heddle merge itself completed.
380    extra_blockers: Vec<String>,
381    /// Top-level mirror of `diff.semantic_changes`. Threaded through
382    /// `MergeReportInput` so every return path sets it consistently
383    /// (instead of relying on each call site to remember). See the
384    /// field doc on `MergeReport::semantic_changes`.
385    semantic_changes: Option<Vec<SemanticChangeEntry>>,
386}
387
388struct SourceThreadUncapturedWork {
389    checkout_path: String,
390    dirty_paths: Vec<String>,
391}
392
393fn semantic_merge_enabled(no_semantic: bool) -> bool {
394    cfg!(feature = "semantic") && !no_semantic
395}
396
397fn merge_strategy_for(use_semantic: bool) -> MergeStrategy {
398    if use_semantic {
399        MergeStrategy::Semantic
400    } else {
401        MergeStrategy::HunkOnly
402    }
403}
404
405pub fn tree_merge_options(labels: ConflictLabels<'_>) -> EngineMergeOptions<'_> {
406    EngineMergeOptions {
407        labels,
408        rename_options: RenameOptions {
409            semantic_similarity: semantic_similarity_hook(),
410            ..RenameOptions::default()
411        },
412        semantic_merge: semantic_merge_hook(),
413    }
414}
415
416#[cfg(feature = "semantic")]
417fn semantic_merge_hook() -> Option<SemanticMergeFn> {
418    Some(semantic::merge_driver::semantic_three_way_merge)
419}
420
421#[cfg(not(feature = "semantic"))]
422fn semantic_merge_hook() -> Option<SemanticMergeFn> {
423    None
424}
425
426#[cfg(feature = "semantic")]
427fn semantic_similarity_hook() -> Option<SemanticSimilarityFn> {
428    Some(compute_semantic_similarity)
429}
430
431#[cfg(not(feature = "semantic"))]
432fn semantic_similarity_hook() -> Option<SemanticSimilarityFn> {
433    None
434}
435
436#[cfg(feature = "semantic")]
437fn compute_semantic_similarity(
438    from_path: &str,
439    to_path: &str,
440    from_content: &[u8],
441    to_content: &[u8],
442) -> f64 {
443    let Ok(from_str) = std::str::from_utf8(from_content) else {
444        return 0.0;
445    };
446    let Ok(to_str) = std::str::from_utf8(to_content) else {
447        return 0.0;
448    };
449
450    let language = semantic::parser::Language::from_path(std::path::Path::new(from_path));
451    let language = if language == semantic::parser::Language::Unknown {
452        semantic::parser::Language::from_path(std::path::Path::new(to_path))
453    } else {
454        language
455    };
456
457    semantic::analysis::analysis_similarity::compute_similarity_with_language(
458        from_str,
459        to_str,
460        semantic::analysis::analysis_similarity::SimilarityMethod::Ast,
461        language,
462    )
463}
464
465/// Strategy + target decided **once per merge attempt** and reused by
466/// preview, refresh, apply, and diff (HeddleCo/heddle#503).
467///
468/// Before this seam existed, `merge_thread_into_current` computed the
469/// content-merge strategy independently for the preview report and for
470/// the apply `MergePlan` (two separate `merge_strategy_for(use_semantic)`
471/// calls). They agreed only by construction — and a Codex r13 P2 finding
472/// documented a real preview-vs-actual divergence regression caused by
473/// exactly that kind of duplicated, drift-prone strategy state. Routing
474/// every consumer through one decision makes "preview == apply" an
475/// invariant the type system enforces rather than a discipline each call
476/// site must remember.
477#[derive(Clone, Copy, Debug, PartialEq, Eq)]
478pub struct MergeAttemptPlan {
479    strategy: MergeStrategy,
480    /// Whether semantic analysis feeds the `--with-diff` / symbol-delta
481    /// payload. Derived from the same `use_semantic` decision as
482    /// `strategy`, so the diff path can't drift from the content-merge
483    /// path either.
484    use_semantic: bool,
485}
486
487impl MergeAttemptPlan {
488    /// Decide the strategy for one `heddle merge` invocation. This is the
489    /// single point where `no_semantic` becomes a `MergeStrategy`; every
490    /// downstream consumer reads it back off the plan.
491    pub(crate) fn decide(no_semantic: bool) -> Self {
492        let use_semantic = semantic_merge_enabled(no_semantic);
493        Self {
494            strategy: merge_strategy_for(use_semantic),
495            use_semantic,
496        }
497    }
498
499    /// The content-merge strategy for this attempt. Consumed identically
500    /// by the preview report's 3-way merge and the apply `MergePlan`, so
501    /// the two cannot select different strategies.
502    pub(crate) fn strategy(&self) -> MergeStrategy {
503        self.strategy
504    }
505
506    /// Whether semantic analysis is active for this attempt's diff
507    /// payload. Mirrors `strategy() == Semantic`.
508    pub(crate) fn use_semantic(&self) -> bool {
509        self.use_semantic
510    }
511}
512
513#[allow(clippy::too_many_arguments)]
514/// Facade options for [`merge_thread`] / [`merge_thread_into_current`].
515#[derive(Clone, Debug)]
516pub struct MergeOptions {
517    pub track_name: String,
518    pub message: Option<String>,
519    pub no_commit: bool,
520    pub preview: bool,
521    pub with_diff: bool,
522    pub no_semantic: bool,
523    pub git_commit: bool,
524}
525
526/// Merge `opts.track_name` into the repository's current HEAD.
527pub fn merge_thread(repo: &Repository, opts: MergeOptions) -> Result<MergeReport> {
528    FacetKind::SourceHistory
529        .require_land()
530        .map_err(|kind| anyhow!("{kind} cannot be landed onto source history"))?;
531    merge_thread_into_current(
532        repo,
533        &opts.track_name,
534        opts.message,
535        opts.no_commit,
536        opts.preview,
537        opts.with_diff,
538        opts.no_semantic,
539        opts.git_commit,
540    )
541}
542
543// Back-compat entrypoint: wraps the machine-contract-aware variant with a
544// default (not_checked) contract. The arg count reflects the merge surface;
545// the contract-aware sibling carries one more.
546#[allow(clippy::too_many_arguments)]
547pub fn merge_thread_into_current(
548    repo: &Repository,
549    track_name: &str,
550    message: Option<String>,
551    no_commit: bool,
552    preview: bool,
553    with_diff: bool,
554    no_semantic: bool,
555    git_commit: bool,
556) -> Result<MergeReport> {
557    merge_thread_into_current_with_machine_contract(
558        repo,
559        track_name,
560        message,
561        no_commit,
562        preview,
563        with_diff,
564        no_semantic,
565        git_commit,
566        &MachineContractInput::default(),
567    )
568}
569
570/// Contract-aware entry point. The CLI shell passes the real machine-contract
571/// coverage (from the command catalog) so the operator report's trust
572/// classification matches `heddle verify`; embedders that lack a command
573/// catalog fall through the default (`not_checked`) via the wrapper above.
574#[allow(clippy::too_many_arguments)]
575pub fn merge_thread_into_current_with_machine_contract(
576    repo: &Repository,
577    track_name: &str,
578    message: Option<String>,
579    no_commit: bool,
580    preview: bool,
581    with_diff: bool,
582    no_semantic: bool,
583    git_commit: bool,
584    machine_contract: &MachineContractInput,
585) -> Result<MergeReport> {
586    merge_thread_into_current_transactional(
587        repo,
588        track_name,
589        message,
590        no_commit,
591        preview,
592        with_diff,
593        no_semantic,
594        git_commit,
595        machine_contract,
596        None,
597    )
598}
599
600/// Merge entrypoint with an optional caller-provided transaction identity.
601/// Land persists this identity before integration so the exact committed batch
602/// remains discoverable across every crash boundary.
603#[allow(clippy::too_many_arguments)]
604pub fn merge_thread_into_current_transactional(
605    repo: &Repository,
606    track_name: &str,
607    message: Option<String>,
608    no_commit: bool,
609    preview: bool,
610    with_diff: bool,
611    no_semantic: bool,
612    git_commit: bool,
613    machine_contract: &MachineContractInput,
614    transaction_id: Option<&str>,
615) -> Result<MergeReport> {
616    FacetKind::SourceHistory
617        .require_land()
618        .map_err(|kind| anyhow!("{kind} cannot be landed onto source history"))?;
619    // Strategy + diff-semantics decided ONCE per merge attempt
620    // (HeddleCo/heddle#503). Preview, refresh, apply, and diff all read
621    // their strategy back off this single plan instead of re-deriving it
622    // — so the preview can't pick a different content-merge strategy than
623    // the apply path, which is the documented Codex r13 divergence class.
624    let attempt = MergeAttemptPlan::decide(no_semantic);
625    let use_semantic = attempt.use_semantic();
626    let registry = ActorPresenceStore::new(repo.heddle_dir());
627    let thread_manager = ThreadManager::new(repo.heddle_dir());
628    let mut thread = thread_manager.find_by_thread(track_name)?;
629    if let Some(ref mut thread) = thread {
630        refresh_thread_freshness(repo, thread)?;
631    }
632    let thread_entry = registry
633        .list()?
634        .into_iter()
635        .filter(|entry| entry.thread == track_name)
636        .max_by_key(|entry| entry.started_at);
637
638    let merge_manager = repo.merge_state_manager();
639    if merge_manager.is_merge_in_progress() {
640        return Err(anyhow!(advice::merge_already_in_progress()));
641    }
642
643    if preview {
644        ensure_worktree_clean(repo, "merge")?;
645    }
646
647    if preview {
648        let trust = trust_state(repo, machine_contract)?;
649        if trust_blocks_merge_preview(&trust) {
650            return Ok(merge_blocked_by_trust_output(
651                &thread, None, trust, preview, None,
652            ));
653        }
654    }
655
656    let merge_target_id = repo
657        .refs()
658        .get_thread(&ThreadName::new(track_name))?
659        .ok_or_else(|| anyhow!(advice::thread_not_found(track_name, "merge")))?;
660
661    let current_change = repo
662        .current_state()?
663        .map(|state| state.state_id)
664        .ok_or_else(|| {
665            anyhow!(
666                "No current state to merge into; capture or bootstrap the repository before merging"
667            )
668        })?;
669    let current_state = repo
670        .store()
671        .get_state(&current_change)?
672        .ok_or_else(|| anyhow!("Current state not found"))?;
673
674    ensure_worktree_clean(repo, "merge")?;
675
676    let mut graph = CommitGraphIndex::new(repo);
677    // Codex r13 P2: the preview report's content-merge strategy must
678    // match the strategy the actual merge plan (below) will use, so
679    // the `preview_summary` lines don't contradict the real outcome
680    // (e.g. reporting `conflicts: 1 path conflict(s)` on a structural
681    // reshape that semantic resolves cleanly). Both now read the SAME
682    // decision off `attempt` (#503), so they cannot diverge.
683    let current_thread = repo
684        .current_lane()?
685        .unwrap_or_else(|| "detached".to_string());
686    // heddle#144: the inner preview report MUST compute its 3-way merge
687    // against the actual destination of *this* merge (the operator's
688    // current HEAD), not `thread.target_thread`. Otherwise running
689    // `heddle merge A` from a thread B whose tip diverges from A's
690    // recorded target (often `main`) yields a preview whose
691    // `preview_summary` line claims one outcome while the apply path
692    // produces another.
693    let preview_report = match thread.as_mut() {
694        Some(thread) => Some(build_thread_preview_report_with_graph(
695            repo,
696            &mut graph,
697            thread,
698            preview,
699            attempt.strategy(),
700            Some(PreviewTarget {
701                label: &current_thread,
702                state_id: current_state.state_id,
703            }),
704        )?),
705        None => None,
706    };
707    if let Some(thread) = thread.as_ref()
708        && let Some(uncaptured) = source_thread_uncaptured_work(repo, thread)?
709    {
710        return Err(anyhow!(advice::source_thread_uncaptured_work(
711            &thread.id,
712            &uncaptured.checkout_path,
713            &uncaptured.dirty_paths,
714            preview,
715        )));
716    }
717    if let Some(output) = merge_freshness_preflight_output(
718        repo,
719        machine_contract,
720        &thread,
721        preview_report.as_ref(),
722        preview,
723    )? {
724        return Ok(output);
725    }
726    let preview_summary = build_preview_summary(preview_report.as_ref());
727    let current_label = format!("CURRENT ({current_thread})");
728    let incoming_label = format!("INCOMING ({track_name})");
729    let merge_plan = MergePlan::for_merge_command(
730        repo,
731        &mut graph,
732        &current_state.state_id,
733        &merge_target_id,
734        ConflictLabels {
735            current: &current_label,
736            incoming: &incoming_label,
737            strategy: attempt.strategy(),
738        },
739    )?;
740
741    // Helper for the `--with-diff` payload. Each branch picks the right
742    // (from, to) once it knows what actually landed — see the per-branch
743    // calls below. Pre-fix, the function computed a single
744    // `current..merge_target` diff up-front and reused it everywhere; that
745    // payload is wrong for non-fast-forward 3-way merges (it can include
746    // removals of current-branch edits that the merge actually preserves)
747    // and for `AlreadyUpToDate` (it can be non-empty when nothing landed).
748    let diff_for = |from: &StateId, to: &StateId| -> Result<Option<DiffReport>> {
749        if !with_diff {
750            return Ok(None);
751        }
752        Ok(Some(compute_state_diff(repo, from, to, use_semantic, 3)?))
753    };
754    // heddle#153: surface per-symbol deltas at the top level so agents
755    // can detect that semantic analysis ran and act on the rename
756    // mapping without digging into `diff.semantic_changes`. We derive
757    // this from the (already-computed) diff payload when both semantic
758    // merge and `--with-diff` are active; without `--with-diff` the
759    // diff isn't computed at all, so there's nothing to mirror. Use
760    // `Some(vec![])` (not `None`) on the with-diff+semantic path even
761    // when the driver found no symbol changes, so consumers can branch
762    // on field presence to detect "semantic mode honored".
763    let top_level_semantic = |diff: Option<&DiffReport>| -> Option<Vec<SemanticChangeEntry>> {
764        if !use_semantic || !with_diff {
765            return None;
766        }
767        Some(
768            diff.and_then(|d| d.semantic_changes.clone())
769                .unwrap_or_default(),
770        )
771    };
772
773    if merge_plan.relation().kind() == MergeRelationKind::AlreadyUpToDate {
774        let trust = trust_state(repo, machine_contract)?;
775        if !trust.verified {
776            return Ok(merge_blocked_by_trust_output(
777                &thread,
778                preview_report.as_ref(),
779                trust,
780                preview,
781                Some(merge_plan.relation().as_json_value().to_string()),
782            ));
783        }
784        // Already-up-to-date means the merge doesn't write anything — the
785        // current state already contains the target. The honest diff is
786        // empty; producing `current..target` would make the JSON falsely
787        // claim a change landed.
788        let already_up_to_date_diff = if with_diff {
789            Some(empty_diff_output(&current_state.state_id))
790        } else {
791            None
792        };
793        return merge_output_from_report(MergeReportInput {
794            repo,
795            machine_contract,
796            thread: &thread,
797            preview_report: preview_report.as_ref(),
798            conflicts: Some(vec![]),
799            merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
800            conflict_count: Some(0),
801            changed_paths: Some(Vec::new()),
802            preview_summary: vec![],
803            message: "Already up to date".to_string(),
804            renames: vec![],
805            directory_renames: vec![],
806            merge_state: None,
807            fast_forward: false,
808            preview_only: preview,
809            semantic_changes: top_level_semantic(already_up_to_date_diff.as_ref()),
810            diff: already_up_to_date_diff,
811            git_commit_preview: None,
812            git_commit: None,
813            extra_blockers: Vec::new(),
814        });
815    }
816
817    if merge_plan.relation().kind() == MergeRelationKind::FastForward {
818        // Use the parent↔thread-tip diff as the source of truth for
819        // which paths the merge writes — see `merge_changed_paths` for
820        // why thread.changed_paths can't be relied on here.
821        let ff_paths = merge_changed_paths(repo, &current_state.state_id, &merge_target_id)?;
822
823        // FF: current..target IS the change set that lands. Compute once
824        // and reuse for any per-branch return below.
825        let (ff_renames, ff_directory_renames) =
826            fast_forward_renames(repo, &current_state.state_id, &merge_target_id)?;
827        let ff_diff = diff_for(&current_state.state_id, &merge_target_id)?
828            .map(|diff| diff_with_known_renames(diff, &ff_renames));
829
830        // Pre-flight `--git-commit` validation (real merge only). On
831        // preview we skip the dirty-tree check — the operator hasn't
832        // committed to landing anything yet, just wants to see the
833        // would-be commit message.
834        let mut git_commit_blockers: Vec<String> = Vec::new();
835        if git_commit
836            && !preview
837            && let Err(blocked) = git_commit::validate_git_state(repo, &ff_paths)
838        {
839            git_commit_blockers = blocked.blockers;
840        }
841
842        if !git_commit_blockers.is_empty() {
843            // Fail loudly *before* advancing heddle state.
844            return merge_output_from_report(MergeReportInput {
845                repo,
846                machine_contract,
847                thread: &thread,
848                preview_report: preview_report.as_ref(),
849                conflicts: Some(vec![]),
850                merge_relation: Some("fast_forward".to_string()),
851                conflict_count: Some(0),
852                changed_paths: Some(ff_paths.clone()),
853                preview_summary,
854                message: "Fast-forward blocked: --git-commit precondition failed".to_string(),
855                renames: ff_renames,
856                directory_renames: ff_directory_renames,
857                merge_state: None,
858                fast_forward: false,
859                preview_only: preview,
860                semantic_changes: top_level_semantic(ff_diff.as_ref()),
861                diff: ff_diff,
862                git_commit_preview: None,
863                git_commit: None,
864                extra_blockers: git_commit_blockers,
865            });
866        }
867
868        let git_branch_before = if git_commit && !preview {
869            Some(
870                repo.git_overlay_current_branch()?
871                    .unwrap_or_else(|| "HEAD".to_string()),
872            )
873        } else {
874            None
875        };
876        let git_oid_before = if git_commit && !preview {
877            git_rev_parse_head(repo.root())
878        } else {
879            None
880        };
881        let source_git_parent = if git_commit {
882            source_git_parent_for_thread(repo, track_name, &merge_target_id)?
883        } else {
884            None
885        };
886        let mut git_commit_preview_payload: Option<GitCommitPreview> = None;
887        let mut git_commit_info: Option<GitCommitInfo> = None;
888
889        if !preview {
890            // Preserve attached-HEAD semantics on fast-forward: if HEAD is
891            // attached to a thread, advance that thread's ref so
892            // `heddle merge X` from inside thread Y leaves Y pointing at
893            // the integrated state. See `Repository::fast_forward_attached`
894            // and the regression test
895            // `merge_fast_forward_advances_current_thread`.
896            //
897            // We perform the FF *without recording* an `OpRecord::Goto`
898            // and then explicitly record `OpRecord::FastForward` so
899            // both ends of the FF are captured. r1 (heddle#99) added the
900            // variant to fix stranded-ref-on-undo. r2 added
901            // `post_target_id` so redo replays the recorded SHA instead
902            // of re-resolving `source_thread → tip` at apply time —
903            // closes Codex's non-determinism finding on PR #109.
904            if let Some(transaction_id) = transaction_id {
905                repo.fast_forward_attached_transactional(
906                    track_name,
907                    &merge_target_id,
908                    transaction_id,
909                )?;
910            } else {
911                let head_before_ff = repo.head_ref()?;
912                repo.fast_forward_attached_without_record(&merge_target_id)?;
913                match &head_before_ff {
914                    Head::Attached {
915                        thread: target_thread,
916                    } => {
917                        repo.oplog().record_fast_forward(
918                            &ThreadName::new(track_name),
919                            target_thread,
920                            &current_state.state_id,
921                            &merge_target_id,
922                            Some(&repo.op_scope()),
923                        )?;
924                    }
925                    Head::Detached { state } => {
926                        repo.oplog().record_goto(
927                            &merge_target_id,
928                            Some(state),
929                            Some(&repo.op_scope()),
930                        )?;
931                    }
932                }
933            }
934            if let Some(entry) = &thread_entry {
935                registry.update_status(&entry.session_id, ActorPresenceStatus::Merged)?;
936            }
937            if let Some(thread) = thread.as_mut() {
938                thread.state = ThreadState::Merged;
939                thread.merged_state = Some(merge_target_id.short());
940                thread.current_state = Some(merge_target_id.short());
941                thread.updated_at = chrono::Utc::now();
942                thread.freshness = ThreadFreshness::Current;
943                thread_manager.save(thread)?;
944            }
945
946            if git_commit {
947                // FF advances heddle to `merge_target_id` (the thread
948                // tip). Use that as the `Merge-State` trailer — there's
949                // no synthetic merge state on a fast-forward.
950                let attribution = Attribution::human(repo.get_principal()?);
951                let ff_message = preview_merge_message(repo, &message, thread.as_ref(), track_name);
952                let commit_message = git_commit::build_commit_message(
953                    &ff_message,
954                    &merge_target_id.short(),
955                    &attribution,
956                );
957                let extra_parents = source_git_parent.clone().into_iter().collect::<Vec<_>>();
958                let info = git_commit::write_git_commit(
959                    repo,
960                    &merge_target_id,
961                    &ff_paths,
962                    &commit_message,
963                    &extra_parents,
964                )?;
965                finalize_merge_git_checkpoint(
966                    repo,
967                    &merge_target_id,
968                    git_branch_before.unwrap_or_else(|| "HEAD".to_string()),
969                    git_oid_before,
970                    &info.sha,
971                    &ff_message,
972                )?;
973                git_commit_info = Some(info);
974            }
975        } else if git_commit {
976            // Preview path: render the would-be commit message.
977            let attribution = Attribution::human(repo.get_principal()?);
978            let ff_message = preview_merge_message(repo, &message, thread.as_ref(), track_name);
979            let preview_msg = git_commit::build_commit_message(
980                &ff_message,
981                &merge_target_id.short(),
982                &attribution,
983            );
984            git_commit_preview_payload = Some(GitCommitPreview {
985                message: preview_msg,
986                files: ff_paths.clone(),
987            });
988        }
989        let output_changed_paths = ff_paths.clone();
990        let output_changed_path_count = output_changed_paths.len();
991
992        let recommended_action = if preview {
993            if let Some(thread) = thread.as_ref() {
994                if thread.state == ThreadState::Ready {
995                    mark_merge_previewed(repo, &thread.id)?;
996                }
997                if let Some(report) = preview_report.as_ref()
998                    && !report.blockers.is_empty()
999                    && !report.recommended_action.trim().is_empty()
1000                    && report
1001                        .blockers
1002                        .iter()
1003                        .any(|blocker| is_real_merge_blocker(blocker))
1004                {
1005                    Some(report.recommended_action.clone())
1006                } else {
1007                    Some(land_local_command(&thread.thread))
1008                }
1009            } else {
1010                None
1011            }
1012        } else {
1013            None
1014        };
1015        return Ok(MergeReport {
1016            operator: OperatorCommandOutput {
1017                status: if preview { "preview" } else { "completed" }.to_string(),
1018                action: OperatorAction::Merge,
1019                message: match (preview, git_commit, repo.head_ref()?) {
1020                    (true, true, Head::Attached { thread }) => {
1021                        format!(
1022                            "Would advance {} to {} and write a Git checkpoint commit",
1023                            thread,
1024                            merge_target_id.short()
1025                        )
1026                    }
1027                    (true, true, Head::Detached { .. }) => {
1028                        format!(
1029                            "Would advance to {} and write a Git checkpoint commit",
1030                            merge_target_id.short()
1031                        )
1032                    }
1033                    (false, true, Head::Attached { thread }) => {
1034                        format!(
1035                            "Advanced {} to {} and wrote a Git checkpoint commit",
1036                            thread,
1037                            merge_target_id.short()
1038                        )
1039                    }
1040                    (false, true, Head::Detached { .. }) => {
1041                        format!(
1042                            "Advanced to {} and wrote a Git checkpoint commit",
1043                            merge_target_id.short()
1044                        )
1045                    }
1046                    (true, false, Head::Attached { thread }) => {
1047                        format!(
1048                            "Would fast-forward {} to {}",
1049                            thread,
1050                            merge_target_id.short()
1051                        )
1052                    }
1053                    (true, false, Head::Detached { .. }) => {
1054                        format!("Would fast-forward to {}", merge_target_id.short())
1055                    }
1056                    (false, false, Head::Attached { thread }) => {
1057                        format!("Fast-forwarded {} to {}", thread, merge_target_id.short())
1058                    }
1059                    (false, false, Head::Detached { .. }) => {
1060                        format!("Fast-forwarded to {}", merge_target_id.short())
1061                    }
1062                },
1063                // Fast-forward never has conflicts, so anything in
1064                // the preview-stage `blockers` list is advisory. The
1065                // operation either advanced state (apply path) or
1066                // would advance state (preview path) — either way
1067                // these belong in `warnings`, not `blockers`.
1068                blockers: Vec::new(),
1069                warnings: preview_report
1070                    .as_ref()
1071                    .map(|r| r.blockers.clone())
1072                    .unwrap_or_default(),
1073                next_action: recommended_action.clone(),
1074                recommended_action: recommended_action.clone(),
1075            },
1076            would_merge: preview,
1077            applied: !preview,
1078            fast_forward: true,
1079            preview_only: preview,
1080            merge_state: (!preview).then(|| merge_target_id.short()),
1081            conflicts: vec![],
1082            preview_summary,
1083            thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
1084            freshness: thread.as_ref().map(|thread| thread.freshness.to_string()),
1085            changed_paths: output_changed_paths,
1086            changed_path_count: output_changed_path_count,
1087            impact_categories: thread_impacts(&thread),
1088            promotion_suggested: thread
1089                .as_ref()
1090                .map(|thread| thread.promotion_suggested)
1091                .unwrap_or(false),
1092            heavy_impact_paths: thread_heavy_paths(&thread),
1093            merge_relation: Some("fast_forward".to_string()),
1094            conflict_count: 0,
1095            thread_health: merge_output_thread_health(thread.as_ref(), preview_report.as_ref()),
1096            renames: ff_renames,
1097            directory_renames: ff_directory_renames,
1098            semantic_changes: top_level_semantic(ff_diff.as_ref()),
1099            diff: ff_diff,
1100            git_commit_preview: git_commit_preview_payload,
1101            git_commit: git_commit_info,
1102            trust: Some({
1103                let mut trust = trust_state(repo, machine_contract)?;
1104                if let Some(action) = recommended_action.as_ref() {
1105                    override_trust_recommended_action(&mut trust, action.clone());
1106                }
1107                trust
1108            }),
1109        });
1110    }
1111
1112    let merge_base_id = merge_plan
1113        .relation()
1114        .merge_base_id()
1115        .ok_or_else(|| anyhow!("Merge base missing from merge plan"))?;
1116    let merge_result = merge_plan
1117        .merge_result()
1118        .ok_or_else(|| anyhow!("Merge result missing from merge plan"))?;
1119    let rename_entries: Vec<RenameEntry> = merge_result
1120        .renames
1121        .iter()
1122        .map(|rename| RenameEntry {
1123            from: rename.from.clone(),
1124            to: rename.to.clone(),
1125            score: rename.score,
1126        })
1127        .collect();
1128    let dir_rename_entries: Vec<RenameEntry> = merge_result
1129        .directory_renames
1130        .iter()
1131        .map(|rename| RenameEntry {
1132            from: rename.from.clone(),
1133            to: rename.to.clone(),
1134            score: 1.0,
1135        })
1136        .collect();
1137
1138    if preview {
1139        // For `--git-commit --preview`, render the would-be commit
1140        // message so the operator can review it before re-running
1141        // without `--preview`. We can't surface a real `Merge-State`
1142        // change-id (no merge state has been written yet) — emit the
1143        // placeholder `<pending>` and let real-mode produce the final
1144        // trailer once the merge state exists.
1145        let git_commit_preview = if git_commit && merge_result.conflicts.is_empty() {
1146            let preview_message =
1147                preview_merge_message(repo, &message, thread.as_ref(), track_name);
1148            let attribution = Attribution::human(repo.get_principal()?);
1149            let preview_msg =
1150                git_commit::build_commit_message(&preview_message, "<pending>", &attribution);
1151            Some(GitCommitPreview {
1152                message: preview_msg,
1153                files: merge_changed_paths(repo, &current_state.state_id, &merge_target_id)?,
1154            })
1155        } else {
1156            None
1157        };
1158        // 3-way preview diff: report the computed merge tree, not
1159        // `current..merge_target`. The source-tip diff can show
1160        // deletions of files that only exist on the destination branch
1161        // even though a real 3-way merge would preserve them.
1162        let preview_path_diff = compute_tree_diff(
1163            repo,
1164            &current_state.state_id,
1165            &merge_result.tree,
1166            "<merged-preview>",
1167            with_diff && use_semantic,
1168            if with_diff { 3 } else { 0 },
1169        )
1170        .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1171        let preview_changed_paths = diff_changed_paths(&preview_path_diff);
1172        let preview_diff = with_diff.then_some(preview_path_diff);
1173        if merge_result.conflicts.is_empty()
1174            && thread
1175                .as_ref()
1176                .is_some_and(|thread| thread.state == ThreadState::Ready)
1177            && let Some(thread) = thread.as_ref()
1178        {
1179            mark_merge_previewed(repo, &thread.id)?;
1180        }
1181        return merge_output_from_report(MergeReportInput {
1182            repo,
1183            machine_contract,
1184            thread: &thread,
1185            preview_report: preview_report.as_ref(),
1186            conflicts: Some(merge_result.conflicts.clone()),
1187            merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1188            conflict_count: Some(merge_plan.relation().conflict_count()),
1189            changed_paths: Some(preview_changed_paths.clone()),
1190            preview_summary,
1191            message: merge_preview_message(
1192                thread.as_ref(),
1193                track_name,
1194                merge_result.conflicts.len(),
1195                preview_changed_paths.len(),
1196            ),
1197            renames: rename_entries.clone(),
1198            directory_renames: dir_rename_entries.clone(),
1199            merge_state: None,
1200            fast_forward: false,
1201            preview_only: true,
1202            semantic_changes: top_level_semantic(preview_diff.as_ref()),
1203            diff: preview_diff,
1204            git_commit_preview,
1205            git_commit: None,
1206            extra_blockers: Vec::new(),
1207        });
1208    }
1209
1210    apply_merged_tree(repo, &merge_result.tree)?;
1211
1212    if !merge_result.conflicts.is_empty() {
1213        let structured_conflicts = merge_plan
1214            .structured_conflicts()
1215            .map(|payload| -> Result<ContentHash> {
1216                let bytes = payload.encode()?;
1217                Ok(repo.store().put_blob(&Blob::new(bytes))?)
1218            })
1219            .transpose()?;
1220        merge_manager.start(
1221            current_state.state_id,
1222            merge_target_id,
1223            Some(merge_base_id),
1224            merge_result.conflicts.clone(),
1225            structured_conflicts,
1226        )?;
1227        // Conflicted merge: the merge wrote a partial tree containing
1228        // conflict markers. Reporting either `current..target` or
1229        // `current..merge_result.tree` here would be misleading — the
1230        // user must resolve before any well-defined diff exists. Empty
1231        // diff is the honest signal.
1232        let conflict_diff = if with_diff {
1233            Some(empty_diff_output(&current_state.state_id))
1234        } else {
1235            None
1236        };
1237        return merge_output_from_report(MergeReportInput {
1238            repo,
1239            machine_contract,
1240            thread: &thread,
1241            preview_report: preview_report.as_ref(),
1242            conflicts: Some(merge_result.conflicts.clone()),
1243            merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1244            conflict_count: Some(merge_plan.relation().conflict_count()),
1245            changed_paths: Some(merge_result.conflicts.clone()),
1246            preview_summary,
1247            message: "Merged with conflicts".to_string(),
1248            renames: rename_entries,
1249            directory_renames: dir_rename_entries,
1250            merge_state: None,
1251            fast_forward: false,
1252            preview_only: false,
1253            semantic_changes: top_level_semantic(conflict_diff.as_ref()),
1254            diff: conflict_diff,
1255            git_commit_preview: None,
1256            git_commit: None,
1257            extra_blockers: Vec::new(),
1258        });
1259    }
1260
1261    if no_commit {
1262        // 3-way clean merge, not committed. The actual change set is
1263        // `current_tree..merge_result.tree`, but the merged tree isn't
1264        // yet a committed `State` — `compute_state_diff` can't run, and
1265        // the public `DiffReport`/`FileChange` constructor surface goes
1266        // through a private module we can't import here. Document the
1267        // gap honestly: when the operator passes `--with-diff` together
1268        // with `--no-commit`, surface `None`; the diff materializes on
1269        // the post-snapshot path. Re-running without `--no-commit` (or
1270        // running `heddle diff` against the new state) recovers the
1271        // full payload.
1272        let no_commit_path_diff = compute_tree_diff(
1273            repo,
1274            &current_state.state_id,
1275            &merge_result.tree,
1276            "<merged-no-commit>",
1277            false,
1278            0,
1279        )
1280        .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1281        let no_commit_changed_paths = diff_changed_paths(&no_commit_path_diff);
1282        let no_commit_diff: Option<DiffReport> = None;
1283        return merge_output_from_report(MergeReportInput {
1284            repo,
1285            machine_contract,
1286            thread: &thread,
1287            preview_report: preview_report.as_ref(),
1288            conflicts: Some(vec![]),
1289            merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1290            conflict_count: Some(merge_plan.relation().conflict_count()),
1291            changed_paths: Some(no_commit_changed_paths),
1292            preview_summary,
1293            message: "Merge applied (not committed)".to_string(),
1294            renames: rename_entries,
1295            directory_renames: dir_rename_entries,
1296            merge_state: None,
1297            fast_forward: false,
1298            preview_only: false,
1299            semantic_changes: top_level_semantic(no_commit_diff.as_ref()),
1300            diff: no_commit_diff,
1301            git_commit_preview: None,
1302            git_commit: None,
1303            extra_blockers: Vec::new(),
1304        });
1305    }
1306
1307    let merge_message =
1308        message.unwrap_or_else(|| default_merge_message(repo, thread.as_ref(), track_name));
1309
1310    let attribution = Attribution::human(repo.get_principal()?);
1311    // If `--git-commit` is set, validate git state *before* writing
1312    // the heddle merge state. That way a dirty git tree can't leave us
1313    // with a half-coordinated outcome (heddle merged, git rejected).
1314    //
1315    // Derive paths from the parent↔thread-tip diff rather than
1316    // `thread.changed_paths`: thread metadata is lazily refreshed and
1317    // can be empty in synthetic / lightweight setups, but the diff is
1318    // ground truth for what the merge actually wrote.
1319    let merge_paths: Vec<String> = if git_commit {
1320        merge_changed_paths(repo, &current_state.state_id, &merge_target_id)?
1321    } else {
1322        Vec::new()
1323    };
1324    let mut git_commit_blockers: Vec<String> = Vec::new();
1325    if git_commit {
1326        if let Err(blocked) = git_commit::validate_git_state(repo, &merge_paths) {
1327            git_commit_blockers = blocked.blockers;
1328        }
1329        // Extended pre-flight: check anything else we can dry-run before
1330        // writing heddle state. The original `validate_git_state` covers
1331        // dirty-tree and detached-HEAD; this catches missing commit
1332        // identity and missing changed paths — both produce
1333        // post-snapshot failures that leave heddle advanced and git
1334        // uncommitted. Fail closed BEFORE `snapshot_merge_with_attribution`
1335        // runs.
1336        let extended = validate_git_commit_preconditions_extended(repo.root(), &merge_paths);
1337        git_commit_blockers.extend(extended);
1338    }
1339    if !git_commit_blockers.is_empty() {
1340        // Surface as a `blocked` outcome — heddle hasn't committed
1341        // anything yet, so the operator can fix git and retry without
1342        // any cleanup. Empty diff: nothing landed, so nothing to
1343        // describe.
1344        let blocked_diff = if with_diff {
1345            Some(empty_diff_output(&current_state.state_id))
1346        } else {
1347            None
1348        };
1349        return merge_output_from_report(MergeReportInput {
1350            repo,
1351            machine_contract,
1352            thread: &thread,
1353            preview_report: preview_report.as_ref(),
1354            conflicts: Some(vec![]),
1355            merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1356            conflict_count: Some(merge_plan.relation().conflict_count()),
1357            changed_paths: Some(Vec::new()),
1358            preview_summary,
1359            message: "Merge blocked: git --git-commit precondition failed".to_string(),
1360            renames: rename_entries,
1361            directory_renames: dir_rename_entries,
1362            merge_state: None,
1363            fast_forward: false,
1364            preview_only: false,
1365            semantic_changes: top_level_semantic(blocked_diff.as_ref()),
1366            diff: blocked_diff,
1367            git_commit_preview: None,
1368            git_commit: None,
1369            extra_blockers: git_commit_blockers,
1370        });
1371    }
1372
1373    let git_branch_before = if git_commit {
1374        Some(
1375            repo.git_overlay_current_branch()?
1376                .unwrap_or_else(|| "HEAD".to_string()),
1377        )
1378    } else {
1379        None
1380    };
1381    let git_oid_before = if git_commit {
1382        git_rev_parse_head(repo.root())
1383    } else {
1384        None
1385    };
1386    let source_git_parent = if git_commit {
1387        source_git_parent_for_thread(repo, track_name, &merge_target_id)?
1388    } else {
1389        None
1390    };
1391
1392    let new_state = repo.snapshot_merge_with_attribution_transaction(
1393        &merge_target_id,
1394        Some(merge_message.clone()),
1395        None,
1396        attribution.clone(),
1397        Some(merge_base_id),
1398        false,
1399        transaction_id,
1400    )?;
1401
1402    if let Some(entry) = &thread_entry {
1403        registry.update_status(&entry.session_id, ActorPresenceStatus::Merged)?;
1404    }
1405    if let Some(thread) = thread.as_mut() {
1406        thread.state = ThreadState::Merged;
1407        thread.merged_state = Some(new_state.state_id.short());
1408        thread.current_state = Some(new_state.state_id.short());
1409        thread.updated_at = chrono::Utc::now();
1410        thread.freshness = ThreadFreshness::Current;
1411        thread_manager.save(thread)?;
1412    }
1413
1414    // Heddle has advanced. If `--git-commit` is set we attempt the git
1415    // commit now — but we DON'T `?`-propagate a failure. Up-front
1416    // validation already drained every dry-runnable failure mode; what
1417    // remains (hooks rejecting, identity rotated mid-call, concurrent
1418    // index lock, FS errors) we surface as a structured `blocked`
1419    // outcome with a precise recovery hint pointing at the intact
1420    // heddle merge state. The operator can resolve git and re-run
1421    // `git commit` manually without losing the merge.
1422    let mut git_commit_info: Option<GitCommitInfo> = None;
1423    let mut post_snapshot_git_blockers: Vec<String> = Vec::new();
1424    if git_commit {
1425        let commit_message = git_commit::build_commit_message(
1426            &merge_message,
1427            &new_state.state_id.short(),
1428            &attribution,
1429        );
1430        let extra_parents = source_git_parent.clone().into_iter().collect::<Vec<_>>();
1431        match git_commit::write_git_commit(
1432            repo,
1433            &new_state.state_id,
1434            &merge_paths,
1435            &commit_message,
1436            &extra_parents,
1437        ) {
1438            Ok(info) => {
1439                git_commit_info = Some(info.clone());
1440                if let Err(err) = finalize_merge_git_checkpoint(
1441                    repo,
1442                    &new_state.state_id,
1443                    git_branch_before.unwrap_or_else(|| "HEAD".to_string()),
1444                    git_oid_before,
1445                    &info.sha,
1446                    &merge_message,
1447                ) {
1448                    tracing::warn!(
1449                        error = %err,
1450                        state = %new_state.state_id.short(),
1451                        git_commit = %info.sha,
1452                        "git commit succeeded after Heddle integration, but Git metadata recording failed"
1453                    );
1454                    post_snapshot_git_blockers.push(format!(
1455                        "git commit {} was written for integrated Heddle state {}, but Git metadata recording failed: {}",
1456                        info.sha,
1457                        new_state.state_id.short(),
1458                        err
1459                    ));
1460                    post_snapshot_git_blockers.push(format!(
1461                        "recovery: integrated Heddle state {} and Git commit {} are intact; run `heddle verify` \
1462                         and use its primary recovery command before undoing this integration",
1463                        new_state.state_id.short(),
1464                        info.sha
1465                    ));
1466                }
1467            }
1468            Err(err) => {
1469                tracing::warn!(
1470                    error = %err,
1471                    state = %new_state.state_id.short(),
1472                    "git commit failed after the integrated Heddle state was written"
1473                );
1474                post_snapshot_git_blockers.push(format!(
1475                    "git commit failed after Heddle integration state {} landed: {}",
1476                    new_state.state_id.short(),
1477                    err
1478                ));
1479                post_snapshot_git_blockers.push(format!(
1480                    "recovery: integrated Heddle state {} is intact; resolve the Git checkout issue \
1481                     (identity, locks, or filesystem errors) and run `heddle capture -m \"{}\"` — do NOT re-run the integration",
1482                    new_state.state_id.short(),
1483                    merge_message
1484                ));
1485            }
1486        }
1487    }
1488
1489    // 3-way committed merge: `new_state` is the actual landed state.
1490    // Compute the diff from current → new_state so the JSON describes
1491    // the change set the user can audit, NOT `current..merge_target`
1492    // which can include removals of current-branch edits the merge
1493    // preserved.
1494    let committed_path_diff = compute_state_diff(
1495        repo,
1496        &current_state.state_id,
1497        &new_state.state_id,
1498        with_diff && use_semantic,
1499        if with_diff { 3 } else { 0 },
1500    )
1501    .map(|diff| diff_with_known_renames(diff, &rename_entries))?;
1502    let committed_changed_paths = diff_changed_paths(&committed_path_diff);
1503    let committed_diff = with_diff.then_some(committed_path_diff);
1504
1505    let final_message = if post_snapshot_git_blockers.is_empty() {
1506        format!("Merged as {}", new_state.state_id.short())
1507    } else {
1508        format!(
1509            "Merged as {} (heddle); git commit failed",
1510            new_state.state_id.short()
1511        )
1512    };
1513
1514    merge_output_from_report(MergeReportInput {
1515        repo,
1516        machine_contract,
1517        thread: &thread,
1518        preview_report: preview_report.as_ref(),
1519        conflicts: Some(vec![]),
1520        merge_relation: Some(merge_plan.relation().as_json_value().to_string()),
1521        conflict_count: Some(merge_plan.relation().conflict_count()),
1522        changed_paths: Some(committed_changed_paths),
1523        preview_summary,
1524        message: final_message,
1525        renames: rename_entries,
1526        directory_renames: dir_rename_entries,
1527        merge_state: Some(new_state.state_id.short()),
1528        fast_forward: false,
1529        preview_only: false,
1530        semantic_changes: top_level_semantic(committed_diff.as_ref()),
1531        diff: committed_diff,
1532        git_commit_preview: None,
1533        git_commit: git_commit_info,
1534        extra_blockers: post_snapshot_git_blockers,
1535    })
1536}
1537
1538fn land_local_command(thread_id: &str) -> String {
1539    if thread_id.starts_with('-') {
1540        format!("heddle land --thread -- {thread_id}")
1541    } else {
1542        format!("heddle land --thread {thread_id}")
1543    }
1544}
1545
1546fn land_command_for_thread(repo: &Repository, thread_id: &str) -> String {
1547    // Core cannot resolve remotes via CLI remote helpers; prefer local land.
1548    let _ = repo;
1549    land_local_command(thread_id)
1550}
1551
1552fn mark_merge_previewed(repo: &Repository, thread_id: &str) -> Result<()> {
1553    let manager = ThreadManager::new(repo.heddle_dir());
1554    let mut thread = manager
1555        .load_id_or_name(thread_id)?
1556        .ok_or_else(|| anyhow!(advice::thread_not_found(thread_id, "mark merge previewed")))?;
1557    thread.integration_policy_result = ThreadIntegrationPolicy {
1558        status: Some("previewed".to_string()),
1559        reason: Some("clean merge preview established land path".to_string()),
1560        manual_resolution_state: thread.integration_policy_result.manual_resolution_state,
1561        conflicts_resolved_manually: thread.integration_policy_result.conflicts_resolved_manually,
1562    };
1563    manager.save(&thread)?;
1564    Ok(())
1565}
1566
1567/// Build a stand-in commit message for `--git-commit --preview` output.
1568/// Mirrors the real-mode logic in the apply path but doesn't allocate
1569/// a heddle merge state — used only for the preview surface.
1570fn preview_merge_message(
1571    repo: &Repository,
1572    explicit: &Option<String>,
1573    thread: Option<&Thread>,
1574    track_name: &str,
1575) -> String {
1576    if let Some(msg) = explicit.as_ref() {
1577        return msg.clone();
1578    }
1579    default_merge_message(repo, thread, track_name)
1580}
1581
1582fn default_merge_message(repo: &Repository, thread: Option<&Thread>, track_name: &str) -> String {
1583    if let Some(intent) =
1584        thread.and_then(|thread| state_intent(repo, thread.current_state.as_deref()))
1585        && !crate::subject_has_machine_identity(&intent)
1586    {
1587        return intent;
1588    }
1589    let name = if crate::looks_like_machine_identity(track_name) {
1590        "thread"
1591    } else {
1592        track_name
1593    };
1594    thread
1595        .and_then(|thread| thread.task.clone())
1596        .filter(|task| !crate::looks_like_machine_identity(task))
1597        .map(|task| format!("Merge thread '{}' ({task})", name))
1598        .unwrap_or_else(|| format!("Merge thread '{}'", name))
1599}
1600
1601fn merge_preview_message(
1602    thread: Option<&Thread>,
1603    track_name: &str,
1604    conflict_count: usize,
1605    diff_changed_path_count: usize,
1606) -> String {
1607    let subject = thread
1608        .map(|thread| thread.thread.as_str())
1609        .unwrap_or(track_name);
1610    let thread_changed_path_count = thread
1611        .map(|thread| thread.changed_paths.len())
1612        .unwrap_or_default();
1613    let changed_path_count = if thread_changed_path_count == 0 {
1614        diff_changed_path_count
1615    } else {
1616        thread_changed_path_count
1617    }
1618    .max(conflict_count);
1619    if conflict_count > 0 {
1620        format!(
1621            "Would merge {subject} with {conflict_count} conflict(s) across {changed_path_count} changed path(s)"
1622        )
1623    } else {
1624        format!("Would merge {subject} cleanly across {changed_path_count} changed path(s)")
1625    }
1626}
1627
1628fn state_intent(repo: &Repository, state: Option<&str>) -> Option<String> {
1629    let state = state?;
1630    let state_id = repo.resolve_state(state).ok().flatten()?;
1631    let state = repo.store().get_state(&state_id).ok().flatten()?;
1632    state.intent.filter(|intent| !intent.trim().is_empty())
1633}
1634
1635fn source_git_parent_for_thread(
1636    repo: &Repository,
1637    track_name: &str,
1638    merge_target_id: &StateId,
1639) -> Result<Option<String>> {
1640    if repo.capability() != repo::RepositoryCapability::GitOverlay {
1641        return Ok(None);
1642    }
1643    let Some(tip) = repo.git_overlay_branch_tip(track_name)? else {
1644        return Ok(None);
1645    };
1646    let Some(mapped_change) = tip.mapped_state else {
1647        return Ok(None);
1648    };
1649    if mapped_change == *merge_target_id {
1650        return Ok(Some(tip.git_commit));
1651    }
1652    let mut graph = CommitGraphIndex::new(repo);
1653    if graph
1654        .is_ancestor(&mapped_change, merge_target_id)
1655        .unwrap_or(false)
1656    {
1657        return Ok(Some(tip.git_commit));
1658    }
1659    Ok(None)
1660}
1661
1662/// Derive the set of paths the merge will touch by diffing the
1663/// parent's tip against the thread's tip. Used to drive
1664/// `--git-commit` staging precisely (no `git add -A`) and to
1665/// distinguish related vs. unrelated dirt during precondition checks.
1666///
1667/// Returns the changed paths (added, modified, deleted), preserving
1668/// diff-output order. Renames surface as a from→to pair so both sides
1669/// land in the commit.
1670fn merge_changed_paths(
1671    repo: &Repository,
1672    parent_tip: &StateId,
1673    thread_tip: &StateId,
1674) -> Result<Vec<String>> {
1675    let diff = compute_state_diff(repo, parent_tip, thread_tip, false, 0)?;
1676    let mut out = Vec::with_capacity(diff.changes.len());
1677    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1678    for change in diff.changes {
1679        if seen.insert(change.path.clone()) {
1680            out.push(change.path);
1681        }
1682    }
1683    Ok(out)
1684}
1685
1686fn finalize_merge_git_checkpoint(
1687    repo: &Repository,
1688    state: &StateId,
1689    branch: String,
1690    previous_git_oid: Option<String>,
1691    git_commit: &str,
1692    summary: &str,
1693) -> Result<()> {
1694    repo.record_git_checkpoint(state, git_commit.to_string(), summary.to_string())
1695        .with_context(|| {
1696            format!(
1697                "recording Git checkpoint metadata for merge state {}",
1698                state.short()
1699            )
1700        })?;
1701    let ids = repo
1702        .oplog()
1703        .record_batch_scoped(
1704            vec![OpRecord::GitCheckpoint {
1705                branch,
1706                state: *state,
1707                previous_git_oid,
1708                new_git_oid: git_commit.to_string(),
1709            }],
1710            Some(&repo.op_scope()),
1711        )
1712        .with_context(|| {
1713            format!(
1714                "recording Git checkpoint undo entry for merge state {}",
1715                state.short()
1716            )
1717        })?;
1718    let checkpoint_batch_id = ids
1719        .first()
1720        .copied()
1721        .ok_or_else(|| anyhow!("Git checkpoint undo entry was not recorded"))?;
1722    let merge_batch = find_recent_merge_batch(repo, state)?;
1723    repo.oplog()
1724        .coalesce_batches(merge_batch.id, checkpoint_batch_id)
1725        .with_context(|| {
1726            format!(
1727                "coalescing merge state {} and Git checkpoint {} into one undo batch",
1728                state.short(),
1729                git_commit
1730            )
1731        })?;
1732    Ok(())
1733}
1734
1735fn find_recent_merge_batch(repo: &Repository, state: &StateId) -> Result<OpBatch> {
1736    repo.oplog()
1737        .recent_batches_scoped(12, Some(&repo.op_scope()))?
1738        .into_iter()
1739        .find(|batch| {
1740            batch
1741                .entries
1742                .iter()
1743                .any(|entry| merge_op_targets_state(&entry.operation, state))
1744        })
1745        .ok_or_else(|| {
1746            anyhow!(
1747                "merge state {} landed but its oplog batch was not found",
1748                state.short()
1749            )
1750        })
1751}
1752
1753fn merge_op_targets_state(op: &OpRecord, state: &StateId) -> bool {
1754    match op {
1755        OpRecord::Snapshot { new_state, .. } => new_state == state,
1756        OpRecord::Goto { target, .. } => target == state,
1757        OpRecord::FastForward { post_target_id, .. } => post_target_id == state,
1758        OpRecord::Checkpoint {
1759            state: checkpoint_state,
1760            ..
1761        } => checkpoint_state == state,
1762        // These records don't advance HEAD/thread to the merge state the merge
1763        // flow tracks.
1764        // Enumerated explicitly (no wildcard) so a new state-advancing variant
1765        // must be considered as a possible merge target here (heddle#354 r9).
1766        OpRecord::ThreadCreate { .. }
1767        | OpRecord::ThreadDelete { .. }
1768        | OpRecord::ThreadUpdate { .. }
1769        | OpRecord::Fork { .. }
1770        | OpRecord::Collapse { .. }
1771        | OpRecord::MarkerCreate { .. }
1772        | OpRecord::MarkerDelete { .. }
1773        | OpRecord::TransactionAbort { .. }
1774        | OpRecord::EphemeralThreadCollapse { .. }
1775        | OpRecord::ConflictResolved { .. }
1776        | OpRecord::TransactionCommit { .. }
1777        | OpRecord::Redact { .. }
1778        | OpRecord::Purge { .. }
1779        | OpRecord::GitCheckpoint { .. }
1780        | OpRecord::RemoteThreadUpdate { .. }
1781        | OpRecord::RemoteThreadDelete { .. }
1782        | OpRecord::UndoRecoveryUpdate { .. }
1783        | OpRecord::StateVisibilitySet { .. }
1784        | OpRecord::StateVisibilityPromote { .. }
1785        | OpRecord::EntryVisibilitySet { .. }
1786        | OpRecord::HeadUpdate { .. } => false,
1787    }
1788}
1789
1790fn git_rev_parse_head(root: &Path) -> Option<String> {
1791    let git = SleyRepository::discover(root).ok()?;
1792    git.head().ok()?.oid.map(|id| id.to_string())
1793}
1794
1795/// Extended pre-flight for `--git-commit`. Catches dry-runnable failure
1796/// modes that `validate_git_state` doesn't cover, so they surface as
1797/// pre-snapshot blockers rather than post-snapshot panics that leave
1798/// heddle advanced while git is uncommitted:
1799///
1800/// - **Empty changed-paths set.** `write_git_commit` errors when the
1801///   merge produced no paths to commit (`refusing to write an empty
1802///   git commit`); detect that pre-snapshot.
1803///
1804/// Heddle writes Git commits with native plumbing and can author them
1805/// from Heddle's captured principal when Git config is absent, so
1806/// `user.name`/`user.email` are not preflight requirements.
1807///
1808/// Hooks (`pre-commit`, `commit-msg`) intentionally aren't dry-run here
1809/// — they have side effects, and a strict dry-run would change semantics
1810/// vs. the real commit. If those reject, the caller surfaces an
1811/// actionable recovery hint pointing at the intact heddle merge state.
1812///
1813/// Strategy chosen: option (a) from the spec — extend up-front
1814/// validation and accept that the residual unvalidated failure modes
1815/// (hooks, race conditions, FS errors) require a recovery hint rather
1816/// than a rollback. Option (b) — explicit rollback of the heddle merge
1817/// — would introduce undo semantics that don't compose well with the
1818/// oplog: a partial rollback hand-rolled here can leave the oplog
1819/// pointing at a state that no longer matches the worktree.
1820fn validate_git_commit_preconditions_extended(
1821    repo_root: &std::path::Path,
1822    merge_paths: &[String],
1823) -> Vec<String> {
1824    let mut blockers = Vec::new();
1825
1826    if merge_paths.is_empty() {
1827        blockers
1828            .push("integration produced no changed paths — no Git commit is needed".to_string());
1829    }
1830
1831    if !repo_root.join(".git").exists() {
1832        // `validate_git_state` already reports this; don't double-report.
1833        return blockers;
1834    }
1835
1836    blockers
1837}
1838
1839/// Empty `DiffReport` keyed at the given change-id. Used for return paths
1840/// that didn't actually advance state (already-up-to-date, conflicted,
1841/// pre-snapshot blocked) so the JSON honestly reports "no change set
1842/// landed" instead of pointing at an arbitrary parent..target diff.
1843fn empty_diff_output(state_id: &StateId) -> DiffReport {
1844    DiffReport::new(
1845        Some(state_id.short()),
1846        Some(state_id.short()),
1847        Vec::new(),
1848        None,
1849        None,
1850        None,
1851    )
1852}
1853
1854/// Shared dir → file type-change handler for merge and cherry-pick.
1855///
1856/// Called *after* `remove_tracked_descendants*` has stripped the directory's
1857/// tracked content. Two outcomes:
1858///
1859/// - The directory is now empty → `fs::remove_dir(path)` so the subsequent
1860///   `materialize_blob` call can write a regular file at this path. Without
1861///   this step `materialize_blob` fails with a kernel "Is a directory"
1862///   error because its `remove_file(dest)` precondition can only clear
1863///   files and symlinks.
1864/// - The directory still holds heddle-ignored content (`.git/`, `target/`,
1865///   `node_modules/`, …) → return a clear, actionable error naming the
1866///   surviving entries. We do NOT silently delete heddle-ignored content
1867///   to make a type-change land; that would defeat the entire reason
1868///   tracked-descendants removal exists.
1869///
1870/// `path` must already be confirmed to exist as a directory by the caller.
1871pub fn prepare_dir_for_file_replacement(path: &Path) -> Result<()> {
1872    match fs::remove_dir(path) {
1873        Ok(()) => Ok(()),
1874        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1875        Err(error) if objects::fs_atomic::is_directory_not_empty(&error) => {
1876            let surviving = list_surviving_entries(path)
1877                .unwrap_or_else(|_| vec!["<unable to list>".to_string()]);
1878            let display = if surviving.is_empty() {
1879                "<unknown ignored content>".to_string()
1880            } else {
1881                surviving.join(", ")
1882            };
1883            Err(anyhow!(
1884                "cannot replace directory {} with a file: contains heddle-ignored content ({}) — move or delete those files manually first",
1885                path.display(),
1886                display
1887            ))
1888        }
1889        Err(error) => {
1890            Err(anyhow::Error::from(error)
1891                .context(format!("removing directory {}", path.display())))
1892        }
1893    }
1894}
1895
1896fn list_surviving_entries(path: &Path) -> std::io::Result<Vec<String>> {
1897    let mut names = Vec::new();
1898    for entry in fs::read_dir(path)? {
1899        let entry = entry?;
1900        if let Some(s) = entry.file_name().to_str() {
1901            names.push(s.to_string());
1902        } else {
1903            names.push(entry.file_name().to_string_lossy().into_owned());
1904        }
1905    }
1906    names.sort();
1907    Ok(names)
1908}
1909
1910pub fn bench_find_merge_base(
1911    repo: &Repository,
1912    state_a: &StateId,
1913    state_b: &StateId,
1914) -> Result<Option<StateId>> {
1915    find_merge_base(repo, state_a, state_b)
1916}
1917
1918/// Result of trying a 3-way merge between two thread tips.
1919pub enum ThreeWayMergeOutcome {
1920    /// Clean tree with no conflicts. Tree is allocated in the
1921    /// `parent_repo` object store.
1922    Clean {
1923        tree: Tree,
1924    },
1925    /// Conflicts exist. `tree` is the partial merge tree containing
1926    /// conflict markers, and `paths` lists the conflicting path strings.
1927    Conflicted {
1928        tree: Tree,
1929        paths: Vec<String>,
1930        base: StateId,
1931    },
1932    /// Already-integrated or fast-forward — caller can take a
1933    /// simpler advance path. The contained `target` is the tip the
1934    /// caller should advance to.
1935    AlreadyIntegrated {
1936        target: StateId,
1937    },
1938    FastForward {
1939        target: StateId,
1940    },
1941}
1942
1943/// Compute a 3-way merge between two thread tips without applying
1944/// it. Used by `heddle thread refresh` to fall back to merge-style
1945/// reasoning when the commit-by-commit rebase replay would block on
1946/// an intermediate state but the final trees actually merge cleanly.
1947///
1948/// `parent_repo` is where merge bases / commit graph are queried;
1949/// the returned `Tree` is allocated in that store and the caller is
1950/// responsible for applying it to a worktree and snapshotting.
1951pub fn try_three_way_merge_between_tips(
1952    parent_repo: &Repository,
1953    current_tip: &StateId,
1954    target_tip: &StateId,
1955    labels: ConflictLabels<'_>,
1956) -> Result<ThreeWayMergeOutcome> {
1957    let mut graph = CommitGraphIndex::new(parent_repo);
1958    let plan =
1959        MergePlan::for_merge_command(parent_repo, &mut graph, current_tip, target_tip, labels)?;
1960    match plan.relation().kind() {
1961        MergeRelationKind::AlreadyUpToDate => Ok(ThreeWayMergeOutcome::AlreadyIntegrated {
1962            target: *target_tip,
1963        }),
1964        MergeRelationKind::FastForward => Ok(ThreeWayMergeOutcome::FastForward {
1965            target: *target_tip,
1966        }),
1967        MergeRelationKind::CleanApply => {
1968            let merge_result = plan
1969                .merge_result()
1970                .ok_or_else(|| anyhow!("Merge plan missing merge_result for CleanApply"))?;
1971            Ok(ThreeWayMergeOutcome::Clean {
1972                tree: merge_result.tree.clone(),
1973            })
1974        }
1975        MergeRelationKind::Conflicted | MergeRelationKind::AlreadyIntegrated => {
1976            let merge_result = plan
1977                .merge_result()
1978                .ok_or_else(|| anyhow!("Merge plan missing merge_result for Conflicted"))?;
1979            let base = plan
1980                .relation()
1981                .merge_base_id()
1982                .ok_or_else(|| anyhow!("Merge base missing from conflicted merge plan"))?;
1983            Ok(ThreeWayMergeOutcome::Conflicted {
1984                tree: merge_result.tree.clone(),
1985                paths: merge_result.conflicts.clone(),
1986                base,
1987            })
1988        }
1989    }
1990}
1991
1992/// Apply a pre-computed merged tree to the given repo's worktree.
1993/// Re-export of the internal helper so callers outside the merge
1994/// module (notably `thread_cmd::refresh_thread`) can converge on the
1995/// same tree-application path the merge command uses.
1996pub fn apply_merged_tree_external(repo: &Repository, tree: &Tree) -> Result<()> {
1997    apply_merged_tree(repo, tree)
1998}
1999
2000pub fn bench_three_way_merge(
2001    repo: &Repository,
2002    base_tree: &Tree,
2003    our_tree: &Tree,
2004    their_tree: &Tree,
2005) -> Result<(Tree, usize, usize, usize)> {
2006    let blob_source = RepositoryMergeBlobSource { repo };
2007    let result = merge_trees(
2008        repo.store(),
2009        &blob_source,
2010        base_tree,
2011        our_tree,
2012        their_tree,
2013        tree_merge_options(ConflictLabels::DEFAULT),
2014    )
2015    .map_err(map_tree_merge_error)?;
2016    Ok((
2017        result.tree,
2018        result.conflicts.len(),
2019        result.renames.len(),
2020        result.directory_renames.len(),
2021    ))
2022}
2023
2024pub fn bench_detect_renames(
2025    store: &impl ObjectStore,
2026    base_tree: &Tree,
2027    branch_tree: &Tree,
2028) -> Result<(usize, RenameMatcherStats)> {
2029    let detection = detect_renames_between_trees(store, base_tree, branch_tree, rename_options())?;
2030    Ok((detection.renames.len(), detection.stats))
2031}
2032
2033fn fast_forward_renames(
2034    repo: &Repository,
2035    from: &StateId,
2036    to: &StateId,
2037) -> Result<(Vec<RenameEntry>, Vec<RenameEntry>)> {
2038    let from_tree = load_state_tree(repo, from)?;
2039    let to_tree = load_state_tree(repo, to)?;
2040    let detection =
2041        detect_renames_between_trees(repo.store(), &from_tree, &to_tree, rename_options())?;
2042
2043    let renames: Vec<RenameEntry> = detection
2044        .renames
2045        .into_iter()
2046        .map(|rename| RenameEntry {
2047            from: rename.from,
2048            to: rename.to,
2049            score: rename.score,
2050        })
2051        .collect();
2052
2053    let directory_renames: Vec<RenameEntry> = detection
2054        .directory_renames
2055        .into_iter()
2056        .map(|rename| RenameEntry {
2057            from: rename.from,
2058            to: rename.to,
2059            score: 1.0,
2060        })
2061        .collect();
2062
2063    Ok((renames, directory_renames))
2064}
2065
2066fn rename_options() -> RenameOptions {
2067    RenameOptions {
2068        semantic_similarity: semantic_similarity_hook(),
2069        ..RenameOptions::default()
2070    }
2071}
2072
2073fn load_state_tree(repo: &Repository, state_id: &StateId) -> Result<Tree> {
2074    let state = repo
2075        .store()
2076        .get_state(state_id)?
2077        .ok_or_else(|| anyhow!("State '{}' not found", state_id.short()))?;
2078    repo.store().get_tree(&state.tree)?.ok_or_else(|| {
2079        anyhow!(
2080            "State '{}' references missing tree {}",
2081            state_id.short(),
2082            state.tree
2083        )
2084    })
2085}
2086
2087pub fn build_thread_preview_report(
2088    repo: &Repository,
2089    thread: &mut Thread,
2090    prefer_apply_recommendation: bool,
2091) -> Result<ThreadPreviewReport> {
2092    let mut graph = CommitGraphIndex::new(repo);
2093    // External callers (`heddle sync`, `heddle land`, `heddle ready`)
2094    // route through the same default merge strategy as `heddle merge`.
2095    // The merge command path can still opt out by passing an explicit
2096    // strategy to `_with_graph`.
2097    build_thread_preview_report_with_graph(
2098        repo,
2099        &mut graph,
2100        thread,
2101        prefer_apply_recommendation,
2102        merge_strategy_for(semantic_merge_enabled(false)),
2103        None,
2104    )
2105}
2106
2107/// Caller-supplied override for the destination side of the preview's
2108/// 3-way merge. When `Some`, the inner preview MUST compute against
2109/// this `(label, state_id)` instead of `thread.target_thread`. Used by
2110/// `merge_thread_into_current` so the preview matches the actual merge
2111/// — `heddle merge A` from thread B merges A → B, but A's
2112/// `target_thread` is whatever A was created from (often `main`), so
2113/// without an override the inner report computes A → main and
2114/// contradicts the real outcome (heddle#144).
2115pub struct PreviewTarget<'a> {
2116    pub label: &'a str,
2117    pub state_id: StateId,
2118}
2119
2120fn build_thread_preview_report_with_graph(
2121    repo: &Repository,
2122    graph: &mut CommitGraphIndex<'_>,
2123    thread: &mut Thread,
2124    prefer_apply_recommendation: bool,
2125    strategy: MergeStrategy,
2126    target_override: Option<PreviewTarget<'_>>,
2127) -> Result<ThreadPreviewReport> {
2128    refresh_thread_freshness(repo, thread)?;
2129    let mut conflicts = Vec::new();
2130    // Resolve the destination side. Prefer the caller's override (the
2131    // merge command supplies the actual current HEAD); otherwise fall
2132    // back to `thread.target_thread` for callers like `ready` / `sync` /
2133    // `land` that don't carry an explicit merge destination.
2134    let resolved_target: Option<(String, StateId)> = if let Some(ovr) = target_override {
2135        Some((ovr.label.to_string(), ovr.state_id))
2136    } else if let Some(name) = thread.target_thread.as_deref() {
2137        let id = repo
2138            .refs()
2139            .get_thread(&ThreadName::new(name))?
2140            .ok_or_else(|| anyhow!(advice::thread_not_found(name, "merge preview")))?;
2141        Some((name.to_string(), id))
2142    } else {
2143        None
2144    };
2145
2146    let mut preview_changed_paths: Option<Vec<String>> = None;
2147    let merge_relation = if let Some((target_label, target_id)) = resolved_target {
2148        let thread_id = repo
2149            .refs()
2150            .get_thread(&ThreadName::new(&thread.thread))?
2151            .ok_or_else(|| anyhow!(advice::thread_not_found(&thread.thread, "merge preview")))?;
2152        let current_label = format!("CURRENT ({target_label})");
2153        let incoming_label = format!("INCOMING ({})", thread.thread);
2154        let merge_plan = MergePlan::for_thread_preview(
2155            repo,
2156            graph,
2157            &target_id,
2158            &thread_id,
2159            ConflictLabels {
2160                current: &current_label,
2161                incoming: &incoming_label,
2162                strategy,
2163            },
2164        )?;
2165        if let Some(merge_result) = merge_plan.merge_result() {
2166            conflicts = merge_result.conflicts.clone();
2167        }
2168        let merge_relation = merge_plan.relation().as_json_value().to_string();
2169        if merge_relation != "already_integrated" {
2170            preview_changed_paths = Some(merge_changed_paths(repo, &target_id, &thread_id)?);
2171        }
2172        merge_relation
2173    } else {
2174        "no_target".to_string()
2175    };
2176
2177    let mut advice =
2178        describe_thread_advice(thread, false, conflicts.len(), prefer_apply_recommendation);
2179    if merge_relation == "already_integrated" {
2180        advice.blockers.clear();
2181        advice.recommended_action.clear();
2182        advice.thread_health = "clean".to_string();
2183    }
2184
2185    let thread_tip = repo
2186        .refs()
2187        .get_thread(&ThreadName::new(&thread.thread))?
2188        .map(|id| id.short());
2189    let manual_resolution_current = thread
2190        .integration_policy_result
2191        .manual_resolution_state
2192        .as_deref()
2193        .zip(thread_tip.as_deref())
2194        .is_some_and(|(resolved, current)| resolved == current);
2195    let conflict_count = if manual_resolution_current {
2196        0
2197    } else {
2198        conflicts.len()
2199    };
2200    let conflicts = if manual_resolution_current {
2201        Vec::new()
2202    } else {
2203        conflicts
2204    };
2205    if manual_resolution_current {
2206        advice.blockers.clear();
2207        advice.recommended_action = land_command_for_thread(repo, &thread.thread);
2208        advice.thread_health = "ready".to_string();
2209    }
2210
2211    let recommended_action = advice.recommended_action;
2212    let all_changed_paths = preview_changed_paths.unwrap_or_else(|| thread.changed_paths.clone());
2213    let changed_path_count = all_changed_paths.len();
2214    let changed_paths = all_changed_paths.into_iter().take(8).collect();
2215    Ok(ThreadPreviewReport {
2216        thread: thread.thread.clone(),
2217        thread_mode: thread.mode.to_string(),
2218        thread_state: thread.state.to_string(),
2219        freshness: thread.freshness.to_string(),
2220        task: thread.task.clone(),
2221        changed_paths,
2222        changed_path_count,
2223        impact_categories: thread
2224            .impact_categories
2225            .iter()
2226            .map(ToString::to_string)
2227            .collect(),
2228        heavy_impact_paths: thread.heavy_impact_paths.clone(),
2229        merge_relation,
2230        conflict_count,
2231        conflicts,
2232        blockers: advice.blockers,
2233        recommended_action_template: action_template(&recommended_action),
2234        recommended_action,
2235        thread_health: advice.thread_health,
2236    })
2237}
2238
2239fn merge_output_from_report(input: MergeReportInput<'_>) -> Result<MergeReport> {
2240    let report_conflicts = input.conflicts.unwrap_or_default();
2241    let diff_changed_paths = input.diff.as_ref().map(diff_changed_paths);
2242    let changed_paths = if let Some(paths) = input.changed_paths {
2243        paths
2244    } else if let Some(thread) = input.thread.as_ref() {
2245        let paths = thread.changed_paths.clone();
2246        if paths.is_empty() {
2247            diff_changed_paths.unwrap_or(paths)
2248        } else {
2249            paths
2250        }
2251    } else {
2252        diff_changed_paths.unwrap_or_default()
2253    };
2254    let changed_path_count = changed_paths.len();
2255    // The preview-stage "blockers" list mixes two kinds of items:
2256    //   1) Real blockers — things that actually prevent the merge from
2257    //      advancing state (e.g. unresolved conflicts).
2258    //   2) Recommendations — non-blocking nudges like "promotion
2259    //      recommended for environment breadth". The merge can and
2260    //      does proceed when these are present; surfacing them as
2261    //      `blockers` while also setting `merge_state` produces the
2262    //      contradictory shape `status: "blocked"` + non-null
2263    //      `merge_state` + `thread_state: "merged"`.
2264    //
2265    // The schema rule is: `blockers` only when `status == "blocked"`
2266    // and the operation did NOT advance state. Everything else moves
2267    // to `warnings`.
2268    let preview_blockers = input
2269        .preview_report
2270        .map(|report| report.blockers.clone())
2271        .unwrap_or_default();
2272    let preview_warnings: Vec<String> = preview_blockers
2273        .iter()
2274        .filter(|item| !is_real_merge_blocker(item))
2275        .cloned()
2276        .collect();
2277    // The only "real" blocker in the merge flow is unresolved
2278    // conflicts. Stale/promotion/etc. are advisory.
2279    let mut real_blockers: Vec<String> = if report_conflicts.is_empty() {
2280        Vec::new()
2281    } else {
2282        vec![format!(
2283            "{} path conflict(s) need manual resolution",
2284            report_conflicts.len()
2285        )]
2286    };
2287    real_blockers.extend(input.extra_blockers.iter().cloned());
2288
2289    let status = if !real_blockers.is_empty() {
2290        "blocked"
2291    } else {
2292        "completed"
2293    };
2294    let stale_refresh_action = input.preview_report.and_then(|report| {
2295        (report.freshness == ThreadFreshness::Stale.to_string()).then(|| {
2296            if report.recommended_action.trim().is_empty() {
2297                format!(
2298                    "heddle sync --thread {}",
2299                    recommended_action_quote(&report.thread)
2300                )
2301            } else {
2302                report.recommended_action.clone()
2303            }
2304        })
2305    });
2306    let recommended_action: Option<String> = if !report_conflicts.is_empty() {
2307        // Apply path with conflicts → tell the operator how to
2308        // resolve. Preview path with conflicts → no actionable
2309        // command (the operator must pick a strategy first).
2310        if input.preview_only {
2311            None
2312        } else {
2313            Some("heddle continue".to_string())
2314        }
2315    } else if !input.extra_blockers.is_empty() {
2316        // Coordination blocker. Two shapes:
2317        //   1. Pre-snapshot (`merge_state` is None): typical
2318        //      `--git-commit` precondition failure. Nothing landed; surface
2319        //      status rather than a self-loop back into this merge command.
2320        //   2. Post-snapshot (`merge_state` is Some): `git commit`
2321        //      itself failed AFTER heddle advanced. Re-running
2322        //      `heddle merge` would noop; the safe recovery is the
2323        //      shared checkpoint template, which records the landed
2324        //      Heddle state in Git after the checkout issue is fixed.
2325        Some(coordination_blocker_recommended_action(
2326            input.merge_state.as_ref(),
2327        ))
2328    } else if input.preview_only
2329        && input.message != "Already up to date"
2330        && stale_refresh_action.is_some()
2331    {
2332        stale_refresh_action
2333    } else if input.preview_only && input.message != "Already up to date" {
2334        // Clean preview: the actionable next step is the human landing
2335        // command. `land` keeps capture, merge, checkpoint, push, and
2336        // verification in one loop, so the preview does not bounce users back
2337        // to the lower-level merge apply command.
2338        input.thread.as_ref().map(|t| land_local_command(&t.thread))
2339    } else {
2340        // Clean apply: nothing to do.
2341        None
2342    };
2343    let meaningful_merge = status == "completed" && input.message != "Already up to date";
2344    let would_merge = input.preview_only && meaningful_merge;
2345    let applied = !input.preview_only && meaningful_merge;
2346    Ok(MergeReport {
2347        operator: OperatorCommandOutput {
2348            status: status.to_string(),
2349            action: OperatorAction::Merge,
2350            message: input.message,
2351            blockers: real_blockers,
2352            warnings: preview_warnings,
2353            next_action: recommended_action.clone(),
2354            recommended_action: recommended_action.clone(),
2355        },
2356        would_merge,
2357        applied,
2358        fast_forward: input.fast_forward,
2359        preview_only: input.preview_only,
2360        merge_state: input.merge_state,
2361        conflicts: report_conflicts.clone(),
2362        preview_summary: input.preview_summary,
2363        thread_state: input.thread.as_ref().map(|thread| thread.state.to_string()),
2364        freshness: input
2365            .thread
2366            .as_ref()
2367            .map(|thread| thread.freshness.to_string()),
2368        changed_paths,
2369        changed_path_count,
2370        impact_categories: thread_impacts(input.thread),
2371        promotion_suggested: input
2372            .thread
2373            .as_ref()
2374            .map(|thread| thread.promotion_suggested)
2375            .unwrap_or(false),
2376        heavy_impact_paths: thread_heavy_paths(input.thread),
2377        merge_relation: input.merge_relation.or_else(|| {
2378            input
2379                .preview_report
2380                .map(|report| report.merge_relation.clone())
2381        }),
2382        conflict_count: input
2383            .conflict_count
2384            .or_else(|| input.preview_report.map(|report| report.conflict_count))
2385            .unwrap_or(report_conflicts.len()),
2386        thread_health: merge_output_thread_health(input.thread.as_ref(), input.preview_report),
2387        renames: input.renames,
2388        directory_renames: input.directory_renames,
2389        semantic_changes: input.semantic_changes,
2390        diff: input.diff,
2391        git_commit_preview: input.git_commit_preview,
2392        git_commit: input.git_commit,
2393        trust: Some(merge_output_trust(
2394            input.repo,
2395            input.machine_contract,
2396            recommended_action.as_deref(),
2397        )?),
2398    })
2399}
2400
2401fn diff_changed_paths(diff: &DiffReport) -> Vec<String> {
2402    diff.changes
2403        .iter()
2404        .map(|change| change.path.clone())
2405        .collect()
2406}
2407
2408fn diff_with_known_renames(diff: DiffReport, renames: &[RenameEntry]) -> DiffReport {
2409    if renames.is_empty() {
2410        return diff;
2411    }
2412    let DiffReport {
2413        from_state,
2414        to_state,
2415        changes: original_changes,
2416        semantic_changes,
2417        context,
2418        broader_guidance,
2419        ..
2420    } = diff;
2421    let rename_by_new = renames
2422        .iter()
2423        .map(|rename| (rename.to.as_str(), rename.from.as_str()))
2424        .collect::<std::collections::BTreeMap<_, _>>();
2425    let removed_old = renames
2426        .iter()
2427        .map(|rename| rename.from.as_str())
2428        .collect::<std::collections::BTreeSet<_>>();
2429    let mut changes = Vec::with_capacity(original_changes.len());
2430    for mut change in original_changes {
2431        if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2432            continue;
2433        }
2434        if change.kind == "added"
2435            && let Some(old_path) = rename_by_new.get(change.path.as_str())
2436        {
2437            change.kind = "renamed".to_string();
2438            change.old_path = Some((*old_path).to_string());
2439        }
2440        changes.push(change);
2441    }
2442    DiffReport::new(
2443        from_state,
2444        to_state,
2445        changes,
2446        semantic_changes,
2447        context,
2448        broader_guidance,
2449    )
2450}
2451
2452fn merge_output_thread_health(
2453    thread: Option<&Thread>,
2454    preview_report: Option<&ThreadPreviewReport>,
2455) -> String {
2456    match thread.map(|thread| &thread.state) {
2457        Some(ThreadState::Merged | ThreadState::Abandoned) => "clean".to_string(),
2458        Some(ThreadState::Blocked) => "blocked".to_string(),
2459        Some(ThreadState::Ready) => "ready".to_string(),
2460        Some(ThreadState::Draft | ThreadState::Active | ThreadState::Promoted) | None => {
2461            preview_report
2462                .map(|report| report.thread_health.clone())
2463                .unwrap_or_else(|| "active".to_string())
2464        }
2465    }
2466}
2467
2468fn coordination_blocker_recommended_action(merge_state: Option<&String>) -> String {
2469    if merge_state.is_some() {
2470        "heddle capture -m \"...\"".to_string()
2471    } else {
2472        "heddle status".to_string()
2473    }
2474}
2475
2476fn merge_output_trust(
2477    repo: &Repository,
2478    machine_contract: &MachineContractInput,
2479    recommended_action: Option<&str>,
2480) -> Result<RepositoryVerificationState> {
2481    let mut trust = trust_state(repo, machine_contract)?;
2482    if let Some(action) = recommended_action {
2483        override_trust_recommended_action(&mut trust, action);
2484    }
2485    Ok(trust)
2486}
2487
2488fn worktree_status_options(config: Option<&repo::RepoConfig>) -> repo::WorktreeStatusOptions {
2489    repo::resolve_worktree_status_options(None, config)
2490}
2491
2492fn worktree_dirty(repo: &Repository, options: &repo::WorktreeStatusOptions) -> Result<bool> {
2493    if repo.current_state()?.is_none()
2494        && let Some(status) = repo.git_overlay_worktree_status()?
2495    {
2496        return Ok(!status.is_clean());
2497    }
2498    let tree = match repo.current_state()? {
2499        Some(state) => repo.require_tree(&state.tree)?,
2500        None => Tree::new(),
2501    };
2502    let status = repo.compare_worktree_cached_with_options(&tree, options)?;
2503    Ok(!status.is_clean())
2504}
2505
2506fn worktree_dirty_paths(
2507    repo: &Repository,
2508    options: &repo::WorktreeStatusOptions,
2509) -> Result<Vec<String>> {
2510    let status = if repo.current_state()?.is_none()
2511        && let Some(status) = repo.git_overlay_worktree_status()?
2512    {
2513        status
2514    } else {
2515        let tree = match repo.current_state()? {
2516            Some(state) => repo.require_tree(&state.tree)?,
2517            None => Tree::new(),
2518        };
2519        repo.compare_worktree_cached_with_options(&tree, options)?
2520    };
2521
2522    let mut paths = Vec::new();
2523    paths.extend(status.modified);
2524    paths.extend(status.added);
2525    paths.extend(status.deleted);
2526    paths.sort();
2527    paths.dedup();
2528    Ok(paths
2529        .into_iter()
2530        .map(|path| path.display().to_string())
2531        .collect())
2532}
2533
2534fn source_thread_uncaptured_work(
2535    target_repo: &Repository,
2536    thread: &Thread,
2537) -> Result<Option<SourceThreadUncapturedWork>> {
2538    if thread.execution_path.as_os_str().is_empty()
2539        || thread.execution_path == *target_repo.root()
2540        || !thread.execution_path.exists()
2541        || !thread.execution_path.join(".heddle").exists()
2542    {
2543        return Ok(None);
2544    }
2545
2546    let source_repo = Repository::open(&thread.execution_path)?;
2547    let options = worktree_status_options(Some(source_repo.config()));
2548    if !worktree_dirty(&source_repo, &options)? {
2549        return Ok(None);
2550    }
2551
2552    Ok(Some(SourceThreadUncapturedWork {
2553        checkout_path: thread.execution_path.display().to_string(),
2554        dirty_paths: worktree_dirty_paths(&source_repo, &options)?,
2555    }))
2556}
2557
2558#[allow(dead_code)] // retained for richer RecoveryDetails copy in a later polish pass
2559fn uncaptured_path_summary(paths: &[String]) -> String {
2560    if paths.is_empty() {
2561        return "uncaptured worktree paths".to_string();
2562    }
2563    let shown = paths
2564        .iter()
2565        .take(12)
2566        .cloned()
2567        .collect::<Vec<_>>()
2568        .join(", ");
2569    let overflow = paths.len().saturating_sub(12);
2570    if overflow == 0 {
2571        format!("uncaptured path(s): {shown}")
2572    } else {
2573        format!("uncaptured path(s): {shown}, and {overflow} more")
2574    }
2575}
2576
2577fn recommended_action_quote(value: &str) -> String {
2578    let safe = !value.is_empty()
2579        && value
2580            .bytes()
2581            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'+'));
2582    if safe {
2583        value.to_string()
2584    } else {
2585        format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
2586    }
2587}
2588
2589fn merge_blocked_by_trust_output(
2590    thread: &Option<Thread>,
2591    preview_report: Option<&ThreadPreviewReport>,
2592    trust: RepositoryVerificationState,
2593    preview_only: bool,
2594    merge_relation: Option<String>,
2595) -> MergeReport {
2596    MergeReport {
2597        operator: OperatorCommandOutput::blocked_by_repository_verification(
2598            OperatorAction::Merge,
2599            trust_blocked_merge_message(&trust, preview_only),
2600            &trust,
2601        ),
2602        would_merge: false,
2603        applied: false,
2604        fast_forward: false,
2605        preview_only,
2606        merge_state: None,
2607        conflicts: Vec::new(),
2608        preview_summary: Vec::new(),
2609        thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
2610        freshness: thread.as_ref().map(|thread| thread.freshness.to_string()),
2611        changed_paths: thread_paths(thread),
2612        changed_path_count: thread_path_count(thread),
2613        impact_categories: thread_impacts(thread),
2614        promotion_suggested: thread
2615            .as_ref()
2616            .map(|thread| thread.promotion_suggested)
2617            .unwrap_or(false),
2618        heavy_impact_paths: thread_heavy_paths(thread),
2619        merge_relation: merge_relation
2620            .or_else(|| preview_report.map(|report| report.merge_relation.clone())),
2621        conflict_count: 0,
2622        thread_health: trust.status.clone(),
2623        renames: Vec::new(),
2624        directory_renames: Vec::new(),
2625        semantic_changes: None,
2626        diff: None,
2627        git_commit_preview: None,
2628        git_commit: None,
2629        trust: Some(trust),
2630    }
2631}
2632
2633fn merge_freshness_preflight_output(
2634    repo: &Repository,
2635    machine_contract: &MachineContractInput,
2636    thread: &Option<Thread>,
2637    preview_report: Option<&ThreadPreviewReport>,
2638    preview_only: bool,
2639) -> Result<Option<MergeReport>> {
2640    if thread
2641        .as_ref()
2642        .is_some_and(|thread| thread.state == ThreadState::Merged)
2643    {
2644        return Ok(None);
2645    }
2646    let Some(report) =
2647        preview_report.filter(|report| report.freshness == ThreadFreshness::Stale.to_string())
2648    else {
2649        return Ok(None);
2650    };
2651    Ok(Some(stale_thread_merge_blocked_output(
2652        repo,
2653        machine_contract,
2654        thread,
2655        report,
2656        preview_only,
2657    )?))
2658}
2659
2660fn stale_thread_merge_blocked_output(
2661    repo: &Repository,
2662    machine_contract: &MachineContractInput,
2663    thread: &Option<Thread>,
2664    preview_report: &ThreadPreviewReport,
2665    preview_only: bool,
2666) -> Result<MergeReport> {
2667    let recommended_action = if preview_report.recommended_action.trim().is_empty() {
2668        format!(
2669            "heddle sync --thread {}",
2670            recommended_action_quote(&preview_report.thread)
2671        )
2672    } else {
2673        preview_report.recommended_action.clone()
2674    };
2675    let blockers = if preview_report.blockers.is_empty() {
2676        vec![format!(
2677            "Thread '{}' is stale against '{}'",
2678            preview_report.thread,
2679            thread
2680                .as_ref()
2681                .and_then(|thread| thread.target_thread.as_deref())
2682                .unwrap_or("its target thread")
2683        )]
2684    } else {
2685        preview_report.blockers.clone()
2686    };
2687    let conflict_suffix = if preview_report.conflict_count > 0 {
2688        format!(
2689            " and has {} path conflict(s)",
2690            preview_report.conflict_count
2691        )
2692    } else {
2693        String::new()
2694    };
2695
2696    Ok(MergeReport {
2697        operator: OperatorCommandOutput {
2698            status: "blocked".to_string(),
2699            action: OperatorAction::Merge,
2700            message: format!(
2701                "Thread '{}' is stale{}; merge {}did not run",
2702                preview_report.thread,
2703                conflict_suffix,
2704                if preview_only { "preview " } else { "" }
2705            ),
2706            blockers,
2707            warnings: Vec::new(),
2708            next_action: Some(recommended_action.clone()),
2709            recommended_action: Some(recommended_action.clone()),
2710        },
2711        would_merge: false,
2712        applied: false,
2713        fast_forward: false,
2714        preview_only,
2715        merge_state: None,
2716        conflicts: preview_report.conflicts.clone(),
2717        preview_summary: build_stale_preview_summary(preview_report),
2718        thread_state: thread.as_ref().map(|thread| thread.state.to_string()),
2719        freshness: Some(preview_report.freshness.clone()),
2720        changed_paths: preview_report.changed_paths.clone(),
2721        changed_path_count: preview_report.changed_path_count,
2722        impact_categories: preview_report.impact_categories.clone(),
2723        promotion_suggested: !preview_report.heavy_impact_paths.is_empty(),
2724        heavy_impact_paths: preview_report.heavy_impact_paths.clone(),
2725        merge_relation: Some(preview_report.merge_relation.clone()),
2726        conflict_count: preview_report.conflict_count,
2727        thread_health: "blocked".to_string(),
2728        renames: Vec::new(),
2729        directory_renames: Vec::new(),
2730        semantic_changes: None,
2731        diff: None,
2732        git_commit_preview: None,
2733        git_commit: None,
2734        trust: Some(merge_output_trust(
2735            repo,
2736            machine_contract,
2737            Some(&recommended_action),
2738        )?),
2739    })
2740}
2741
2742fn override_trust_recommended_action(
2743    trust: &mut RepositoryVerificationState,
2744    action: impl Into<String>,
2745) {
2746    let action = action.into();
2747    trust.recommended_action_template = action_template(&action);
2748    trust.recommended_action = action.clone();
2749    if let Some(check) = trust
2750        .checks
2751        .iter_mut()
2752        .find(|check| check.name == "Workflow")
2753    {
2754        check.recommended_action_template = action_template(&action);
2755        check.recommended_action = Some(action);
2756    }
2757}
2758
2759fn trust_blocks_merge_preview(trust: &RepositoryVerificationState) -> bool {
2760    trust
2761        .checks
2762        .iter()
2763        .any(|check| !check.clean && matches!(check.name.as_str(), "Mapping" | "Operation"))
2764}
2765
2766fn trust_blocked_merge_message(trust: &RepositoryVerificationState, preview_only: bool) -> String {
2767    if preview_only {
2768        format!(
2769            "Repository verification is blocked; merge preview did not run: {}",
2770            trust.summary
2771        )
2772    } else {
2773        format!(
2774            "Repository verification is blocked; merge did not run: {}",
2775            trust.summary
2776        )
2777    }
2778}
2779
2780fn preview_list(paths: &[String], total: usize) -> String {
2781    const LIMIT: usize = 5;
2782    if paths.is_empty() {
2783        return "none".to_string();
2784    }
2785    let shown = paths
2786        .iter()
2787        .take(LIMIT)
2788        .cloned()
2789        .collect::<Vec<_>>()
2790        .join(", ");
2791    if total > LIMIT {
2792        format!("{shown} (+{} more)", total - LIMIT)
2793    } else {
2794        shown
2795    }
2796}
2797
2798fn is_real_merge_blocker(advisory: &str) -> bool {
2799    let lower = advisory.to_lowercase();
2800    lower.contains("path conflict")
2801}
2802
2803fn thread_paths(thread: &Option<Thread>) -> Vec<String> {
2804    thread
2805        .as_ref()
2806        .map(|thread| thread.changed_paths.clone())
2807        .unwrap_or_default()
2808}
2809
2810fn thread_path_count(thread: &Option<Thread>) -> usize {
2811    thread
2812        .as_ref()
2813        .map(|thread| thread.changed_paths.len())
2814        .unwrap_or(0)
2815}
2816
2817fn thread_impacts(thread: &Option<Thread>) -> Vec<String> {
2818    thread
2819        .as_ref()
2820        .map(|thread| {
2821            thread
2822                .impact_categories
2823                .iter()
2824                .map(ToString::to_string)
2825                .collect::<Vec<_>>()
2826        })
2827        .unwrap_or_default()
2828}
2829
2830fn thread_heavy_paths(thread: &Option<Thread>) -> Vec<String> {
2831    thread
2832        .as_ref()
2833        .map(|thread| thread.heavy_impact_paths.clone())
2834        .unwrap_or_default()
2835}
2836
2837fn build_preview_summary(report: Option<&ThreadPreviewReport>) -> Vec<String> {
2838    let mut lines = Vec::new();
2839    if let Some(report) = report {
2840        let real_blockers = report
2841            .blockers
2842            .iter()
2843            .filter(|blocker| is_real_merge_blocker(blocker))
2844            .cloned()
2845            .collect::<Vec<_>>();
2846        if !real_blockers.is_empty() {
2847            lines.push(format!("blocked: {}", real_blockers.join("; ")));
2848        }
2849        lines.push(format!(
2850            "checkout: {}",
2851            thread_mode_summary(&report.thread_mode)
2852        ));
2853        lines.push(format!("sync: {}", report.freshness));
2854        if let Some(task) = &report.task {
2855            lines.push(format!("task: {}", task));
2856        }
2857        if !report.changed_paths.is_empty() {
2858            lines.push(format!(
2859                "changed paths: {}",
2860                report.changed_paths.join(", ")
2861            ));
2862        }
2863        if !report.impact_categories.is_empty() {
2864            lines.push(format!(
2865                "impact categories: {}",
2866                report.impact_categories.join(", ")
2867            ));
2868        }
2869        if !report.heavy_impact_paths.is_empty() {
2870            lines.push(format!(
2871                "heavy-impact change: {} — review broader impact before merging",
2872                preview_list(&report.heavy_impact_paths, report.heavy_impact_paths.len(),)
2873            ));
2874        }
2875        lines.push(format!(
2876            "merge type: {}",
2877            merge_relation_summary(&report.merge_relation)
2878        ));
2879        if report.conflict_count > 0 {
2880            lines.push(format!(
2881                "conflicts: {} path conflict(s)",
2882                report.conflict_count
2883            ));
2884        }
2885    }
2886    lines
2887}
2888
2889fn build_stale_preview_summary(report: &ThreadPreviewReport) -> Vec<String> {
2890    let mut lines = Vec::new();
2891    if !report.blockers.is_empty() {
2892        lines.push(format!("blocked: {}", report.blockers.join("; ")));
2893    }
2894    lines.push(format!(
2895        "checkout: {}",
2896        thread_mode_summary(&report.thread_mode)
2897    ));
2898    lines.push(format!("sync: {}", report.freshness));
2899    if let Some(task) = &report.task {
2900        lines.push(format!("task: {}", task));
2901    }
2902    if !report.changed_paths.is_empty() {
2903        lines.push(format!(
2904            "changed paths: {}",
2905            report.changed_paths.join(", ")
2906        ));
2907    }
2908    if !report.impact_categories.is_empty() {
2909        lines.push(format!(
2910            "impact categories: {}",
2911            report.impact_categories.join(", ")
2912        ));
2913    }
2914    if !report.heavy_impact_paths.is_empty() {
2915        lines.push(format!(
2916            "heavy-impact change: {} — review broader impact before merging",
2917            preview_list(&report.heavy_impact_paths, report.heavy_impact_paths.len(),)
2918        ));
2919    }
2920    lines.push(format!(
2921        "merge type: {}",
2922        merge_relation_summary(&report.merge_relation)
2923    ));
2924    if report.conflict_count > 0 {
2925        lines.push(format!(
2926            "conflicts: {} path conflict(s)",
2927            report.conflict_count
2928        ));
2929    }
2930    lines
2931}
2932
2933fn thread_mode_summary(mode: &str) -> &str {
2934    match mode {
2935        "solid" => "main checkout",
2936        "materialized" => "disk checkout",
2937        "virtualized" => "virtual checkout",
2938        other => other,
2939    }
2940}
2941
2942fn merge_relation_summary(result: &str) -> String {
2943    result.replace('_', "-")
2944}
2945
2946#[cfg(test)]
2947mod tests {
2948    use super::*;
2949
2950    /// Regression-lock for HeddleCo/heddle#503 (guards the documented
2951    /// Codex r13 preview-vs-actual divergence class).
2952    ///
2953    /// Strategy is decided **once per merge attempt** via
2954    /// `MergeAttemptPlan::decide`; preview and apply both read the
2955    /// strategy back off that single plan. This test pins two things:
2956    ///
2957    /// 1. The decision is internally consistent — `strategy()` and
2958    ///    `use_semantic()` never disagree (a `Semantic` strategy with
2959    ///    `use_semantic == false`, or vice versa, would let the diff
2960    ///    payload contradict the content merge).
2961    /// 2. `merge_thread_into_current` no longer recomputes the strategy
2962    ///    independently for preview and apply. We enforce this at the
2963    ///    source level: the body must contain exactly ONE
2964    ///    `MergeAttemptPlan::decide(` call and ZERO bare
2965    ///    `merge_strategy_for(use_semantic)` call sites — the old
2966    ///    duplicated form the issue flagged. If a future edit
2967    ///    reintroduces a second, drift-prone strategy decision, this
2968    ///    assertion fails.
2969    #[test]
2970    fn merge_strategy_is_decided_once_preview_equals_apply() {
2971        // (1) The decision object is self-consistent for both flags.
2972        for no_semantic in [false, true] {
2973            let plan = MergeAttemptPlan::decide(no_semantic);
2974            let semantic_active = plan.strategy() == MergeStrategy::Semantic;
2975            assert_eq!(
2976                semantic_active,
2977                plan.use_semantic(),
2978                "MergeAttemptPlan strategy and use_semantic must agree (no_semantic={no_semantic})"
2979            );
2980            assert_eq!(
2981                plan.strategy(),
2982                merge_strategy_for(semantic_merge_enabled(no_semantic)),
2983                "decide() must select the same strategy the legacy derivation would"
2984            );
2985        }
2986
2987        // (2) Source-level invariant: the merge-attempt flow decides the
2988        // strategy exactly once and never re-derives it independently for
2989        // preview vs apply. The preview report and the apply MergePlan
2990        // must consume the SAME plan, so there is one `decide(` call and
2991        // no surviving `merge_strategy_for(use_semantic)` call sites in
2992        // `merge_thread_into_current`.
2993        let source = include_str!("mod.rs");
2994        let body = source
2995            .split_once("pub fn merge_thread_into_current_with_machine_contract(")
2996            .expect("merge_thread_into_current_with_machine_contract must exist")
2997            .1
2998            .split_once("\nfn mark_merge_previewed(")
2999            .expect(
3000                "merge_thread_into_current_with_machine_contract must be delimited by mark_merge_previewed",
3001            )
3002            .0;
3003        let decide_calls = body.matches("MergeAttemptPlan::decide(").count();
3004        assert_eq!(
3005            decide_calls, 1,
3006            "merge_thread_into_current must decide the merge strategy exactly once \
3007             (found {decide_calls} MergeAttemptPlan::decide call sites)"
3008        );
3009        assert!(
3010            !body.contains("merge_strategy_for(use_semantic)"),
3011            "preview and apply must consume the single MergeAttemptPlan, not re-derive \
3012             the strategy via merge_strategy_for(use_semantic)"
3013        );
3014    }
3015
3016    #[test]
3017    fn merge_in_progress_refusal_uses_typed_recovery_advice() {
3018        let err = advice::merge_already_in_progress();
3019        let objects::HeddleError::Recovery(details) = err else {
3020            panic!("expected recovery error");
3021        };
3022
3023        assert_eq!(details.kind, "merge_already_in_progress");
3024        assert!(details.error.contains("merge is already in progress"));
3025        assert!(details.hint.contains("heddle continue"));
3026        assert!(details.preserved.contains("left unchanged"));
3027    }
3028
3029    /// Empty directory case: `prepare_dir_for_file_replacement` removes
3030    /// it so the materializer can write a regular file at the same path.
3031    /// Without this step, `materialize_blob` blows up deep in the
3032    /// materializer with a "Is a directory" I/O error.
3033    #[test]
3034    fn prepare_dir_for_file_replacement_removes_empty_directory() {
3035        let dir = tempfile::TempDir::new().unwrap();
3036        let target = dir.path().join("entry");
3037        fs::create_dir(&target).unwrap();
3038
3039        prepare_dir_for_file_replacement(&target).expect("empty dir is removable");
3040
3041        assert!(
3042            !target.exists(),
3043            "empty directory must be removed so a file can take its place"
3044        );
3045    }
3046
3047    /// Non-empty directory case (heddle-ignored content remains): the
3048    /// helper must error with an actionable message naming the offending
3049    /// content. Silently deleting heddle-ignored content to make a
3050    /// type-change land would defeat the entire reason
3051    /// `remove_tracked_descendants` exists.
3052    #[test]
3053    fn prepare_dir_for_file_replacement_errors_on_non_empty_directory() {
3054        let dir = tempfile::TempDir::new().unwrap();
3055        let target = dir.path().join("entry");
3056        fs::create_dir(&target).unwrap();
3057        // Simulate heddle-ignored content (e.g. `target/`, `node_modules/`)
3058        // that `remove_tracked_descendants_with_source` left in place
3059        // because it isn't in the source tree.
3060        fs::create_dir(target.join("node_modules")).unwrap();
3061        fs::write(target.join("node_modules").join("dep.js"), "ignored").unwrap();
3062
3063        let err = prepare_dir_for_file_replacement(&target)
3064            .expect_err("non-empty dir must error rather than silently delete");
3065        let msg = err.to_string();
3066        assert!(
3067            msg.contains("cannot replace directory"),
3068            "missing 'cannot replace directory' phrase: {msg}"
3069        );
3070        assert!(
3071            msg.contains("heddle-ignored content"),
3072            "missing 'heddle-ignored content' phrase: {msg}"
3073        );
3074        assert!(
3075            msg.contains("node_modules"),
3076            "error must list the offending entry: {msg}"
3077        );
3078        // Content must survive the failed call — the helper is
3079        // load-bearing precisely because it does NOT touch ignored
3080        // content.
3081        assert!(
3082            target.join("node_modules").join("dep.js").exists(),
3083            "ignored content must NOT be deleted by the failure path"
3084        );
3085    }
3086
3087    /// Missing-path case: a NotFound error is harmless — the path is
3088    /// already gone, so the materializer can write the new file freely.
3089    #[test]
3090    fn prepare_dir_for_file_replacement_tolerates_missing_path() {
3091        let dir = tempfile::TempDir::new().unwrap();
3092        let target = dir.path().join("entry");
3093        // Don't create it.
3094
3095        prepare_dir_for_file_replacement(&target).expect("missing dir is a no-op, not an error");
3096    }
3097
3098    /// `empty_diff_output` is the schema-honest payload for return paths
3099    /// where heddle didn't actually advance state (already-up-to-date,
3100    /// conflicted, pre-snapshot blocked). The shape must round-trip as
3101    /// JSON cleanly: both `from_state` and `to_state` are populated with
3102    /// the same change-id and `changes` is an empty array.
3103    #[test]
3104    fn extended_validation_does_not_require_git_cli_identity() {
3105        use std::process::Command;
3106
3107        let dir = tempfile::TempDir::new().unwrap();
3108        // Initialize a git repo with no user.name.
3109        let status = Command::new("git")
3110            .arg("-C")
3111            .arg(dir.path())
3112            .args(["init", "--quiet"])
3113            .status()
3114            .expect("git must be on PATH for the native Git validation test");
3115        assert!(
3116            status.success(),
3117            "git init must succeed for the test fixture"
3118        );
3119        let blockers =
3120            validate_git_commit_preconditions_extended(dir.path(), &["dummy.txt".to_string()]);
3121        assert!(
3122            blockers.is_empty(),
3123            "native Git commit writing should not require a Git CLI/config identity; Heddle can author from captured principal: {blockers:?}"
3124        );
3125    }
3126
3127    /// Empty merge-paths case: `write_git_commit` rejects an empty
3128    /// integration commit inside `git_commit.rs`, which only
3129    /// surfaces AFTER `snapshot_merge_with_attribution` has advanced
3130    /// heddle. The up-front check catches it before snapshot.
3131    #[test]
3132    fn extended_validation_flags_empty_changed_paths() {
3133        let dir = tempfile::TempDir::new().unwrap();
3134        let blockers = validate_git_commit_preconditions_extended(dir.path(), &[]);
3135        assert!(
3136            blockers
3137                .iter()
3138                .any(|b| b.contains("integration produced no changed paths")),
3139            "empty merge_paths must surface as a blocker: {blockers:?}"
3140        );
3141    }
3142
3143    /// Negative case: when the directory isn't a git repo, the
3144    /// extended check returns early without spurious identity blockers
3145    /// (the existing `validate_git_state` reports the "no git
3146    /// repository" blocker; the extended check shouldn't double-report).
3147    #[test]
3148    fn extended_validation_skips_identity_check_when_no_git_dir() {
3149        let dir = tempfile::TempDir::new().unwrap();
3150        let blockers = validate_git_commit_preconditions_extended(dir.path(), &["a".to_string()]);
3151        // Only the `merge_paths.is_empty()` check fires before the
3152        // `.git` short-circuit; with non-empty paths it should be
3153        // empty (the absent-`.git` check is `validate_git_state`'s
3154        // job).
3155        assert!(
3156            !blockers.iter().any(|b| b.contains("git user.name")),
3157            "must not report identity blockers without a git overlay: {blockers:?}"
3158        );
3159        assert!(
3160            !blockers.iter().any(|b| b.contains("git user.email")),
3161            "must not report identity blockers without a git overlay: {blockers:?}"
3162        );
3163    }
3164
3165    #[test]
3166    fn coordination_blocker_recommendations_are_machine_actions() {
3167        let merge_state = "hs-landed123".to_string();
3168        let post_snapshot = coordination_blocker_recommended_action(Some(&merge_state));
3169        assert_eq!(post_snapshot, "heddle capture -m \"...\"");
3170        assert!(
3171            action_template(&post_snapshot).is_some(),
3172            "commit placeholder should carry a fillable template"
3173        );
3174
3175        let pre_snapshot = coordination_blocker_recommended_action(None);
3176        assert_eq!(pre_snapshot, "heddle status");
3177        assert!(
3178            action_template(&pre_snapshot).is_some(),
3179            "status action should carry a template"
3180        );
3181        for action in [post_snapshot, pre_snapshot] {
3182            assert!(
3183                !action.contains("resolve git state")
3184                    && !action.contains("see blockers")
3185                    && !action.contains("do NOT"),
3186                "recommended actions must be Heddle commands/templates, not prose: {action}"
3187            );
3188        }
3189    }
3190
3191    #[test]
3192    fn empty_diff_output_is_self_consistent_and_serializes() {
3193        let id = objects::object::StateId::from_bytes([69; 32]);
3194        let out = empty_diff_output(&id);
3195
3196        assert_eq!(out.from_state.as_deref(), Some(id.short()).as_deref());
3197        assert_eq!(out.to_state.as_deref(), Some(id.short()).as_deref());
3198        assert!(
3199            out.changes.is_empty(),
3200            "empty_diff_output must report no changes — that's the whole point"
3201        );
3202        assert!(out.semantic_changes.is_none());
3203
3204        let json = serde_json::to_value(&out).unwrap();
3205        assert_eq!(
3206            json["changes"].as_array().unwrap().len(),
3207            0,
3208            "`changes` array must serialize as empty, not be omitted"
3209        );
3210        assert_eq!(
3211            json["from_state"], json["to_state"],
3212            "self-loop semantics: from == to when no change landed"
3213        );
3214    }
3215}