Skip to main content

verbs/diff/
mod.rs

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