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