Skip to main content

verbs/diff/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Embeddable diff facade and report model.
3
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    path::{Path, PathBuf},
7};
8
9use anyhow::{Result, anyhow};
10use merge::RenameCandidateIndex;
11use objects::{
12    HeddleError, RecoveryDetails,
13    lock::RepositoryLockExt,
14    object::{
15        Blob, ContentHash, DiffKind, EntryType, FileChangeSet, FileMode, SemanticChange, State,
16        StateId, Tree, TreeEntry,
17    },
18    store::ObjectStore,
19    worktree::{WorktreeStatus, diff_blobs},
20};
21use repo::{
22    Repository, ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
23};
24#[cfg(feature = "semantic")]
25use semantic::diff::{SemanticDiffOptions, WorktreeStatus as SemanticWorktreeStatus};
26use sley::{EntryKind, Repository as SleyRepository};
27
28use crate::{
29    ExecutionContext, LastTurnAnchor, read_identity_cursor, read_last_turn_anchor,
30    write_last_turn_anchor,
31};
32
33mod context;
34mod patch;
35mod path_filter;
36mod types;
37
38pub use context::{attach_show_context, worktree_context_state};
39pub use patch::{render_diff_patch, render_diff_patch_bytes, write_diff_patch};
40pub use types::*;
41
42const BINARY_DIFF_ERROR: &str = "binary file";
43const RENAME_SIMILARITY_THRESHOLD: f64 = 0.75;
44
45/// A named comparison base that is resolved from repository metadata.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum DiffBase {
48    LastTurn,
49}
50
51impl DiffBase {
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Self::LastTurn => "last-turn",
55        }
56    }
57}
58
59#[derive(Clone, Debug, Default)]
60struct SemanticDiffResult {
61    changes: Vec<SemanticChange>,
62    file_changes: FileChangeSet,
63}
64
65/// Options for computing a diff report through the embeddable facade.
66#[derive(Clone, Debug)]
67pub struct DiffOptions {
68    pub from: Option<String>,
69    pub to: Option<String>,
70    pub base: Option<DiffBase>,
71    pub semantic: bool,
72    pub stat: bool,
73    pub name_only: bool,
74    pub unified: usize,
75    pub show_context: bool,
76    /// Whether the report should include the top-level patch string when a
77    /// patch-compatible representation is available. CLI callers set this for
78    /// `--patch` and for JSON output, preserving the existing machine contract.
79    pub include_patch_text: bool,
80    /// Repository-relative path filters. Empty means the full change set.
81    pub paths: Vec<String>,
82}
83
84impl Default for DiffOptions {
85    fn default() -> Self {
86        Self {
87            from: None,
88            to: None,
89            base: None,
90            semantic: false,
91            stat: false,
92            name_only: false,
93            unified: 3,
94            show_context: false,
95            include_patch_text: false,
96            paths: Vec::new(),
97        }
98    }
99}
100
101/// Core-friendly view of the plain-Git probe the CLI health layer discovers.
102#[derive(Debug)]
103pub struct PlainGitDiffProbe {
104    pub root: PathBuf,
105    pub changes: WorktreeStatus,
106}
107
108/// Compute a Heddle diff report without rendering to stdout.
109pub fn diff(ctx: &ExecutionContext, options: DiffOptions) -> Result<DiffReport> {
110    let repo = ctx.require_repo().map_err(anyhow::Error::new)?;
111    let to = options.to.as_ref();
112    let git_overlay_head_worktree_diff = repo.current_state()?.is_none()
113        && to.is_none()
114        && options.base.is_none()
115        && matches!(options.from.as_deref(), Some("HEAD" | "@"));
116
117    let to_state = if let Some(to_spec) = to {
118        let to_id = resolve_state_id(repo, to_spec)?;
119        Some(require_resolved_state(repo, &to_id)?)
120    } else {
121        None
122    };
123
124    let from_id = if git_overlay_head_worktree_diff {
125        None
126    } else if options.base == Some(DiffBase::LastTurn) {
127        if options.from.is_some() {
128            return Err(anyhow!(
129                "--base last-turn cannot be combined with an explicit from state"
130            ));
131        }
132        let target = to_state
133            .as_ref()
134            .map(|state| state.state_id)
135            .or(repo.head()?)
136            .ok_or_else(|| anyhow!("no agent turn is available for the last-turn base"))?;
137        Some(resolve_last_turn_base(repo, target)?)
138    } else if let Some(ref spec) = options.from {
139        Some(resolve_state_id(repo, spec)?)
140    } else {
141        repo.head()?
142    };
143
144    let from_state = if let Some(id) = from_id {
145        Some(require_resolved_state(repo, &id)?)
146    } else {
147        None
148    };
149
150    let from_tree = if let Some(ref state) = from_state {
151        repo.store().get_tree(&state.tree)?
152    } else {
153        None
154    };
155    let to_tree = if let Some(ref state) = to_state {
156        repo.store().get_tree(&state.tree)?
157    } else {
158        None
159    };
160    let status_options = ctx.worktree_status_options();
161    let from_hash = from_state
162        .as_ref()
163        .map(|state| state.tree)
164        .unwrap_or_else(|| Tree::new().hash());
165
166    let semantic_diff_result = if options.semantic {
167        if let Some(ref to_state) = to_state {
168            Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
169        } else {
170            Some(run_semantic_worktree_diff(
171                repo,
172                &from_hash,
173                &status_options,
174            )?)
175        }
176    } else {
177        None
178    };
179
180    let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
181        result.file_changes.clone()
182    } else if let Some(ref to_state) = to_state {
183        repo.diff_trees(&from_hash, &to_state.tree)?
184    } else if git_overlay_head_worktree_diff {
185        file_change_set_from_status(&repo.git_overlay_worktree_status()?.unwrap_or_default())
186    } else {
187        let tree = from_tree.clone().unwrap_or_default();
188        file_change_set_from_status(
189            &repo.compare_worktree_cached_with_options(&tree, &status_options)?,
190        )
191    };
192
193    let patch_text_needed = options.include_patch_text;
194    let want_hunks = patch_text_needed || !(options.name_only || options.stat);
195    let file_changes = file_changes_from_change_set(
196        repo,
197        from_tree.as_ref(),
198        to_tree.as_ref(),
199        &changes,
200        &options,
201        want_hunks,
202        patch_text_needed,
203    )?;
204
205    let semantic_changes = semantic_diff_result.map(|result| {
206        result
207            .changes
208            .into_iter()
209            .map(SemanticChangeEntry::from)
210            .collect()
211    });
212
213    let context_state = if options.show_context {
214        if let Some(ref state) = to_state {
215            Some(state.clone())
216        } else if let Some(state) = from_state.clone() {
217            Some(state)
218        } else {
219            repo.current_state()?
220        }
221    } else {
222        None
223    };
224
225    let stats = DiffStats::from_changes(&file_changes, semantic_changes.as_deref());
226    let mut output = DiffReport::with_stats(
227        from_id.map(|id| id.short()),
228        options.to.clone(),
229        file_changes,
230        semantic_changes,
231        None,
232        None,
233        stats,
234    );
235    output.base = options.base.map(DiffBase::as_str);
236    output.worktree_mode = options.to.is_none();
237    let mut output = finalize_diff_report(output, &options)?;
238    if let Some(state) = context_state.as_ref() {
239        attach_show_context(repo, &mut output, state, &options.paths)?;
240    }
241    Ok(output)
242}
243
244/// Record the first successful capture in a live harness session. A matching
245/// anchor is retained only while it remains on this thread's first-parent
246/// chain; a new session or unrelated thread starts a new turn anchor.
247pub fn record_last_turn_capture(
248    repo: &Repository,
249    session_id: &str,
250    captured_state: StateId,
251) -> Result<()> {
252    let _guard = repo.locker().write()?;
253    let keep_existing = match read_last_turn_anchor(repo.root()) {
254        Some(anchor) if anchor.session_id == session_id => {
255            first_parent_contains(repo, captured_state, anchor.state_id)?
256        }
257        Some(_) | None => false,
258    };
259    if keep_existing {
260        return Ok(());
261    }
262    write_last_turn_anchor(
263        repo.root(),
264        &LastTurnAnchor {
265            session_id: session_id.to_string(),
266            state_id: captured_state,
267        },
268    )?;
269    Ok(())
270}
271
272/// Resolve `last-turn` against the live workspace stamp and the selected
273/// target's thread history. Every missing or stale link is refused.
274pub fn resolve_last_turn_base(repo: &Repository, target: StateId) -> Result<StateId> {
275    let session_id = read_identity_cursor(repo.root())
276        .session
277        .filter(|session| !session.trim().is_empty())
278        .ok_or_else(|| anyhow!("no agent turn is available for the last-turn base"))?;
279    let anchor = read_last_turn_anchor(repo.root())
280        .filter(|anchor| anchor.session_id == session_id)
281        .ok_or_else(|| anyhow!("no captured agent turn matches the last-turn session stamp"))?;
282    if repo.store().get_state(&anchor.state_id)?.is_none() {
283        return Err(anyhow!("the captured last-turn base state is unavailable"));
284    }
285    if !first_parent_contains(repo, target, anchor.state_id)? {
286        return Err(anyhow!(
287            "the last-turn base is not on the selected thread history"
288        ));
289    }
290    Ok(anchor.state_id)
291}
292
293fn first_parent_contains(repo: &Repository, start: StateId, expected: StateId) -> Result<bool> {
294    let mut current = Some(start);
295    while let Some(state_id) = current {
296        if state_id == expected {
297            return Ok(true);
298        }
299        let Some(state) = repo.store().get_state(&state_id)? else {
300            return Ok(false);
301        };
302        current = state.parents.first().copied();
303    }
304    Ok(false)
305}
306
307fn file_changes_from_change_set(
308    repo: &Repository,
309    from_tree: Option<&Tree>,
310    to_tree: Option<&Tree>,
311    changes: &FileChangeSet,
312    options: &DiffOptions,
313    want_hunks: bool,
314    patch_text_needed: bool,
315) -> Result<Vec<FileChange>> {
316    let file_changes: Vec<FileChange> = if options.name_only && !patch_text_needed {
317        changes
318            .iter()
319            .map(|change| {
320                make_status_only_change(
321                    Some(repo),
322                    from_tree,
323                    to_tree,
324                    &change.path,
325                    &change.kind.to_string(),
326                )
327            })
328            .collect()
329    } else {
330        changes
331            .iter()
332            .map(|change| {
333                let effective_kind = if to_tree.is_none() {
334                    worktree_modified_type_change(repo.root(), &change.path, change.kind)
335                        .map(|(_, diff_kind)| diff_kind)
336                        .unwrap_or(change.kind)
337                } else {
338                    change.kind
339                };
340                let diff_result = if let Some(tree) = to_tree {
341                    get_state_diff(repo, from_tree, tree, &change.path, &effective_kind)
342                } else {
343                    get_worktree_diff(repo, from_tree, &change.path, &effective_kind)
344                };
345                let binary = diff_result.as_ref().err().is_some_and(is_binary_diff_error);
346                let (raw_lines, eol) = match diff_result {
347                    Ok((lines, eol)) => (Some(lines), eol),
348                    Err(_) => (None, FileEolState::default()),
349                };
350                let (lines, line_counts) = if options.stat && !patch_text_needed {
351                    let counts = change_line_counts(raw_lines.as_deref());
352                    (None, Some(counts))
353                } else {
354                    (
355                        raw_lines.map(|lines| unified_hunks(lines, options.unified, &eol)),
356                        None,
357                    )
358                };
359
360                let kind = effective_kind.to_string();
361                let (old_mode, mode) =
362                    change_file_modes(repo, from_tree, to_tree, &change.path, &kind);
363                let symlink = symlink_change_for_paths(
364                    repo,
365                    from_tree,
366                    to_tree,
367                    &kind,
368                    &change.path,
369                    &change.path,
370                    old_mode,
371                    mode,
372                );
373                FileChange {
374                    path: change.path.clone(),
375                    kind,
376                    binary: binary && symlink.is_none(),
377                    lines,
378                    line_counts,
379                    eol,
380                    mode,
381                    old_mode,
382                    symlink,
383                    ..Default::default()
384                }
385            })
386            .collect()
387    };
388    let file_changes = sort_changes_by_path(file_changes);
389    let file_changes = expand_type_changes(
390        repo,
391        from_tree,
392        to_tree,
393        file_changes,
394        want_hunks,
395        options.unified,
396    )?;
397    detect_clear_renames(
398        repo,
399        from_tree,
400        to_tree,
401        file_changes,
402        want_hunks,
403        options.unified,
404    )
405}
406
407/// Compute a HEAD-vs-worktree report from an existing status scan.
408pub fn diff_worktree_status(
409    status: &WorktreeStatus,
410    options: &DiffOptions,
411    repo: Option<&Repository>,
412    detect_renames: bool,
413) -> Result<DiffReport> {
414    let want_hunks = options.include_patch_text && repo.is_some();
415    let from_tree = match repo {
416        Some(repo) => head_from_tree(repo)?,
417        None => None,
418    };
419    let changes = file_changes_from_status(
420        status,
421        want_hunks,
422        repo,
423        from_tree.as_ref(),
424        options.unified,
425    );
426    let changes = match repo {
427        Some(repo) => expand_type_changes(
428            repo,
429            from_tree.as_ref(),
430            None,
431            changes,
432            want_hunks,
433            options.unified,
434        )?,
435        None => changes,
436    };
437    let changes = if detect_renames {
438        match repo {
439            Some(repo) => detect_clear_renames(
440                repo,
441                from_tree.as_ref(),
442                None,
443                changes,
444                want_hunks,
445                options.unified,
446            )?,
447            None => changes,
448        }
449    } else {
450        changes
451    };
452    let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
453    output.worktree_mode = true;
454    let mut output = finalize_diff_report(output, options)?;
455    if options.show_context
456        && let Some(repo) = repo
457        && let Some(state) = worktree_context_state(repo)?
458    {
459        attach_show_context(repo, &mut output, &state, &options.paths)?;
460    }
461    Ok(output)
462}
463
464/// Compute a HEAD-vs-worktree report for a plain Git repository discovered by
465/// the CLI health layer.
466pub fn plain_git_head_diff(probe: &PlainGitDiffProbe, options: &DiffOptions) -> Result<DiffReport> {
467    if options.include_patch_text {
468        let changes = plain_git_file_changes_with_hunks(probe, options.unified)?;
469        let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
470        output.worktree_mode = true;
471        return finalize_diff_report(output, options);
472    }
473    diff_worktree_status(&probe.changes, options, None, false)
474}
475
476fn finalize_diff_report(mut output: DiffReport, options: &DiffOptions) -> Result<DiffReport> {
477    path_filter::apply_path_filters(&mut output, &options.paths)?;
478    if options.include_patch_text {
479        populate_patch_text(&mut output);
480    }
481    if options.stat {
482        output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
483    }
484    Ok(output)
485}
486
487/// Render and stash the standard unified-diff text on the output payload.
488fn populate_patch_text(output: &mut DiffReport) {
489    let text = render_diff_patch(output);
490    if !text.is_empty() {
491        output.patch = Some(text);
492    }
493}
494
495fn file_change_set_from_status(status: &WorktreeStatus) -> FileChangeSet {
496    let mut changes = FileChangeSet::with_capacity(status.change_count());
497    for path in &status.modified {
498        changes.push_modified(path.display().to_string());
499    }
500    for path in &status.added {
501        changes.push_added(path.display().to_string());
502    }
503    for path in &status.deleted {
504        changes.push_deleted(path.display().to_string());
505    }
506    changes
507}
508
509fn resolve_state_id(repository: &Repository, spec: &str) -> Result<StateId> {
510    resolve_state_for_command(repository, spec, ResolvePolicy::minimal())
511        .map(|resolved| resolved.state_id)
512        .map_err(|error| match error {
513            StateResolveError::Repository(err) => err.into(),
514            StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
515                anyhow!(HeddleError::recovery(RecoveryDetails::state_not_found(
516                    spec
517                )))
518            }
519            StateResolveError::Failure(other) => anyhow!("{other}"),
520        })
521}
522
523fn require_resolved_state(repo: &Repository, id: &StateId) -> Result<State> {
524    repo.store().get_state(id)?.ok_or_else(|| {
525        anyhow!(HeddleError::MissingObject {
526            object_type: "state".to_string(),
527            id: id.to_string_full(),
528        })
529    })
530}
531
532#[cfg(feature = "semantic")]
533fn run_semantic_diff(
534    repo: &Repository,
535    from_tree_hash: &objects::object::ContentHash,
536    to_tree_hash: &objects::object::ContentHash,
537) -> Result<SemanticDiffResult> {
538    let options = SemanticDiffOptions::default();
539    let result =
540        semantic::diff::semantic_diff(repo.store(), from_tree_hash, to_tree_hash, &options)?;
541    Ok(SemanticDiffResult {
542        changes: result.changes,
543        file_changes: result.file_changes,
544    })
545}
546
547#[cfg(not(feature = "semantic"))]
548fn run_semantic_diff(
549    _repo: &Repository,
550    _from_tree_hash: &objects::object::ContentHash,
551    _to_tree_hash: &objects::object::ContentHash,
552) -> Result<SemanticDiffResult> {
553    Err(anyhow!(HeddleError::recovery(
554        RecoveryDetails::feature_unavailable("semantic diff", "semantic")
555    )))
556}
557
558#[cfg(feature = "semantic")]
559fn run_semantic_worktree_diff(
560    repo: &Repository,
561    from_tree_hash: &objects::object::ContentHash,
562    status_options: &repo::WorktreeStatusOptions,
563) -> Result<SemanticDiffResult> {
564    let from_tree = repo.require_tree(from_tree_hash)?;
565    let status = repo.compare_worktree_cached_with_options(&from_tree, status_options)?;
566    let status = SemanticWorktreeStatus {
567        modified: status.modified,
568        added: status.added,
569        deleted: status.deleted,
570    };
571    let options = SemanticDiffOptions::default();
572    let result = semantic::diff::semantic_diff_worktree(
573        repo.store(),
574        from_tree_hash,
575        repo.root(),
576        &status,
577        &options,
578    )?;
579    Ok(SemanticDiffResult {
580        changes: result.changes,
581        file_changes: result.file_changes,
582    })
583}
584
585#[cfg(not(feature = "semantic"))]
586fn run_semantic_worktree_diff(
587    _repo: &Repository,
588    _from_tree_hash: &objects::object::ContentHash,
589    _status_options: &repo::WorktreeStatusOptions,
590) -> Result<SemanticDiffResult> {
591    Err(anyhow!(HeddleError::recovery(
592        RecoveryDetails::feature_unavailable("semantic diff", "semantic")
593    )))
594}
595
596/// Order a state-to-state change list by flat path. `diff_trees` emits a
597/// deterministic merge-join order over sorted tree entries, but recursive
598/// directory descent can still differ from this flat `String::cmp` order for
599/// paths such as `a.txt` and `a/file.txt`. git emits diff entries in flat path
600/// order; sorting here matches that and keeps every render of the same diff
601/// byte-identical. Sort *before* `expand_type_changes` so each type change's
602/// local delete-before-add ordering stays intact (the expansion replaces a
603/// single entry in place).
604fn sort_changes_by_path(mut changes: Vec<FileChange>) -> Vec<FileChange> {
605    changes.sort_by(|a, b| a.path.cmp(&b.path));
606    changes
607}
608/// Build one `FileChange` per status entry in the plain-Git probe,
609/// computing real hunks against the sley-read HEAD blobs so `--patch`
610/// emits a body the regular renderer can stamp newline markers onto.
611///
612/// Unborn HEAD (plain `git init` + staged file, no commit yet) has
613/// no tree to read; in that case we skip old-side lookup and the add-only path
614/// in `compute_plain_git_hunks` renders against `/dev/null`. Without
615/// this check, resolving old-side blobs propagates a "no HEAD commit" error and
616/// the whole `--patch` render fails, even though the only honest diff
617/// is "everything is new."
618fn plain_git_file_changes_with_hunks(
619    probe: &PlainGitDiffProbe,
620    unified: usize,
621) -> Result<Vec<FileChange>> {
622    let git_repo = SleyRepository::discover(&probe.root)?;
623    let head_has_tree = !git_repo.head()?.is_unborn();
624    // `plain_git_worktree_status` can report the same path as BOTH
625    // deleted (index-vs-HEAD) and added (untracked worktree) — e.g.
626    // `git rm --cached f` followed by editing the still-present untracked
627    // `f`. Emitting an add patch and a separate delete patch for one path
628    // produces a conflicting pair `git apply` rejects; git renders that
629    // state as a single modify (HEAD content -> worktree content), so we
630    // coalesce here.
631    let added_set: BTreeSet<&Path> = probe.changes.added.iter().map(PathBuf::as_path).collect();
632    let deleted_set: BTreeSet<&Path> = probe.changes.deleted.iter().map(PathBuf::as_path).collect();
633
634    let mut changes = Vec::with_capacity(probe.changes.change_count());
635    for path in &probe.changes.modified {
636        push_plain_git_modified(
637            &git_repo,
638            head_has_tree,
639            &probe.root,
640            path,
641            unified,
642            &mut changes,
643        )?;
644    }
645    for path in &probe.changes.added {
646        if deleted_set.contains(path.as_path()) {
647            // Coalesced HEAD→worktree modify (see above): route through the
648            // type-change classifier so a coalesced regular↔symlink swap
649            // splits into delete+add rather than emitting a cross-type chmod.
650            push_plain_git_modified(
651                &git_repo,
652                head_has_tree,
653                &probe.root,
654                path,
655                unified,
656                &mut changes,
657            )?;
658        } else {
659            changes.push(plain_git_file_change(
660                &git_repo,
661                head_has_tree,
662                &probe.root,
663                path,
664                "added",
665                DiffKind::Added,
666                unified,
667            )?);
668        }
669    }
670    for path in &probe.changes.deleted {
671        // Already emitted as a coalesced modify in the added loop.
672        if added_set.contains(path.as_path()) {
673            continue;
674        }
675        changes.push(plain_git_file_change(
676            &git_repo,
677            head_has_tree,
678            &probe.root,
679            path,
680            "deleted",
681            DiffKind::Deleted,
682            unified,
683        )?);
684    }
685    Ok(changes)
686}
687
688#[allow(clippy::too_many_arguments)]
689fn plain_git_file_change(
690    git_repo: &SleyRepository,
691    head_has_tree: bool,
692    root: &Path,
693    path: &std::path::Path,
694    kind: &str,
695    diff_kind: DiffKind,
696    unified: usize,
697) -> Result<FileChange> {
698    let (old_blob, old_mode) = match (head_has_tree, &diff_kind) {
699        (true, DiffKind::Modified | DiffKind::Deleted) => {
700            match plain_git_lookup_blob_and_mode(git_repo, path)? {
701                Some((blob, mode)) => (Some(blob), Some(mode)),
702                None => (None, None),
703            }
704        }
705        _ => (None, None),
706    };
707    let new_blob = match diff_kind {
708        DiffKind::Added | DiffKind::Modified => {
709            // A read error here means the file vanished between the
710            // status scan and the diff attempt — fall back to status-
711            // only so the rendered patch at least names the path.
712            read_worktree_blob_for_diff(&root.join(path)).ok()
713        }
714        _ => None,
715    };
716    // Added files take their mode from the live worktree; deleted files
717    // from the HEAD tree entry resolved above. A modify carries both: the
718    // HEAD-tree old mode and the live-worktree new mode, so a chmod
719    // (exec-bit flip) surfaces as `old mode`/`new mode`.
720    let (old_mode_field, mode) = match diff_kind {
721        DiffKind::Added => (None, worktree_file_mode(&root.join(path))),
722        DiffKind::Deleted => (None, old_mode),
723        DiffKind::Modified => (old_mode, worktree_file_mode(&root.join(path))),
724        DiffKind::Unchanged => (None, None),
725    };
726    let (lines, eol, binary) =
727        compute_plain_git_hunks(old_blob.as_ref(), new_blob.as_ref(), &diff_kind, unified);
728    let symlink = symlink_change_from_blobs(
729        kind,
730        old_blob.as_ref(),
731        old_mode_field,
732        new_blob.as_ref(),
733        mode,
734    );
735    Ok(FileChange {
736        path: path.display().to_string(),
737        kind: kind.to_string(),
738        binary: binary && symlink.is_none(),
739        lines,
740        eol,
741        mode,
742        old_mode: old_mode_field,
743        symlink,
744        ..Default::default()
745    })
746}
747
748fn plain_git_lookup_blob_and_mode(
749    git_repo: &SleyRepository,
750    path: &std::path::Path,
751) -> Result<Option<(Blob, FileMode)>> {
752    let tree_path = plain_git_tree_path(path);
753    let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
754        return Ok(None);
755    };
756    let Some(entry_mode) = entry.mode else {
757        return Ok(None);
758    };
759    let mode = match EntryKind::from_mode(entry_mode) {
760        Some(EntryKind::Symlink) => FileMode::Symlink,
761        Some(EntryKind::BlobExecutable) => FileMode::Executable,
762        Some(EntryKind::Blob) => FileMode::Normal,
763        _ => return Ok(None),
764    };
765    let object = git_repo.read_object(&entry.oid)?;
766    Ok(Some((Blob::new(object.body.clone()), mode)))
767}
768
769fn plain_git_tree_path(path: &std::path::Path) -> String {
770    path.components()
771        .map(|component| component.as_os_str().to_string_lossy())
772        .collect::<Vec<_>>()
773        .join("/")
774}
775
776/// Classify the HEAD-tree side of a plain-Git path. A tracked entry is a
777/// blob or symlink — git records no directory entries — so this returns
778/// `Regular` or `Symlink`; an absent entry (unborn HEAD, or a path not in
779/// HEAD) is `Absent`, which `is_type_change` treats as no type change so
780/// the modify renders as content.
781fn plain_git_old_side_kind(
782    git_repo: &SleyRepository,
783    head_has_tree: bool,
784    path: &std::path::Path,
785) -> Result<SideKind> {
786    if !head_has_tree {
787        return Ok(SideKind::Absent);
788    }
789    let tree_path = plain_git_tree_path(path);
790    let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
791        return Ok(SideKind::Absent);
792    };
793    Ok(match entry.mode.and_then(EntryKind::from_mode) {
794        Some(EntryKind::Symlink) => SideKind::Symlink,
795        Some(EntryKind::Tree) => SideKind::Dir,
796        _ => SideKind::Regular,
797    })
798}
799
800/// Emit the plain-Git `FileChange`(s) for one `modified` (or coalesced-
801/// modify) path, splitting a *type change* into the delete+add pair git
802/// records rather than a cross-type chmod `git apply` rejects.
803///
804/// This is the plain-Git mirror of the heddle path's
805/// `worktree_modified_type_change` + `expand_type_changes`: it reuses the
806/// same `worktree_side_kind` / `is_type_change` decision so both backends
807/// classify identical input identically (a regular↔symlink swap splits, a
808/// file→dir change downgrades to a deletion whose new leaves arrive as
809/// their own `added` entries from status). A tracked old side is always a
810/// single blob/symlink, so there is never an old subtree to expand here.
811fn push_plain_git_modified(
812    git_repo: &SleyRepository,
813    head_has_tree: bool,
814    root: &Path,
815    path: &std::path::Path,
816    unified: usize,
817    out: &mut Vec<FileChange>,
818) -> Result<()> {
819    let new_kind = worktree_side_kind(&root.join(path));
820    let old_kind = plain_git_old_side_kind(git_repo, head_has_tree, path)?;
821    if is_type_change(old_kind, new_kind) {
822        out.push(plain_git_file_change(
823            git_repo,
824            head_has_tree,
825            root,
826            path,
827            "deleted",
828            DiffKind::Deleted,
829            unified,
830        )?);
831        // A new-side directory's leaves arrive as separate `added` status
832        // entries; only a non-directory new side adds here.
833        if new_kind != SideKind::Dir {
834            out.push(plain_git_file_change(
835                git_repo,
836                head_has_tree,
837                root,
838                path,
839                "added",
840                DiffKind::Added,
841                unified,
842            )?);
843        }
844    } else {
845        out.push(plain_git_file_change(
846            git_repo,
847            head_has_tree,
848            root,
849            path,
850            "modified",
851            DiffKind::Modified,
852            unified,
853        )?);
854    }
855    Ok(())
856}
857
858fn compute_plain_git_hunks(
859    old: Option<&Blob>,
860    new: Option<&Blob>,
861    diff_kind: &DiffKind,
862    unified: usize,
863) -> (Option<Vec<LineDiff>>, FileEolState, bool) {
864    let attempt = || -> Result<(Vec<LineDiff>, FileEolState)> {
865        match diff_kind {
866            DiffKind::Added => {
867                let Some(new) = new else {
868                    return Ok((Vec::new(), FileEolState::default()));
869                };
870                ensure_text_diffable(new)?;
871                let eol = eol_for_added(new);
872                Ok((number_lines(blob_lines(new, "+")?), eol))
873            }
874            DiffKind::Deleted => {
875                let Some(old) = old else {
876                    return Ok((Vec::new(), FileEolState::default()));
877                };
878                ensure_text_diffable(old)?;
879                let eol = eol_for_deleted(old);
880                Ok((number_lines(blob_lines(old, "-")?), eol))
881            }
882            DiffKind::Modified => match (old, new) {
883                (Some(old), Some(new)) => modified_blob_hunks(old, new),
884                (None, Some(new)) => {
885                    ensure_text_diffable(new)?;
886                    let eol = eol_for_added(new);
887                    Ok((number_lines(blob_lines(new, "+")?), eol))
888                }
889                (Some(old), None) => {
890                    ensure_text_diffable(old)?;
891                    let eol = eol_for_deleted(old);
892                    Ok((number_lines(blob_lines(old, "-")?), eol))
893                }
894                (None, None) => Ok((Vec::new(), FileEolState::default())),
895            },
896            DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
897        }
898    };
899    match attempt() {
900        Ok((lines, eol)) => (Some(unified_hunks(lines, unified, &eol)), eol, false),
901        Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
902        Err(_) => (None, FileEolState::default(), false),
903    }
904}
905/// Build `FileChange` entries from a `WorktreeStatus`, optionally
906/// computing the per-file hunk vector (with EOL metadata) so the
907/// patch renderer has something to render. When `want_hunks` is
908/// false the entries are status-only — same as the old behaviour.
909fn file_changes_from_status(
910    status: &objects::worktree::WorktreeStatus,
911    want_hunks: bool,
912    repo: Option<&Repository>,
913    from_tree: Option<&Tree>,
914    unified: usize,
915) -> Vec<FileChange> {
916    let mut changes = Vec::with_capacity(status.change_count());
917    for path in &status.modified {
918        changes.push(make_status_file_change(
919            path,
920            "modified",
921            DiffKind::Modified,
922            want_hunks,
923            repo,
924            from_tree,
925            unified,
926        ));
927    }
928    for path in &status.added {
929        changes.push(make_status_file_change(
930            path,
931            "added",
932            DiffKind::Added,
933            want_hunks,
934            repo,
935            from_tree,
936            unified,
937        ));
938    }
939    for path in &status.deleted {
940        changes.push(make_status_file_change(
941            path,
942            "deleted",
943            DiffKind::Deleted,
944            want_hunks,
945            repo,
946            from_tree,
947            unified,
948        ));
949    }
950    changes
951}
952
953#[allow(clippy::too_many_arguments)]
954fn make_status_file_change(
955    path: &std::path::Path,
956    kind: &str,
957    diff_kind: DiffKind,
958    want_hunks: bool,
959    repo: Option<&Repository>,
960    from_tree: Option<&Tree>,
961    unified: usize,
962) -> FileChange {
963    let path_str = path.display().to_string();
964    // Reclassify a `modified` path that is now a directory (file→dir type
965    // change) into a deletion so the renderer emits `+++ /dev/null` and
966    // `git apply` removes the blocking file before the nested adds land.
967    let (kind, diff_kind) = match repo
968        .and_then(|repo| worktree_modified_type_change(repo.root(), &path_str, diff_kind))
969    {
970        Some(reclassified) => reclassified,
971        None => (kind, diff_kind),
972    };
973    match repo {
974        Some(repo) if want_hunks => {
975            build_worktree_change(repo, from_tree, &path_str, kind, diff_kind, unified)
976        }
977        _ => make_status_only_change(repo, from_tree, None, &path_str, kind),
978    }
979}
980
981/// Build a status-only `FileChange` (no hunk body) that still carries its
982/// `(old_mode, mode)` pair. Modes are cheap metadata that *every* output mode
983/// needs, not just `--patch`/JSON: rename detection rejects a cross-type
984/// (regular↔symlink) collapse by comparing the two sides' modes, and the
985/// renderers stamp rename+mode headers from them. Gating mode capture on the
986/// hunk-only flag dropped them on the default/`--stat`/`--name-only` paths, so
987/// a cross-type move silently re-collapsed into a rename there while `--patch`
988/// (which kept the modes) correctly stayed split (cid 3321103601). This is the
989/// single chokepoint every status-only construction site routes through — the
990/// worktree-status path, the type-change split, and the `--name-only` builder
991/// — so the capture can't diverge between them again. `repo == None` is the
992/// plain-Git fast path, which has no object store to resolve modes from (and
993/// runs no rename collapse), so it stays modeless.
994fn make_status_only_change(
995    repo: Option<&Repository>,
996    from_tree: Option<&Tree>,
997    to_tree: Option<&Tree>,
998    path_str: &str,
999    kind: &str,
1000) -> FileChange {
1001    let (old_mode, mode) = match repo {
1002        Some(repo) => change_file_modes(repo, from_tree, to_tree, path_str, kind),
1003        None => (None, None),
1004    };
1005    FileChange {
1006        path: path_str.to_string(),
1007        kind: kind.to_string(),
1008        mode,
1009        old_mode,
1010        ..Default::default()
1011    }
1012}
1013
1014/// Build a worktree-side `FileChange` with its hunk vector, EOL metadata,
1015/// and `(old_mode, mode)` pair. Worktree status diffs have no `to_tree`:
1016/// the new-side mode comes from the live worktree, the old-side mode from
1017/// `from_tree`.
1018fn build_worktree_change(
1019    repo: &Repository,
1020    from_tree: Option<&Tree>,
1021    path_str: &str,
1022    kind: &str,
1023    diff_kind: DiffKind,
1024    unified: usize,
1025) -> FileChange {
1026    let (old_mode, mode) = change_file_modes(repo, from_tree, None, path_str, kind);
1027    let (lines, eol, binary) = match get_worktree_diff(repo, from_tree, path_str, &diff_kind) {
1028        Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1029        Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1030        // Worktree read errors on a status-listed file mean the file
1031        // vanished between the status scan and the diff attempt. Fall back
1032        // to status-only; the renderer prints the file header without a
1033        // body, matching git's behaviour for transient races.
1034        Err(_) => (None, FileEolState::default(), false),
1035    };
1036    let symlink = symlink_change_for_paths(
1037        repo, from_tree, None, kind, path_str, path_str, old_mode, mode,
1038    );
1039    FileChange {
1040        path: path_str.to_string(),
1041        kind: kind.to_string(),
1042        binary: binary && symlink.is_none(),
1043        lines,
1044        eol,
1045        mode,
1046        old_mode,
1047        symlink,
1048        ..Default::default()
1049    }
1050}
1051
1052/// The object kind a path resolves to on one side of a diff.
1053#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1054enum SideKind {
1055    Absent,
1056    Dir,
1057    /// A regular or executable file (`100644` / `100755`).
1058    Regular,
1059    Symlink,
1060}
1061
1062/// Classify a path's kind within a tree (the old side of a diff, or the
1063/// new side of a state-to-state diff). `find_entry_in_tree` resolves blob
1064/// and symlink leaves; a `None` there means either a directory or a
1065/// missing path, disambiguated by `dir_subtree_in_tree`.
1066fn tree_side_kind(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<SideKind> {
1067    let Some(tree) = tree else {
1068        return Ok(SideKind::Absent);
1069    };
1070    if let Some(entry) = find_entry_in_tree(repo, tree, path)? {
1071        return Ok(if entry.entry_type() == EntryType::Symlink {
1072            SideKind::Symlink
1073        } else {
1074            SideKind::Regular
1075        });
1076    }
1077    if dir_subtree_in_tree(repo, tree, path)?.is_some() {
1078        Ok(SideKind::Dir)
1079    } else {
1080        Ok(SideKind::Absent)
1081    }
1082}
1083
1084/// Classify a path's new-side kind: the `to_tree` entry for a
1085/// state-to-state diff, otherwise the live worktree.
1086fn new_side_kind(repo: &Repository, to_tree: Option<&Tree>, path: &str) -> Result<SideKind> {
1087    match to_tree {
1088        Some(tree) => tree_side_kind(repo, Some(tree), path),
1089        None => Ok(worktree_side_kind(&repo.root().join(path))),
1090    }
1091}
1092
1093/// Classify a worktree path. `symlink_metadata` does not follow links, so
1094/// a symlink (even one pointing at a directory) reports `Symlink`, not
1095/// `Dir`. A missing path is `Absent`.
1096fn worktree_side_kind(path: &Path) -> SideKind {
1097    let Ok(meta) = std::fs::symlink_metadata(path) else {
1098        return SideKind::Absent;
1099    };
1100    if meta.file_type().is_symlink() {
1101        SideKind::Symlink
1102    } else if meta.is_dir() {
1103        SideKind::Dir
1104    } else {
1105        SideKind::Regular
1106    }
1107}
1108
1109/// A `modified` entry whose two sides are different object *kinds* — git
1110/// can't represent it as a chmod and `git apply` rejects the attempt.
1111fn is_type_change(old: SideKind, new: SideKind) -> bool {
1112    use SideKind::{Dir, Regular, Symlink};
1113    matches!(
1114        (old, new),
1115        (Dir, Regular)
1116            | (Dir, Symlink)
1117            | (Regular, Dir)
1118            | (Symlink, Dir)
1119            | (Regular, Symlink)
1120            | (Symlink, Regular)
1121    )
1122}
1123
1124/// Rewrite a `modified` entry that is actually a *type change* into the
1125/// delete-old + add-new pair `git diff` emits, so `git apply` can swap one
1126/// object kind for another instead of attempting a cross-type chmod.
1127///
1128/// Two shapes need this (both verified against `git diff`):
1129/// * **dir ↔ file/symlink** — a tracked directory replaced by a file (or
1130///   the reverse). git emits a deletion of every leaf under the old
1131///   directory plus an add of the new file (or vice versa); a bare
1132///   `old mode`/`new mode` chmod cannot turn a directory into a file
1133///   (cid 3319484717 — the committed-diff side dropped this entirely).
1134/// * **regular ↔ symlink** — `100644`/`100755` ⇄ `120000`. git emits a
1135///   delete of the old object and an add of the new; `git apply` rejects
1136///   the `old mode 100644`/`new mode 120000` chmod form across this
1137///   boundary (cid 3319484727).
1138///
1139/// Shared by the worktree path (`to_tree == None`, new side read from
1140/// disk) and the state-to-state path (`to_tree == Some`, new side read
1141/// from the object store) so the split is byte-identical on both — fixing
1142/// it in only one place would leave committed diffs (`heddle diff HEAD~1
1143/// HEAD --patch`) emitting the form git rejects.
1144///
1145/// The worktree path never sees a *file → dir* `modified` entry here:
1146/// `worktree_modified_type_change` downgrades it to a deletion upstream
1147/// and the directory's new leaves arrive as separate `added` entries from
1148/// status. The state path has no such upstream pass, so both directions
1149/// are handled below.
1150fn expand_type_changes(
1151    repo: &Repository,
1152    from_tree: Option<&Tree>,
1153    to_tree: Option<&Tree>,
1154    changes: Vec<FileChange>,
1155    want_hunks: bool,
1156    unified: usize,
1157) -> Result<Vec<FileChange>> {
1158    let mut output = Vec::with_capacity(changes.len());
1159    for change in changes {
1160        if change.kind != "modified" {
1161            output.push(change);
1162            continue;
1163        }
1164        let old_kind = tree_side_kind(repo, from_tree, &change.path)?;
1165        let new_kind = new_side_kind(repo, to_tree, &change.path)?;
1166        if !is_type_change(old_kind, new_kind) {
1167            output.push(change);
1168            continue;
1169        }
1170
1171        // Delete the old side: every leaf under a directory, else the
1172        // single old object.
1173        if old_kind == SideKind::Dir {
1174            if let Some(from_tree) = from_tree
1175                && let Some(subtree) = dir_subtree_in_tree(repo, from_tree, &change.path)?
1176            {
1177                let mut nested = Vec::new();
1178                collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1179                for nested_path in nested {
1180                    output.push(make_type_change_part(
1181                        repo,
1182                        Some(from_tree),
1183                        to_tree,
1184                        &nested_path,
1185                        DiffKind::Deleted,
1186                        want_hunks,
1187                        unified,
1188                    ));
1189                }
1190            }
1191        } else {
1192            output.push(make_type_change_part(
1193                repo,
1194                from_tree,
1195                to_tree,
1196                &change.path,
1197                DiffKind::Deleted,
1198                want_hunks,
1199                unified,
1200            ));
1201        }
1202
1203        // Add the new side: every leaf under a directory, else the single
1204        // new object. A new-side directory only occurs in the state path
1205        // (the worktree path reclassifies file→dir upstream), so its
1206        // leaves come from `to_tree`.
1207        if new_kind == SideKind::Dir {
1208            if let Some(to_tree) = to_tree
1209                && let Some(subtree) = dir_subtree_in_tree(repo, to_tree, &change.path)?
1210            {
1211                let mut nested = Vec::new();
1212                collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1213                for nested_path in nested {
1214                    output.push(make_type_change_part(
1215                        repo,
1216                        from_tree,
1217                        Some(to_tree),
1218                        &nested_path,
1219                        DiffKind::Added,
1220                        want_hunks,
1221                        unified,
1222                    ));
1223                }
1224            }
1225        } else {
1226            output.push(make_type_change_part(
1227                repo,
1228                from_tree,
1229                to_tree,
1230                &change.path,
1231                DiffKind::Added,
1232                want_hunks,
1233                unified,
1234            ));
1235        }
1236    }
1237    Ok(output)
1238}
1239
1240fn make_type_change_part(
1241    repo: &Repository,
1242    from_tree: Option<&Tree>,
1243    to_tree: Option<&Tree>,
1244    path_str: &str,
1245    diff_kind: DiffKind,
1246    want_hunks: bool,
1247    unified: usize,
1248) -> FileChange {
1249    let kind = diff_kind.to_string();
1250    if !want_hunks {
1251        return make_status_only_change(Some(repo), from_tree, to_tree, path_str, &kind);
1252    }
1253    match to_tree {
1254        Some(to_tree) => build_state_change(
1255            repo, from_tree, to_tree, path_str, &kind, diff_kind, unified,
1256        ),
1257        None => build_worktree_change(repo, from_tree, path_str, &kind, diff_kind, unified),
1258    }
1259}
1260
1261/// State-to-state analogue of `build_worktree_change`: both sides come
1262/// from the object store, so the new-side mode and content are read from
1263/// `to_tree` rather than the live worktree.
1264fn build_state_change(
1265    repo: &Repository,
1266    from_tree: Option<&Tree>,
1267    to_tree: &Tree,
1268    path_str: &str,
1269    kind: &str,
1270    diff_kind: DiffKind,
1271    unified: usize,
1272) -> FileChange {
1273    let (old_mode, mode) = change_file_modes(repo, from_tree, Some(to_tree), path_str, kind);
1274    let (lines, eol, binary) = match get_state_diff(repo, from_tree, to_tree, path_str, &diff_kind)
1275    {
1276        Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1277        Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1278        Err(_) => (None, FileEolState::default(), false),
1279    };
1280    let symlink = symlink_change_for_paths(
1281        repo,
1282        from_tree,
1283        Some(to_tree),
1284        kind,
1285        path_str,
1286        path_str,
1287        old_mode,
1288        mode,
1289    );
1290    FileChange {
1291        path: path_str.to_string(),
1292        kind: kind.to_string(),
1293        binary: binary && symlink.is_none(),
1294        lines,
1295        eol,
1296        mode,
1297        old_mode,
1298        symlink,
1299        ..Default::default()
1300    }
1301}
1302
1303/// Resolve `path` to its subtree if it names a directory in `tree`,
1304/// descending component by component. Returns `None` for a missing path or
1305/// a blob/symlink leaf.
1306fn dir_subtree_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Tree>> {
1307    let mut current = tree.clone();
1308    let mut parts = path.split('/').peekable();
1309    while let Some(name) = parts.next() {
1310        let Some(entry) = current.get(name) else {
1311            return Ok(None);
1312        };
1313        if !entry.is_tree() {
1314            return Ok(None);
1315        }
1316        let Some(hash) = entry.tree_hash() else {
1317            return Ok(None);
1318        };
1319        let Some(subtree) = repo.store().get_tree(&hash)? else {
1320            return Ok(None);
1321        };
1322        if parts.peek().is_none() {
1323            return Ok(Some(subtree));
1324        }
1325        current = subtree;
1326    }
1327    Ok(None)
1328}
1329
1330/// Collect every blob/symlink leaf path under `subtree`, prefixed with the
1331/// subtree's path, so a dir→file type change can emit a deletion per file.
1332fn collect_subtree_blob_paths(
1333    repo: &Repository,
1334    subtree: &Tree,
1335    prefix: &str,
1336    out: &mut Vec<String>,
1337) -> Result<()> {
1338    for entry in subtree.entries() {
1339        let child_path = format!("{prefix}/{}", entry.name());
1340        if entry.is_tree() {
1341            if let Some(hash) = entry.tree_hash()
1342                && let Some(nested) = repo.store().get_tree(&hash)?
1343            {
1344                collect_subtree_blob_paths(repo, &nested, &child_path, out)?;
1345            }
1346        } else {
1347            out.push(child_path);
1348        }
1349    }
1350    Ok(())
1351}
1352
1353fn head_from_tree(repo: &Repository) -> Result<Option<Tree>> {
1354    let Some(head_id) = repo.head()? else {
1355        return Ok(None);
1356    };
1357    let Some(state) = repo.store().get_state(&head_id)? else {
1358        return Ok(None);
1359    };
1360    Ok(repo.store().get_tree(&state.tree)?)
1361}
1362
1363/// Compute a state-to-state diff payload without printing.
1364///
1365/// Reuses the same line-rendering pipeline as `cmd_diff`'s state-to-state
1366/// path: object-store lookups for both sides, `diff_blobs` for modified
1367/// files, hunk grouping via `unified_hunks`. The result is the same
1368/// `DiffReport` shape that `cmd_diff` serializes, so callers can embed
1369/// it inside their own JSON payload.
1370///
1371/// Used by `heddle merge --with-diff` to surface the diff that would
1372/// land (or just landed) without a separate `heddle diff` invocation.
1373///
1374/// `semantic` requests the semantic change list in addition to the
1375/// line-level hunks. Building with `--features semantic` is required;
1376/// otherwise this errors out the same way `cmd_diff --semantic` does.
1377pub fn compute_state_diff(
1378    repo: &Repository,
1379    from_state_id: &StateId,
1380    to_state_id: &StateId,
1381    semantic: bool,
1382    unified: usize,
1383) -> Result<DiffReport> {
1384    let from_state = repo.store().get_state(from_state_id)?;
1385    let from_tree = if let Some(ref state) = from_state {
1386        repo.store().get_tree(&state.tree)?
1387    } else {
1388        None
1389    };
1390
1391    let to_state = require_resolved_state(repo, to_state_id)?;
1392    let to_tree = repo
1393        .store()
1394        .get_tree(&to_state.tree)?
1395        .ok_or_else(|| anyhow!("Tree not found for state {}", to_state_id.short()))?;
1396
1397    let from_hash = from_state
1398        .as_ref()
1399        .map(|s| s.tree)
1400        .unwrap_or_else(|| Tree::new().hash());
1401
1402    let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1403        Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
1404    } else {
1405        None
1406    };
1407
1408    let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1409        result.file_changes.clone()
1410    } else {
1411        repo.diff_trees(&from_hash, &to_state.tree)?
1412    };
1413
1414    let file_changes: Vec<FileChange> = changes
1415        .iter()
1416        .map(|change| {
1417            build_state_change(
1418                repo,
1419                from_tree.as_ref(),
1420                &to_tree,
1421                &change.path,
1422                &change.kind.to_string(),
1423                change.kind,
1424                unified,
1425            )
1426        })
1427        .collect();
1428    let file_changes = sort_changes_by_path(file_changes);
1429    let file_changes = expand_type_changes(
1430        repo,
1431        from_tree.as_ref(),
1432        Some(&to_tree),
1433        file_changes,
1434        true,
1435        unified,
1436    )?;
1437    let file_changes = detect_clear_renames(
1438        repo,
1439        from_tree.as_ref(),
1440        Some(&to_tree),
1441        file_changes,
1442        true,
1443        unified,
1444    )?;
1445
1446    let semantic_changes = semantic_diff_result.map(|r| {
1447        r.changes
1448            .into_iter()
1449            .map(SemanticChangeEntry::from)
1450            .collect()
1451    });
1452
1453    let mut output = DiffReport::new(
1454        Some(from_state_id.short()),
1455        Some(to_state_id.short()),
1456        file_changes,
1457        semantic_changes,
1458        None,
1459        None,
1460    );
1461    populate_patch_text(&mut output);
1462    Ok(output)
1463}
1464
1465/// Compute a diff from an existing state to an in-memory tree.
1466///
1467/// Merge preview uses this for clean 3-way previews: the tree that would
1468/// land has been computed, but no state has been committed yet. The top
1469/// tree is installed in the object store so the existing semantic and
1470/// rename-aware diff pipeline can address it by hash.
1471pub fn compute_tree_diff(
1472    repo: &Repository,
1473    from_state_id: &StateId,
1474    to_tree: &Tree,
1475    to_label: impl Into<String>,
1476    semantic: bool,
1477    unified: usize,
1478) -> Result<DiffReport> {
1479    let from_state = repo.store().get_state(from_state_id)?;
1480    let from_tree = if let Some(ref state) = from_state {
1481        repo.store().get_tree(&state.tree)?
1482    } else {
1483        None
1484    };
1485    let from_hash = from_state
1486        .as_ref()
1487        .map(|s| s.tree)
1488        .unwrap_or_else(|| Tree::new().hash());
1489
1490    let to_hash = repo.store().put_tree(to_tree)?;
1491
1492    let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1493        Some(run_semantic_diff(repo, &from_hash, &to_hash)?)
1494    } else {
1495        None
1496    };
1497
1498    let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1499        result.file_changes.clone()
1500    } else {
1501        repo.diff_trees(&from_hash, &to_hash)?
1502    };
1503
1504    let file_changes: Vec<FileChange> = changes
1505        .iter()
1506        .map(|change| {
1507            build_state_change(
1508                repo,
1509                from_tree.as_ref(),
1510                to_tree,
1511                &change.path,
1512                &change.kind.to_string(),
1513                change.kind,
1514                unified,
1515            )
1516        })
1517        .collect();
1518    let file_changes = sort_changes_by_path(file_changes);
1519    let file_changes = expand_type_changes(
1520        repo,
1521        from_tree.as_ref(),
1522        Some(to_tree),
1523        file_changes,
1524        true,
1525        unified,
1526    )?;
1527    let file_changes = detect_clear_renames(
1528        repo,
1529        from_tree.as_ref(),
1530        Some(to_tree),
1531        file_changes,
1532        true,
1533        unified,
1534    )?;
1535
1536    let semantic_changes = semantic_diff_result.map(|r| {
1537        r.changes
1538            .into_iter()
1539            .map(SemanticChangeEntry::from)
1540            .collect()
1541    });
1542
1543    let mut output = DiffReport::new(
1544        Some(from_state_id.short()),
1545        Some(to_label.into()),
1546        file_changes,
1547        semantic_changes,
1548        None,
1549        None,
1550    );
1551    populate_patch_text(&mut output);
1552    Ok(output)
1553}
1554
1555fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
1556    changes
1557        .into_iter()
1558        .map(|mut change| {
1559            change.lines = None;
1560            change
1561        })
1562        .collect()
1563}
1564
1565fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
1566    if lines.is_empty() {
1567        return lines;
1568    }
1569    if !lines.iter().any(|line| line.prefix != " ") {
1570        // No `+`/`-` lines. The only way an all-context diff is still a
1571        // real change is a trailing-newline-only edit (`hello\n` <->
1572        // `hello`): `diff_blobs` strips terminators, so the changed tail
1573        // line collapses to shared context. Synthesize a single tail
1574        // hunk so the renderer can split it and attach the
1575        // `\ No newline at end of file` marker. Otherwise it's a genuine
1576        // no-op — return the lines untouched (no hunk header).
1577        if eol.old_has_final_newline == eol.new_has_final_newline {
1578            return lines;
1579        }
1580        return eol_only_tail_hunk(lines, context);
1581    }
1582
1583    let mut ranges = Vec::<(usize, usize)>::new();
1584    let mut cursor = 0usize;
1585    while cursor < lines.len() {
1586        while cursor < lines.len() && lines[cursor].prefix == " " {
1587            cursor += 1;
1588        }
1589        if cursor >= lines.len() {
1590            break;
1591        }
1592
1593        let start = cursor.saturating_sub(context);
1594        while cursor < lines.len() && lines[cursor].prefix != " " {
1595            cursor += 1;
1596        }
1597        let mut end = (cursor + context).min(lines.len());
1598
1599        while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
1600            cursor += 1;
1601        }
1602        while cursor < lines.len() && lines[cursor].prefix != " " {
1603            end = (cursor + 1 + context).min(lines.len());
1604            cursor += 1;
1605        }
1606
1607        if let Some((_, previous_end)) = ranges.last_mut()
1608            && start <= *previous_end
1609        {
1610            *previous_end = end;
1611            continue;
1612        }
1613        ranges.push((start, end));
1614    }
1615
1616    let mut output = Vec::new();
1617    for (start, end) in ranges {
1618        let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1619        output.push(LineDiff {
1620            prefix: "@".to_string(),
1621            content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1622            old_line: None,
1623            new_line: None,
1624        });
1625        // Emit the hunk body UNTRIMMED. Decoration trimming drops a real
1626        // `+` line, which is a pretty-display nicety only — applying it
1627        // here would desync the body from the `@@` header counts computed
1628        // above (via `hunk_span`) and corrupt the `--patch`/JSON line
1629        // model so `git apply` rejects or mis-reconstructs the file (cid
1630        // 3320364905). The trim now lives in `print_diff` alone, via
1631        // `trim_added_decorations_for_display`.
1632        output.extend_from_slice(&lines[start..end]);
1633    }
1634    output
1635}
1636
1637/// Build a single hunk anchored on the file's last line for a
1638/// trailing-newline-only change. The body is `context` lines plus the
1639/// tail (all shared context); the renderer (`render_patch_hunks`) splits
1640/// the tail into a `-`/`+` pair and attaches the no-newline marker to
1641/// the side that lacks the terminator. Mirrors `git diff`'s hunk for an
1642/// EOL-only edit (e.g. `@@ -2,4 +2,4 @@` for a 5-line file at context 3).
1643fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
1644    let end = lines.len();
1645    let start = end.saturating_sub(context + 1);
1646    let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1647    let mut output = Vec::with_capacity(end - start + 1);
1648    output.push(LineDiff {
1649        prefix: "@".to_string(),
1650        content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1651        old_line: None,
1652        new_line: None,
1653    });
1654    output.extend_from_slice(&lines[start..end]);
1655    output
1656}
1657
1658/// Pretty-display transform: drop a leading added "decoration" line
1659/// (`#[...]`, `///`, `@`, etc.) when an identical context line already
1660/// follows the inserted block, so the diff anchors on the existing item
1661/// rather than showing a duplicated attribute.
1662///
1663/// DISPLAY ONLY. This drops a real `+` line, so it must never reach the
1664/// `--patch`/JSON line model — the dropped line is a genuine change and
1665/// omitting it desyncs the `@@` header counts, corrupting `git apply`
1666/// (cid 3320364905). `unified_hunks` keeps the canonical (untrimmed)
1667/// hunk body; `print_diff` calls this purely for human-facing rendering.
1668///
1669/// Applied per hunk body (segmented on the `@` header lines) so the
1670/// decoration match can never cross a hunk boundary into an unrelated
1671/// context line.
1672pub fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
1673    let mut output = Vec::with_capacity(lines.len());
1674    let mut body_start = 0usize;
1675    for (index, line) in lines.iter().enumerate() {
1676        if line.prefix == "@" {
1677            if body_start < index {
1678                output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
1679            }
1680            output.push(line.clone());
1681            body_start = index + 1;
1682        }
1683    }
1684    if body_start < lines.len() {
1685        output.extend(trim_trailing_added_decorations(&lines[body_start..]));
1686    }
1687    output
1688}
1689
1690fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
1691    let mut trimmed = Vec::with_capacity(lines.len());
1692    let mut index = 0usize;
1693    while index < lines.len() {
1694        if lines[index].prefix == "+"
1695            && is_visual_decoration_line(&lines[index].content)
1696            && let Some(next_context) = next_context_line(lines, index + 1)
1697            && next_context.content == lines[index].content
1698        {
1699            let added_block_has_code = lines[index + 1..next_context.index]
1700                .iter()
1701                .any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
1702            if added_block_has_code {
1703                index += 1;
1704                continue;
1705            }
1706        }
1707        trimmed.push(lines[index].clone());
1708        index += 1;
1709    }
1710    trimmed
1711}
1712
1713struct IndexedLine<'a> {
1714    index: usize,
1715    content: &'a str,
1716}
1717
1718fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
1719    lines[start..]
1720        .iter()
1721        .enumerate()
1722        .find(|(_, line)| line.prefix == " ")
1723        .map(|(offset, line)| IndexedLine {
1724            index: start + offset,
1725            content: &line.content,
1726        })
1727}
1728
1729fn is_blank_or_visual_decoration(line: &str) -> bool {
1730    line.trim().is_empty() || is_visual_decoration_line(line)
1731}
1732
1733fn is_visual_decoration_line(line: &str) -> bool {
1734    let trimmed = line.trim_start();
1735    trimmed.starts_with("#[")
1736        || trimmed.starts_with("#![")
1737        || trimmed.starts_with('@')
1738        || trimmed.starts_with("///")
1739        || trimmed.starts_with("//!")
1740}
1741
1742fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
1743    let old_before = lines[..start]
1744        .iter()
1745        .filter(|line| line.prefix != "+")
1746        .count();
1747    let new_before = lines[..start]
1748        .iter()
1749        .filter(|line| line.prefix != "-")
1750        .count();
1751    let old_len = lines[start..end]
1752        .iter()
1753        .filter(|line| line.prefix != "+")
1754        .count();
1755    let new_len = lines[start..end]
1756        .iter()
1757        .filter(|line| line.prefix != "-")
1758        .count();
1759
1760    let old_start = if old_len == 0 {
1761        old_before
1762    } else {
1763        old_before + 1
1764    };
1765    let new_start = if new_len == 0 {
1766        new_before
1767    } else {
1768        new_before + 1
1769    };
1770    (old_start, old_len, new_start, new_len)
1771}
1772
1773fn get_worktree_diff(
1774    repo: &Repository,
1775    from_tree: Option<&Tree>,
1776    path: &str,
1777    kind: &DiffKind,
1778) -> Result<(Vec<LineDiff>, FileEolState)> {
1779    let worktree_path = repo.root().join(path);
1780
1781    match kind {
1782        DiffKind::Added => {
1783            let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1784            let eol = eol_for_added(&new_blob);
1785            Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1786        }
1787        DiffKind::Deleted => {
1788            // `find_blob_in_tree` walks the path component by component;
1789            // a root-only `tree.get(path)` misses nested deletions like
1790            // `src/nested/file.txt` and would drop the deletion hunk.
1791            if let Some(tree) = from_tree
1792                && let Some(blob) = find_blob_in_tree(repo, tree, path)?
1793            {
1794                let eol = eol_for_deleted(&blob);
1795                return Ok((number_lines(blob_lines(&blob, "-")?), eol));
1796            }
1797            Ok((vec![], FileEolState::default()))
1798        }
1799        DiffKind::Modified => {
1800            let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1801
1802            if let Some(tree) = from_tree
1803                && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
1804            {
1805                return modified_blob_hunks(&old_blob, &new_blob);
1806            }
1807
1808            let eol = eol_for_added(&new_blob);
1809            Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1810        }
1811        DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
1812    }
1813}
1814
1815/// A tracked file replaced by a directory (`foo` → `foo/bar`) surfaces in
1816/// heddle's worktree status as a `modified` path whose worktree side is
1817/// now a directory. `git diff` represents that as a *deletion* of the file
1818/// (the directory's new files arrive as separate `added` entries), so we
1819/// reclassify the modify to a deletion: otherwise `read_worktree_blob_for_diff`
1820/// fails reading the directory, the change collapses to `lines: None`, and
1821/// the renderer drops it — leaving `git apply` unable to create `foo/bar`
1822/// over the still-present `foo`. Returns the effective `(kind, DiffKind)`.
1823///
1824/// Classification goes through `worktree_side_kind` (`symlink_metadata`, no
1825/// link following), so only a *real* directory triggers the downgrade. A
1826/// regular file replaced by a symlink *pointing at* a directory reports
1827/// `Symlink`, stays a `modified` entry, and is split into delete+add by
1828/// `expand_type_changes` — `Path::is_dir()` would have followed the link,
1829/// misread it as a directory, and dropped the `120000` add (cid 3320033195).
1830fn worktree_modified_type_change(
1831    repo_root: &Path,
1832    path: &str,
1833    diff_kind: DiffKind,
1834) -> Option<(&'static str, DiffKind)> {
1835    if matches!(diff_kind, DiffKind::Modified)
1836        && worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
1837    {
1838        Some(("deleted", DiffKind::Deleted))
1839    } else {
1840        None
1841    }
1842}
1843
1844fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
1845    let metadata = std::fs::symlink_metadata(path)?;
1846    if metadata.file_type().is_symlink() {
1847        let target = std::fs::read_link(path)?;
1848        return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
1849    }
1850    Ok(Blob::new(std::fs::read(path)?))
1851}
1852
1853fn is_symlink_mode(mode: Option<FileMode>) -> bool {
1854    matches!(mode, Some(FileMode::Symlink))
1855}
1856
1857/// Whether each side of a change is a symlink, resolved per `kind`. The mode
1858/// fields' meaning is kind-dependent: an `added`/`deleted` change carries the
1859/// present side's mode in `mode` (with `old_mode == None` even for a delete,
1860/// where `mode` is the *deleted* file's mode — see `change_file_modes`),
1861/// while a `modified`/`renamed` change carries `old_mode` + `mode` per side.
1862/// Reading `old_mode`/`mode` blindly would miss a deleted symlink (whose
1863/// old-side mode lives in `mode`, not `old_mode`).
1864fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
1865    match kind {
1866        "added" => (false, is_symlink_mode(mode)),
1867        "deleted" => (is_symlink_mode(mode), false),
1868        _ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
1869    }
1870}
1871
1872/// The single byte-preserving extraction of symlink target content for one
1873/// change. A symlink's git blob *is* its raw target bytes, so the renderer
1874/// reconstructs the patch hunk from these directly — never through
1875/// `content_str()`/`diff_blobs` (which require UTF-8) and never as a
1876/// placeholder-binary stanza (which `git apply` rejects for a `120000`
1877/// entry). A side's bytes are taken only when that side's mode is a symlink:
1878/// `old`/`new` mirror the change's two sides (an add has no old side, a
1879/// delete no new side, a target-edit/rename both). Returns `None` when
1880/// neither side is a symlink, leaving the change to render as ordinary text.
1881fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
1882    (old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
1883}
1884
1885/// Build the symlink content from blobs already in hand (the plain-Git path,
1886/// which loads both sides up front). `blob.content()` is the raw target bytes
1887/// for a symlink entry, so no lossy conversion ever occurs.
1888fn symlink_change_from_blobs(
1889    kind: &str,
1890    old_blob: Option<&Blob>,
1891    old_mode: Option<FileMode>,
1892    new_blob: Option<&Blob>,
1893    mode: Option<FileMode>,
1894) -> Option<SymlinkChange> {
1895    let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1896    let old = old_is_link
1897        .then(|| old_blob.map(|blob| blob.content().to_vec()))
1898        .flatten();
1899    let new = new_is_link
1900        .then(|| new_blob.map(|blob| blob.content().to_vec()))
1901        .flatten();
1902    make_symlink_change(old, new)
1903}
1904
1905/// Build the symlink content for a heddle-overlay change by loading each
1906/// side's blob through the same loaders the hunk path uses
1907/// (`blob_from_tree` for a tree side, `new_blob_for_rename` for the new side,
1908/// which reads the live worktree via `read_worktree_blob_for_diff` when
1909/// `to_tree` is `None`). `to_tree == None` means the new side is the live
1910/// worktree. `old_path`/`new_path` differ only for a rename.
1911#[allow(clippy::too_many_arguments)]
1912fn symlink_change_for_paths(
1913    repo: &Repository,
1914    from_tree: Option<&Tree>,
1915    to_tree: Option<&Tree>,
1916    kind: &str,
1917    old_path: &str,
1918    new_path: &str,
1919    old_mode: Option<FileMode>,
1920    mode: Option<FileMode>,
1921) -> Option<SymlinkChange> {
1922    let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1923    let old = old_is_link
1924        .then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
1925        .flatten()
1926        .map(|blob| blob.content().to_vec());
1927    let new = new_is_link
1928        .then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
1929        .flatten()
1930        .map(|blob| blob.content().to_vec());
1931    make_symlink_change(old, new)
1932}
1933fn detect_clear_renames(
1934    repo: &Repository,
1935    from_tree: Option<&Tree>,
1936    to_tree: Option<&Tree>,
1937    changes: Vec<FileChange>,
1938    include_lines: bool,
1939    unified: usize,
1940) -> Result<Vec<FileChange>> {
1941    detect_clear_renames_with_stats(
1942        repo,
1943        from_tree,
1944        to_tree,
1945        changes,
1946        include_lines,
1947        unified,
1948        &mut RenameDetectionStats::default(),
1949    )
1950}
1951
1952#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1953struct RenameDetectionStats {
1954    blob_reads: usize,
1955    lcs_comparisons: usize,
1956    total_possible_pairs: usize,
1957    qualifying_candidate_pairs: usize,
1958}
1959
1960struct PreparedRenameBlob {
1961    blob: Blob,
1962    content_hash: ContentHash,
1963    text: Option<RenameTextFingerprint>,
1964}
1965
1966struct RenameTextFingerprint {
1967    line_count: usize,
1968    line_hash_counts: BTreeMap<u64, usize>,
1969}
1970
1971#[allow(clippy::too_many_arguments)]
1972fn detect_clear_renames_with_stats(
1973    repo: &Repository,
1974    from_tree: Option<&Tree>,
1975    to_tree: Option<&Tree>,
1976    changes: Vec<FileChange>,
1977    include_lines: bool,
1978    unified: usize,
1979    stats: &mut RenameDetectionStats,
1980) -> Result<Vec<FileChange>> {
1981    let mut deleted = changes
1982        .iter()
1983        .filter(|change| change.kind == "deleted")
1984        .map(|change| change.path.as_str())
1985        .collect::<Vec<_>>();
1986    let mut added = changes
1987        .iter()
1988        .filter(|change| change.kind == "added")
1989        .map(|change| change.path.as_str())
1990        .collect::<Vec<_>>();
1991    deleted.sort_unstable();
1992    added.sort_unstable();
1993    if deleted.is_empty() || added.is_empty() {
1994        return Ok(changes);
1995    }
1996    stats.total_possible_pairs = deleted.len().saturating_mul(added.len());
1997
1998    // Snapshot each side's git mode so a candidate can be rejected when the
1999    // deleted and added sides differ in git *type class* (regular vs
2000    // symlink). git never renames across a type boundary: `git apply`
2001    // rejects a `rename from/to` whose `old mode`/`new mode` cross S_IFMT
2002    // (e.g. `100644` → `120000`). Such a pair must stay a delete + add,
2003    // which the cross-path delete/add rendering already round-trips. A
2004    // regular↔executable move stays *within* the regular class, so it is
2005    // intentionally still collapsible — git emits it as a rename with an
2006    // `old mode`/`new mode` pair that `git apply` accepts.
2007    let deleted_side_modes = changes
2008        .iter()
2009        .filter(|change| change.kind == "deleted")
2010        .map(|change| (change.path.as_str(), change.mode))
2011        .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
2012    let added_side_modes = changes
2013        .iter()
2014        .filter(|change| change.kind == "added")
2015        .map(|change| (change.path.as_str(), change.mode))
2016        .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
2017
2018    let mut added_blobs = BTreeMap::new();
2019    for new_path in &added {
2020        stats.blob_reads += 1;
2021        if let Some(blob) = new_blob_for_rename(repo, to_tree, new_path)? {
2022            added_blobs.insert(*new_path, prepare_rename_blob(blob));
2023        }
2024    }
2025
2026    let mut candidates = RenameCandidateIndex::new(deleted.len(), added.len());
2027    for (old_index, old_path) in deleted.iter().enumerate() {
2028        stats.blob_reads += 1;
2029        let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2030            continue;
2031        };
2032        let old_blob = prepare_rename_blob(old_blob);
2033        for (new_index, new_path) in added.iter().enumerate() {
2034            // A delete + add at the *same* path is a type change
2035            // (regular ↔ symlink), not a rename — `expand_type_changes`
2036            // emits both halves and collapsing them back into a
2037            // `foo → foo` rename would drop the type swap.
2038            if old_path == new_path {
2039                continue;
2040            }
2041            // A cross-*type* move (regular ↔ symlink) at different paths is
2042            // never a rename either: collapsing it would emit a rename
2043            // header carrying a mismatched `old mode`/`new mode`, which
2044            // `git apply` rejects. Leave the pair as a separate delete +
2045            // add. (Regular↔executable stays compatible — see the
2046            // mode-snapshot comment above.)
2047            if !rename_mode_compatible(
2048                deleted_side_modes.get(old_path).copied().flatten(),
2049                added_side_modes.get(new_path).copied().flatten(),
2050            ) {
2051                continue;
2052            }
2053            let Some(new_blob) = added_blobs.get(new_path) else {
2054                continue;
2055            };
2056            let score = rename_similarity(&old_blob, new_blob, stats);
2057            if score >= RENAME_SIMILARITY_THRESHOLD {
2058                candidates.push(old_index, new_index, score);
2059            }
2060        }
2061    }
2062    stats.qualifying_candidate_pairs = candidates.candidate_count();
2063
2064    let renames = candidates
2065        .assign()
2066        .into_iter()
2067        .map(|assignment| {
2068            (
2069                deleted[assignment.source_index].to_string(),
2070                added[assignment.target_index].to_string(),
2071                assignment.score,
2072            )
2073        })
2074        .collect::<Vec<_>>();
2075    if renames.is_empty() {
2076        return Ok(changes);
2077    }
2078
2079    let rename_by_new = renames
2080        .iter()
2081        .map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
2082        .collect::<std::collections::BTreeMap<_, _>>();
2083    let removed_old = renames
2084        .iter()
2085        .map(|(old_path, _, _)| old_path.as_str())
2086        .collect::<BTreeSet<_>>();
2087    // The deleted entry (whose `mode` carries the rename's *old-side*
2088    // mode) is dropped below, so snapshot old-side modes keyed by path
2089    // first. A rename paired with a chmod/type change (`old.sh` -> `new.sh`
2090    // made executable) needs both modes on the collapsed `renamed` change
2091    // so the renderer can emit `old mode`/`new mode`.
2092    let deleted_modes = changes
2093        .iter()
2094        .filter(|change| change.kind == "deleted")
2095        .map(|change| (change.path.clone(), change.mode))
2096        .collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
2097
2098    let mut output = Vec::with_capacity(changes.len() - renames.len());
2099    for mut change in changes {
2100        if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2101            continue;
2102        }
2103        if change.kind == "added"
2104            && let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
2105        {
2106            let (lines, eol) = if include_lines {
2107                match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
2108                    Ok(Some((lines, eol))) => (Some(lines), eol),
2109                    Ok(None) => (None, FileEolState::default()),
2110                    Err(error) if is_binary_diff_error(&error) => {
2111                        change.binary = true;
2112                        (None, FileEolState::default())
2113                    }
2114                    Err(error) => return Err(error),
2115                }
2116            } else {
2117                (None, FileEolState::default())
2118            };
2119            change.kind = "renamed".to_string();
2120            change.old_path = Some(old_path.to_string());
2121            change.similarity_score = Some(score);
2122            change.lines = lines;
2123            change.eol = eol;
2124            // `change.mode` already holds the added (new) side mode; pull
2125            // the deleted (old) side mode off the snapshot so a rename+chmod
2126            // surfaces both modes in the patch headers.
2127            change.old_mode = deleted_modes.get(old_path).copied().flatten();
2128            // A symlink↔symlink rename (the only symlink move that collapses;
2129            // `rename_mode_compatible` keeps regular↔symlink as delete+add)
2130            // must carry byte-preserving target content so the renderer emits
2131            // a target-bytes hunk for a non-UTF-8 link instead of a binary
2132            // marker. Load both sides through the same loaders the rename
2133            // similarity used.
2134            change.symlink = symlink_change_for_paths(
2135                repo,
2136                from_tree,
2137                to_tree,
2138                "renamed",
2139                old_path,
2140                &change.path,
2141                change.old_mode,
2142                change.mode,
2143            );
2144            if change.symlink.is_some() {
2145                change.binary = false;
2146            }
2147            // The original `added` carried a stat-path tally that
2148            // counted the file as a pure insertion; after we collapse
2149            // the (added, deleted) pair into one rename, those line
2150            // counts double-count the move. Drop them so DiffStats
2151            // falls back to walking the (possibly None) `lines`
2152            // payload chosen above.
2153            change.line_counts = None;
2154        }
2155        output.push(change);
2156    }
2157    Ok(output)
2158}
2159
2160fn rename_lines(
2161    repo: &Repository,
2162    from_tree: Option<&Tree>,
2163    to_tree: Option<&Tree>,
2164    old_path: &str,
2165    new_path: &str,
2166    unified: usize,
2167) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
2168    let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2169        return Ok(None);
2170    };
2171    let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
2172        return Ok(None);
2173    };
2174    ensure_text_diffable(&old_blob)?;
2175    ensure_text_diffable(&new_blob)?;
2176    let eol = eol_for_modified(&old_blob, &new_blob);
2177    let diff = diff_blobs(&old_blob, &new_blob);
2178    let lines = diff
2179        .iter()
2180        .map(|line| LineDiff::new(line.prefix(), line.content()))
2181        .collect();
2182    Ok(Some((
2183        unified_hunks(number_lines(lines), unified, &eol),
2184        eol,
2185    )))
2186}
2187
2188fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
2189    let Some(tree) = tree else {
2190        return Ok(None);
2191    };
2192    find_blob_in_tree(repo, tree, path)
2193}
2194
2195fn new_blob_for_rename(
2196    repo: &Repository,
2197    to_tree: Option<&Tree>,
2198    path: &str,
2199) -> Result<Option<Blob>> {
2200    if let Some(tree) = to_tree {
2201        return find_blob_in_tree(repo, tree, path);
2202    }
2203
2204    // Rename similarity must compare the bytes git would store as the blob,
2205    // per entry type: a regular file → its content, a symlink → its target
2206    // *path* bytes. `read_worktree_blob_for_diff` branches on the entry type
2207    // (`read_link` for symlinks, `read` for files) — a blind `std::fs::read`
2208    // here would *follow* a symlink and score the dereferenced target file's
2209    // content, collapsing a symlink move into a wrong-target rename whose
2210    // patch leaves the old link target after `git apply` (cid 3322115749).
2211    let worktree_path = repo.root().join(path);
2212    match std::fs::symlink_metadata(&worktree_path) {
2213        Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
2214        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2215        Err(error) => Err(error.into()),
2216    }
2217}
2218
2219/// Whether a delete + add can be collapsed into a single `renamed` change
2220/// given the two sides' git file modes. git only renames *within* one
2221/// S_IFMT type class: regular files (`100644`) and executables (`100755`)
2222/// share the regular-file type, so a move between them renders as a rename
2223/// with an `old mode`/`new mode` pair that `git apply` accepts; a symlink
2224/// (`120000`) is a distinct type, so a regular↔symlink move is never a
2225/// rename — `git apply` rejects a `rename from/to` whose `new mode
2226/// (120000)` doesn't match its `old mode (100644)`. A missing mode falls
2227/// back to the regular-file default the renderer also assumes.
2228fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
2229    let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
2230    is_symlink(old) == is_symlink(new)
2231}
2232
2233fn prepare_rename_blob(blob: Blob) -> PreparedRenameBlob {
2234    let content_hash = blob.hash();
2235    let text = blob.content_str().and_then(|text| {
2236        if text.chars().any(is_terminal_hostile_control) {
2237            return None;
2238        }
2239        let mut line_count = 0;
2240        let mut line_hash_counts = BTreeMap::new();
2241        for line in text.lines() {
2242            line_count += 1;
2243            *line_hash_counts.entry(cheap_line_hash(line)).or_insert(0) += 1;
2244        }
2245        Some(RenameTextFingerprint {
2246            line_count,
2247            line_hash_counts,
2248        })
2249    });
2250    PreparedRenameBlob {
2251        blob,
2252        content_hash,
2253        text,
2254    }
2255}
2256
2257fn cheap_line_hash(line: &str) -> u64 {
2258    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
2259    const FNV_PRIME: u64 = 0x100000001b3;
2260    line.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
2261        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
2262    })
2263}
2264
2265fn can_reach_rename_threshold(
2266    old_text: &RenameTextFingerprint,
2267    new_text: &RenameTextFingerprint,
2268) -> bool {
2269    let total_lines = old_text.line_count + new_text.line_count;
2270    if old_text.line_count == 0 || new_text.line_count == 0 {
2271        return false;
2272    }
2273
2274    let length_upper_bound = old_text.line_count.min(new_text.line_count);
2275    if (length_upper_bound as u128) * 8 < (total_lines as u128) * 3 {
2276        return false;
2277    }
2278
2279    // An LCS cannot contain more copies of a line than the two inputs share.
2280    // Hash collisions only raise this upper bound, so they can admit extra
2281    // LCS work but can never reject a qualifying pair.
2282    let shared_hash_upper_bound = old_text
2283        .line_hash_counts
2284        .iter()
2285        .filter_map(|(hash, old_count)| {
2286            new_text
2287                .line_hash_counts
2288                .get(hash)
2289                .map(|new_count| old_count.min(new_count))
2290        })
2291        .sum::<usize>();
2292    (shared_hash_upper_bound as u128) * 8 >= (total_lines as u128) * 3
2293}
2294
2295fn rename_similarity(
2296    old_blob: &PreparedRenameBlob,
2297    new_blob: &PreparedRenameBlob,
2298    stats: &mut RenameDetectionStats,
2299) -> f64 {
2300    if old_blob.content_hash == new_blob.content_hash
2301        && old_blob.blob.content() == new_blob.blob.content()
2302    {
2303        return 1.0;
2304    }
2305    let (Some(old_fingerprint), Some(new_fingerprint)) = (&old_blob.text, &new_blob.text) else {
2306        return 0.0;
2307    };
2308    if !can_reach_rename_threshold(old_fingerprint, new_fingerprint) {
2309        return 0.0;
2310    }
2311    let old_text = old_blob
2312        .blob
2313        .content_str()
2314        .expect("text fingerprint requires UTF-8 content");
2315    let new_text = new_blob
2316        .blob
2317        .content_str()
2318        .expect("text fingerprint requires UTF-8 content");
2319    let old_lines = old_text.lines().collect::<Vec<_>>();
2320    let new_lines = new_text.lines().collect::<Vec<_>>();
2321    stats.lcs_comparisons += 1;
2322    let shared = lcs_len(&old_lines, &new_lines);
2323    (shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
2324}
2325
2326fn lcs_len(left: &[&str], right: &[&str]) -> usize {
2327    let mut previous = vec![0usize; right.len() + 1];
2328    let mut current = vec![0usize; right.len() + 1];
2329    for left_line in left {
2330        for (index, right_line) in right.iter().enumerate() {
2331            current[index + 1] = if left_line == right_line {
2332                previous[index] + 1
2333            } else {
2334                previous[index + 1].max(current[index])
2335            };
2336        }
2337        std::mem::swap(&mut previous, &mut current);
2338        current.fill(0);
2339    }
2340    previous[right.len()]
2341}
2342
2343/// Render line-level diff for a path between two stored states.
2344///
2345/// Sister of `get_worktree_diff`, but every blob is loaded from the
2346/// heddle object store via `find_blob_in_tree` rather than from the
2347/// live filesystem — which is why this can run from anywhere (not just
2348/// the current worktree) and why it Just Works for `heddle diff
2349/// <thread-a> <thread-b>`.
2350///
2351/// Returns the same `Vec<LineDiff>` shape `print_diff` already knows
2352/// how to render, so the only renderer change for state-to-state diffs
2353/// is "stop falling through to the binary-file catch-all."
2354fn get_state_diff(
2355    repo: &Repository,
2356    from_tree: Option<&Tree>,
2357    to_tree: &Tree,
2358    path: &str,
2359    kind: &DiffKind,
2360) -> Result<(Vec<LineDiff>, FileEolState)> {
2361    match kind {
2362        DiffKind::Added => {
2363            let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2364                return Ok((Vec::new(), FileEolState::default()));
2365            };
2366            let eol = eol_for_added(&new_blob);
2367            Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2368        }
2369        DiffKind::Deleted => {
2370            let Some(tree) = from_tree else {
2371                return Ok((Vec::new(), FileEolState::default()));
2372            };
2373            let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
2374                return Ok((Vec::new(), FileEolState::default()));
2375            };
2376            let eol = eol_for_deleted(&old_blob);
2377            Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
2378        }
2379        DiffKind::Modified => {
2380            let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2381                return Ok((Vec::new(), FileEolState::default()));
2382            };
2383            if let Some(tree) = from_tree
2384                && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
2385            {
2386                return modified_blob_hunks(&old_blob, &new_blob);
2387            }
2388            // No corresponding blob in `from_tree` — render as all-new.
2389            let eol = eol_for_added(&new_blob);
2390            Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2391        }
2392        DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
2393    }
2394}
2395
2396/// Trailing-newline state for a one-sided change (added or deleted).
2397/// The absent side is reported as "has newline" so the patch renderer
2398/// never tries to emit a marker for content that doesn't exist.
2399fn eol_for_added(new_blob: &Blob) -> FileEolState {
2400    let (new_eol, new_count) = blob_eol_meta(new_blob);
2401    FileEolState {
2402        old_has_final_newline: true,
2403        new_has_final_newline: new_eol,
2404        old_line_count: 0,
2405        new_line_count: new_count,
2406    }
2407}
2408
2409fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
2410    let (old_eol, old_count) = blob_eol_meta(old_blob);
2411    FileEolState {
2412        old_has_final_newline: old_eol,
2413        new_has_final_newline: true,
2414        old_line_count: old_count,
2415        new_line_count: 0,
2416    }
2417}
2418
2419fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
2420    let (old_eol, old_count) = blob_eol_meta(old_blob);
2421    let (new_eol, new_count) = blob_eol_meta(new_blob);
2422    FileEolState {
2423        old_has_final_newline: old_eol,
2424        new_has_final_newline: new_eol,
2425        old_line_count: old_count,
2426        new_line_count: new_count,
2427    }
2428}
2429
2430/// `diff_blobs` strips line terminators before the renderer sees the
2431/// hunks, so the per-side trailing-newline state has to come from the
2432/// raw blob bytes. Empty blobs are treated as "no marker needed":
2433/// there's nothing to lack a newline.
2434fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
2435    let content = blob.content();
2436    if content.is_empty() {
2437        return (true, 0);
2438    }
2439    let has_eol = content.ends_with(b"\n");
2440    let line_count = blob
2441        .content_str()
2442        .map(|text| text.lines().count())
2443        .unwrap_or(0);
2444    (has_eol, line_count)
2445}
2446
2447fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
2448    let text = text_diff_content(blob)?;
2449    Ok(text
2450        .lines()
2451        .map(|line| LineDiff::new(prefix, line))
2452        .collect())
2453}
2454
2455/// Compute the `(lines, eol)` for a `modified` pair of blobs, applying the
2456/// identical-content short-circuit shared by every diff-rendering path.
2457///
2458/// When the two blobs carry identical bytes the change is a pure mode flip
2459/// (chmod / exec-bit), even on a binary file: returning an empty body routes
2460/// the renderer through the `old mode`/`new mode` header instead of the
2461/// binary-refusal branch, so a binary chmod-only round-trips through `git
2462/// apply` rather than emitting a placeholder binary patch git rejects.
2463///
2464/// Both heddle-backed paths (`get_worktree_diff`, `get_state_diff`) and the
2465/// plain-Git fast path (`compute_plain_git_hunks`) call this, so the
2466/// short-circuit + text-diff decision lives in exactly one place — a binary
2467/// chmod-only behaves identically regardless of backend (cid 3320033191).
2468fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
2469    if old.content() == new.content() {
2470        return Ok((Vec::new(), FileEolState::default()));
2471    }
2472    ensure_text_diffable(old)?;
2473    ensure_text_diffable(new)?;
2474    let eol = eol_for_modified(old, new);
2475    let diff = diff_blobs(old, new);
2476    let lines = diff
2477        .iter()
2478        .map(|l| LineDiff::new(l.prefix(), l.content()))
2479        .collect();
2480    Ok((number_lines(lines), eol))
2481}
2482
2483fn ensure_text_diffable(blob: &Blob) -> Result<()> {
2484    text_diff_content(blob).map(|_| ())
2485}
2486
2487fn text_diff_content(blob: &Blob) -> Result<&str> {
2488    let Some(text) = blob.content_str() else {
2489        return Err(anyhow!(BINARY_DIFF_ERROR));
2490    };
2491    if text.chars().any(is_terminal_hostile_control) {
2492        return Err(anyhow!(BINARY_DIFF_ERROR));
2493    }
2494    Ok(text)
2495}
2496
2497fn is_binary_diff_error(error: &anyhow::Error) -> bool {
2498    error.to_string() == BINARY_DIFF_ERROR
2499}
2500
2501fn is_terminal_hostile_control(ch: char) -> bool {
2502    ch.is_control() && ch != '\n' && ch != '\t'
2503}
2504
2505fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
2506    let mut old_line = 1usize;
2507    let mut new_line = 1usize;
2508
2509    lines
2510        .into_iter()
2511        .map(|line| {
2512            let old = if line.prefix != "+" {
2513                let current = Some(old_line);
2514                old_line += 1;
2515                current
2516            } else {
2517                None
2518            };
2519            let new = if line.prefix != "-" {
2520                let current = Some(new_line);
2521                new_line += 1;
2522                current
2523            } else {
2524                None
2525            };
2526            LineDiff::with_lines(line.prefix, line.content, old, new)
2527        })
2528        .collect()
2529}
2530
2531fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
2532    match find_entry_in_tree(repo, tree, path)? {
2533        Some(entry) => match entry.content_hash() {
2534            Some(hash) if entry.is_blob() || entry.is_symlink() => {
2535                Ok(Some(repo.require_blob(&hash)?))
2536            }
2537            _ => Ok(None),
2538        },
2539        None => Ok(None),
2540    }
2541}
2542
2543/// Resolve a path to its `TreeEntry`, descending through subtrees.
2544///
2545/// `Tree::get` binary-searches a single tree's direct children only, so
2546/// a nested path like `src/nested/file.txt` must be walked component by
2547/// component — a root-level `tree.get("src/nested/file.txt")` always
2548/// misses. Returns the entry for a blob or symlink leaf; `None` for a
2549/// missing path or a directory leaf.
2550fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
2551    let parts: Vec<&str> = path.split('/').collect();
2552    find_entry_recursive(repo, tree, &parts)
2553}
2554
2555fn find_entry_recursive(
2556    repo: &Repository,
2557    tree: &Tree,
2558    parts: &[&str],
2559) -> Result<Option<TreeEntry>> {
2560    if parts.is_empty() {
2561        return Ok(None);
2562    }
2563
2564    let name = parts[0];
2565    let entry = match tree.get(name) {
2566        Some(e) => e,
2567        None => return Ok(None),
2568    };
2569
2570    if parts.len() == 1 {
2571        if entry.is_blob() || entry.entry_type() == EntryType::Symlink || entry.is_gitlink() {
2572            return Ok(Some(entry.clone()));
2573        }
2574    } else if entry.is_tree()
2575        && let Some(hash) = entry.tree_hash()
2576        && let Some(subtree) = repo.store().get_tree(&hash)?
2577    {
2578        return find_entry_recursive(repo, &subtree, &parts[1..]);
2579    }
2580
2581    Ok(None)
2582}
2583
2584/// Resolve a worktree path's git file mode for patch headers. A symlink
2585/// reports `120000`; a regular file with any executable bit set reports
2586/// `100755`; everything else `100644`. Read failures fall back to `None`
2587/// (the renderer then emits the regular-file default).
2588fn worktree_file_mode(path: &Path) -> Option<FileMode> {
2589    let metadata = std::fs::symlink_metadata(path).ok()?;
2590    if metadata.file_type().is_symlink() {
2591        return Some(FileMode::Symlink);
2592    }
2593    #[cfg(unix)]
2594    {
2595        use std::os::unix::fs::PermissionsExt;
2596        if metadata.permissions().mode() & 0o111 != 0 {
2597            return Some(FileMode::Executable);
2598        }
2599    }
2600    Some(FileMode::Normal)
2601}
2602
2603/// Resolve the `(old_mode, mode)` pair the patch renderer stamps on a
2604/// change. `mode` is the field the renderer reads for `new file mode`
2605/// (adds) / `deleted file mode` (deletes); `old_mode` pairs with it on a
2606/// `modified` change so a chmod surfaces as `old mode`/`new mode`.
2607///
2608/// * **added** — `(None, new-side mode)`: the `to_tree` entry for a
2609///   state-to-state diff, otherwise the live worktree.
2610/// * **deleted** — `(None, old-side mode)`: the `from_tree` entry's mode
2611///   carried in `mode` for the `deleted file mode` header.
2612/// * **modified** — `(old-side mode, new-side mode)`: `from_tree` entry
2613///   vs. the `to_tree` entry (state diff) or live worktree.
2614/// * anything else — `(None, None)`.
2615fn change_file_modes(
2616    repo: &Repository,
2617    from_tree: Option<&Tree>,
2618    to_tree: Option<&Tree>,
2619    path: &str,
2620    kind: &str,
2621) -> (Option<FileMode>, Option<FileMode>) {
2622    let old_side = || {
2623        from_tree
2624            .and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
2625            .map(|entry| entry.mode())
2626    };
2627    let new_side = || match to_tree {
2628        Some(tree) => find_entry_in_tree(repo, tree, path)
2629            .ok()
2630            .flatten()
2631            .map(|entry| entry.mode()),
2632        None => worktree_file_mode(&repo.root().join(path)),
2633    };
2634    match kind {
2635        "added" => (None, new_side()),
2636        "deleted" => (None, old_side()),
2637        "modified" => (old_side(), new_side()),
2638        _ => (None, None),
2639    }
2640}
2641
2642#[cfg(test)]
2643mod tests {
2644    use objects::{
2645        object::{Blob, FileMode, Tree, TreeEntry},
2646        store::ObjectStore,
2647    };
2648    use repo::Repository;
2649    use tempfile::TempDir;
2650
2651    use super::{
2652        DiffStats, FileChange, FileEolState, LineCounts, LineDiff, RENAME_SIMILARITY_THRESHOLD,
2653        RenameDetectionStats, change_line_counts, detect_clear_renames_with_stats, lcs_len,
2654        prepare_rename_blob, rename_similarity, unified_hunks,
2655    };
2656
2657    type RenameSummary = Vec<(String, String, Option<String>, Option<f64>)>;
2658
2659    fn rename_fixture(
2660        shared_line_counts: &[usize],
2661    ) -> (TempDir, Repository, Tree, Tree, Vec<FileChange>) {
2662        let temp = TempDir::new().expect("create rename fixture");
2663        let repo = Repository::init_default(temp.path()).expect("initialize rename fixture");
2664        let mut old_entries = Vec::with_capacity(shared_line_counts.len());
2665        let mut new_entries = Vec::with_capacity(shared_line_counts.len());
2666        let mut changes = Vec::with_capacity(shared_line_counts.len() * 2);
2667
2668        for (file_index, shared_lines) in shared_line_counts.iter().copied().enumerate() {
2669            let old_content = (0..32)
2670                .map(|line_index| format!("file {file_index} original line {line_index}\n"))
2671                .collect::<String>();
2672            let new_content = (0..32)
2673                .map(|line_index| {
2674                    if line_index < shared_lines {
2675                        format!("file {file_index} original line {line_index}\n")
2676                    } else {
2677                        format!("file {file_index} replacement line {line_index}\n")
2678                    }
2679                })
2680                .collect::<String>();
2681            let old_hash = repo
2682                .store()
2683                .put_blob(&Blob::from(old_content))
2684                .expect("store old rename blob");
2685            let new_hash = repo
2686                .store()
2687                .put_blob(&Blob::from(new_content))
2688                .expect("store new rename blob");
2689            let old_path = format!("old_{file_index:04}.txt");
2690            let new_path = format!("new_{file_index:04}.txt");
2691            old_entries
2692                .push(TreeEntry::file(&old_path, old_hash, false).expect("build old tree entry"));
2693            new_entries
2694                .push(TreeEntry::file(&new_path, new_hash, false).expect("build new tree entry"));
2695            changes.push(FileChange {
2696                path: old_path,
2697                kind: "deleted".to_string(),
2698                mode: Some(FileMode::Normal),
2699                ..Default::default()
2700            });
2701            changes.push(FileChange {
2702                path: new_path,
2703                kind: "added".to_string(),
2704                mode: Some(FileMode::Normal),
2705                ..Default::default()
2706            });
2707        }
2708
2709        (
2710            temp,
2711            repo,
2712            Tree::from_entries(old_entries),
2713            Tree::from_entries(new_entries),
2714            changes,
2715        )
2716    }
2717
2718    fn run_rename_fixture(shared_line_counts: &[usize]) -> (RenameSummary, RenameDetectionStats) {
2719        let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(shared_line_counts);
2720        let mut stats = RenameDetectionStats::default();
2721        let output = detect_clear_renames_with_stats(
2722            &repo,
2723            Some(&old_tree),
2724            Some(&new_tree),
2725            changes,
2726            false,
2727            0,
2728            &mut stats,
2729        )
2730        .expect("detect fixture renames");
2731        let summary = output
2732            .into_iter()
2733            .map(|change| {
2734                (
2735                    change.path,
2736                    change.kind,
2737                    change.old_path,
2738                    change.similarity_score,
2739                )
2740            })
2741            .collect();
2742        (summary, stats)
2743    }
2744
2745    #[test]
2746    fn rename_fixture_characterizes_exact_threshold_and_rejected_pairs() {
2747        let (summary, _) = run_rename_fixture(&[32, 31, 24, 23]);
2748
2749        assert_eq!(
2750            summary,
2751            vec![
2752                (
2753                    "new_0000.txt".to_string(),
2754                    "renamed".to_string(),
2755                    Some("old_0000.txt".to_string()),
2756                    Some(1.0),
2757                ),
2758                (
2759                    "new_0001.txt".to_string(),
2760                    "renamed".to_string(),
2761                    Some("old_0001.txt".to_string()),
2762                    Some(31.0 / 32.0),
2763                ),
2764                (
2765                    "new_0002.txt".to_string(),
2766                    "renamed".to_string(),
2767                    Some("old_0002.txt".to_string()),
2768                    Some(0.75),
2769                ),
2770                (
2771                    "old_0003.txt".to_string(),
2772                    "deleted".to_string(),
2773                    None,
2774                    None,
2775                ),
2776                ("new_0003.txt".to_string(), "added".to_string(), None, None,),
2777            ]
2778        );
2779    }
2780
2781    #[test]
2782    fn many_rename_detection_reads_each_blob_once_and_limits_lcs_to_candidates() {
2783        const FILE_COUNT: usize = 32;
2784        let (summary, stats) = run_rename_fixture(&[31; FILE_COUNT]);
2785
2786        assert_eq!(summary.len(), FILE_COUNT);
2787        assert!(summary.iter().all(|(_, kind, _, _)| kind == "renamed"));
2788        assert_eq!(
2789            stats.blob_reads,
2790            FILE_COUNT * 2,
2791            "each old and added blob should be read once per diff"
2792        );
2793        assert_eq!(
2794            stats.lcs_comparisons, FILE_COUNT,
2795            "only the one plausible modified target per deleted file should reach LCS"
2796        );
2797        assert_eq!(stats.total_possible_pairs, FILE_COUNT * FILE_COUNT);
2798        assert_eq!(
2799            stats.qualifying_candidate_pairs, FILE_COUNT,
2800            "only threshold-qualified pairs should enter deterministic assignment"
2801        );
2802        assert!(stats.qualifying_candidate_pairs < stats.total_possible_pairs);
2803    }
2804
2805    #[test]
2806    fn rename_prefilter_preserves_all_qualifying_short_line_pairs() {
2807        let mut contents = vec![String::new()];
2808        for line_count in 1..=5 {
2809            for bits in 0..(1usize << line_count) {
2810                let content = (0..line_count)
2811                    .map(|line| {
2812                        if bits & (1 << line) == 0 {
2813                            "alpha"
2814                        } else {
2815                            "beta"
2816                        }
2817                    })
2818                    .collect::<Vec<_>>()
2819                    .join("\n");
2820                contents.push(content);
2821            }
2822        }
2823
2824        for old_content in &contents {
2825            for new_content in &contents {
2826                let old_lines = old_content.lines().collect::<Vec<_>>();
2827                let new_lines = new_content.lines().collect::<Vec<_>>();
2828                let expected = if old_content == new_content {
2829                    1.0
2830                } else if old_lines.is_empty() || new_lines.is_empty() {
2831                    0.0
2832                } else {
2833                    (lcs_len(&old_lines, &new_lines) * 2) as f64
2834                        / (old_lines.len() + new_lines.len()) as f64
2835                };
2836                if expected < RENAME_SIMILARITY_THRESHOLD {
2837                    continue;
2838                }
2839
2840                let old_blob = prepare_rename_blob(Blob::from(old_content.clone()));
2841                let new_blob = prepare_rename_blob(Blob::from(new_content.clone()));
2842                let actual =
2843                    rename_similarity(&old_blob, &new_blob, &mut RenameDetectionStats::default());
2844                assert_eq!(
2845                    actual, expected,
2846                    "qualifying pair changed score: old={old_content:?}, new={new_content:?}"
2847                );
2848            }
2849        }
2850    }
2851
2852    #[test]
2853    #[ignore = "focused release-mode wall-time measurement"]
2854    fn benchmark_many_modified_renames() {
2855        use std::time::Instant;
2856
2857        const FILE_COUNT: usize = 128;
2858        const SAMPLES: usize = 7;
2859        let shared_line_counts = vec![31; FILE_COUNT];
2860        let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(&shared_line_counts);
2861        let mut samples = Vec::with_capacity(SAMPLES);
2862        let mut final_stats = RenameDetectionStats::default();
2863
2864        for _ in 0..SAMPLES {
2865            let mut stats = RenameDetectionStats::default();
2866            let started = Instant::now();
2867            let output = detect_clear_renames_with_stats(
2868                &repo,
2869                Some(&old_tree),
2870                Some(&new_tree),
2871                changes.clone(),
2872                false,
2873                0,
2874                &mut stats,
2875            )
2876            .expect("benchmark rename detection");
2877            assert_eq!(output.len(), FILE_COUNT);
2878            samples.push(started.elapsed());
2879            final_stats = stats;
2880        }
2881        samples.sort();
2882        eprintln!(
2883            "rename_diff files={FILE_COUNT} samples={SAMPLES} median_ms={:.3} blob_reads={} lcs_comparisons={}",
2884            samples[SAMPLES / 2].as_secs_f64() * 1_000.0,
2885            final_stats.blob_reads,
2886            final_stats.lcs_comparisons,
2887        );
2888    }
2889
2890    fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
2891        FileChange {
2892            path: "notes.txt".to_string(),
2893            kind: kind.to_string(),
2894            line_counts: Some(counts),
2895            ..Default::default()
2896        }
2897    }
2898
2899    /// The stat-only branch is supposed to count once and then drop
2900    /// the hunk vector. `DiffStats` must read the pre-computed tally
2901    /// off the FileChange so a 10MB diff renders as
2902    /// "1 files changed, 1 additions, 0 modifications" even though
2903    /// `lines` is `None`. Regressing this re-introduces the cheap-
2904    /// branch behaviour that treated the file like name-only.
2905    #[test]
2906    fn diff_stats_reads_line_counts_when_hunks_dropped() {
2907        let changes = vec![stat_change(
2908            "modified",
2909            LineCounts {
2910                added: 1,
2911                modified: 0,
2912                deleted: 0,
2913            },
2914        )];
2915
2916        let stats = DiffStats::from_changes(&changes, None);
2917
2918        assert_eq!(stats.files_changed, 1);
2919        assert_eq!(stats.additions, 1);
2920        assert_eq!(stats.modifications, 0);
2921        assert_eq!(stats.deletions, 0);
2922        assert_eq!(stats.renames, 0);
2923    }
2924
2925    /// The file-level kind fallback must not fire when a stat-path
2926    /// FileChange has an empty `line_counts` payload — empty means
2927    /// "we counted and there were no eligible lines" (the binary or
2928    /// empty-diff case), not "we never counted".
2929    #[test]
2930    fn diff_stats_treats_zero_line_counts_as_authoritative() {
2931        let changes = vec![stat_change(
2932            "modified",
2933            LineCounts {
2934                added: 0,
2935                modified: 0,
2936                deleted: 0,
2937            },
2938        )];
2939
2940        let stats = DiffStats::from_changes(&changes, None);
2941
2942        assert_eq!(stats.modifications, 0);
2943        assert_eq!(stats.additions, 0);
2944        assert_eq!(stats.deletions, 0);
2945    }
2946
2947    /// Sanity-check the underlying counter so the stat closure that
2948    /// feeds `line_counts` produces matching output.
2949    #[test]
2950    fn change_line_counts_pairs_modified_lines() {
2951        let lines = vec![
2952            LineDiff::with_lines("-", "alpha", Some(1), None),
2953            LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
2954            LineDiff::with_lines("+", "fresh", None, Some(2)),
2955        ];
2956        let counts = change_line_counts(Some(&lines));
2957        assert_eq!(counts.modified, 1);
2958        assert_eq!(counts.added, 1);
2959        assert_eq!(counts.deleted, 0);
2960    }
2961
2962    /// The canonical hunk body (the one `--patch`/JSON consume) must keep
2963    /// every real `+` line, including a leading `+#[test]` decoration that
2964    /// duplicates a following context line. Dropping it here desyncs the
2965    /// `@@` header counts and corrupts `git apply` (cid 3320364905) — the
2966    /// trim is now a display-only transform, not a property of the model.
2967    #[test]
2968    fn unified_hunks_keeps_added_decoration_in_canonical_body() {
2969        let lines = vec![
2970            LineDiff::with_lines("+", "#[test]", None, Some(1)),
2971            LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2972            LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2973            LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2974        ];
2975
2976        let hunk = unified_hunks(lines, 3, &FileEolState::default());
2977
2978        let header = hunk
2979            .iter()
2980            .find(|line| line.prefix == "@")
2981            .expect("hunk should carry an `@@` header");
2982        // Two added (`+`) lines + two context lines on the new side → +4.
2983        assert_eq!(
2984            header.content, "@ -1,2 +1,4 @@",
2985            "header counts must match the untrimmed body: {hunk:?}"
2986        );
2987        assert!(
2988            hunk.iter()
2989                .any(|line| line.prefix == "+" && line.content == "#[test]"),
2990            "added decoration line must survive in the canonical body: {hunk:?}"
2991        );
2992        assert!(
2993            hunk.iter()
2994                .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2995            "added function body should remain: {hunk:?}"
2996        );
2997    }
2998
2999    /// The display transform DOES trim the leading `+#[test]` so the
3000    /// pretty diff anchors on the existing item — but only the body lines
3001    /// move; the `@@` header (untrimmed counts) is preserved verbatim.
3002    #[test]
3003    fn display_trim_drops_added_decoration_but_keeps_header() {
3004        use super::trim_added_decorations_for_display;
3005
3006        let lines = vec![
3007            LineDiff::with_lines("+", "#[test]", None, Some(1)),
3008            LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
3009            LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
3010            LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
3011        ];
3012        let hunk = unified_hunks(lines, 3, &FileEolState::default());
3013
3014        let display = trim_added_decorations_for_display(&hunk);
3015
3016        assert!(
3017            display
3018                .iter()
3019                .filter(|line| line.content == "#[test]")
3020                .all(|line| line.prefix == " "),
3021            "display trim should let existing context own the decoration: {display:?}"
3022        );
3023        assert!(
3024            display
3025                .iter()
3026                .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
3027            "added function body should remain after display trim: {display:?}"
3028        );
3029        assert_eq!(
3030            display
3031                .iter()
3032                .find(|line| line.prefix == "@")
3033                .map(|l| l.content.as_str()),
3034            Some("@ -1,2 +1,4 @@"),
3035            "display trim must not rewrite the `@@` header: {display:?}"
3036        );
3037    }
3038
3039    /// Characterization: core::diff maps minimal-policy not-found failures to
3040    /// [`RecoveryDetails::state_not_found`], not plain strings.
3041    #[test]
3042    fn minimal_resolve_failure_maps_to_recovery_state_not_found() {
3043        use objects::{RecoveryDetails, error::HeddleError};
3044        use repo::{
3045            ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
3046        };
3047        use tempfile::TempDir;
3048
3049        let temp = TempDir::new().unwrap();
3050        let repo = repo::Repository::init_default(temp.path()).unwrap();
3051        std::fs::write(temp.path().join("a.txt"), "a").unwrap();
3052        repo.snapshot(Some("seed".into()), None).unwrap();
3053
3054        let err = resolve_state_for_command(&repo, "hs-zzzzzzzzzzzz", ResolvePolicy::minimal())
3055            .unwrap_err();
3056        let mapped = match err {
3057            StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
3058                HeddleError::recovery(RecoveryDetails::state_not_found(spec))
3059            }
3060            other => panic!("expected not-found failure, got {other:?}"),
3061        };
3062        assert!(matches!(mapped, HeddleError::Recovery(_)));
3063        assert!(
3064            mapped.to_string().contains("State not found"),
3065            "unexpected message: {mapped}"
3066        );
3067    }
3068}