Skip to main content

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