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