Skip to main content

verbs/merge/
mod.rs

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