Skip to main content

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