1use std::{
5 collections::{BTreeMap, BTreeSet},
6 path::{Path, PathBuf},
7};
8
9use anyhow::{Result, anyhow};
10use merge::RenameCandidateIndex;
11use objects::{
12 HeddleError, RecoveryDetails,
13 object::{
14 Blob, ContentHash, DiffKind, EntryType, FileChangeSet, FileMode, SemanticChange, State,
15 StateId, Tree, TreeEntry,
16 },
17 store::ObjectStore,
18 worktree::{WorktreeStatus, diff_blobs},
19};
20use repo::{
21 Repository, ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
22};
23#[cfg(feature = "semantic")]
24use semantic::diff::{SemanticDiffOptions, WorktreeStatus as SemanticWorktreeStatus};
25use sley::{EntryKind, Repository as SleyRepository};
26
27use crate::ExecutionContext;
28
29mod context;
30mod patch;
31mod path_filter;
32mod types;
33
34pub use context::{attach_show_context, worktree_context_state};
35pub use patch::{render_diff_patch, render_diff_patch_bytes, write_diff_patch};
36pub use types::*;
37
38const BINARY_DIFF_ERROR: &str = "binary file";
39const RENAME_SIMILARITY_THRESHOLD: f64 = 0.75;
40
41#[derive(Clone, Debug, Default)]
42struct SemanticDiffResult {
43 changes: Vec<SemanticChange>,
44 file_changes: FileChangeSet,
45}
46
47#[derive(Clone, Debug)]
49pub struct DiffOptions {
50 pub from: Option<String>,
51 pub to: Option<String>,
52 pub semantic: bool,
53 pub stat: bool,
54 pub name_only: bool,
55 pub unified: usize,
56 pub show_context: bool,
57 pub include_patch_text: bool,
61 pub paths: Vec<String>,
63}
64
65impl Default for DiffOptions {
66 fn default() -> Self {
67 Self {
68 from: None,
69 to: None,
70 semantic: false,
71 stat: false,
72 name_only: false,
73 unified: 3,
74 show_context: false,
75 include_patch_text: false,
76 paths: Vec::new(),
77 }
78 }
79}
80
81#[derive(Debug)]
83pub struct PlainGitDiffProbe {
84 pub root: PathBuf,
85 pub changes: WorktreeStatus,
86}
87
88pub fn diff(ctx: &ExecutionContext, options: DiffOptions) -> Result<DiffReport> {
90 let repo = ctx.require_repo().map_err(anyhow::Error::new)?;
91 let to = options.to.as_ref();
92 let git_overlay_head_worktree_diff = repo.current_state()?.is_none()
93 && to.is_none()
94 && matches!(options.from.as_deref(), Some("HEAD" | "@"));
95
96 let from_id = if git_overlay_head_worktree_diff {
97 None
98 } else if let Some(ref spec) = options.from {
99 Some(resolve_state_id(repo, spec)?)
100 } else {
101 repo.head()?
102 };
103
104 let from_state = if let Some(id) = from_id {
105 Some(require_resolved_state(repo, &id)?)
106 } else {
107 None
108 };
109
110 let from_tree = if let Some(ref state) = from_state {
111 repo.store().get_tree(&state.tree)?
112 } else {
113 None
114 };
115 let to_state = if let Some(to_spec) = to {
116 let to_id = resolve_state_id(repo, to_spec)?;
117 Some(require_resolved_state(repo, &to_id)?)
118 } else {
119 None
120 };
121 let to_tree = if let Some(ref state) = to_state {
122 repo.store().get_tree(&state.tree)?
123 } else {
124 None
125 };
126 let status_options = ctx.worktree_status_options();
127 let from_hash = from_state
128 .as_ref()
129 .map(|state| state.tree)
130 .unwrap_or_else(|| Tree::new().hash());
131
132 let semantic_diff_result = if options.semantic {
133 if let Some(ref to_state) = to_state {
134 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
135 } else {
136 Some(run_semantic_worktree_diff(
137 repo,
138 &from_hash,
139 &status_options,
140 )?)
141 }
142 } else {
143 None
144 };
145
146 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
147 result.file_changes.clone()
148 } else if let Some(ref to_state) = to_state {
149 repo.diff_trees(&from_hash, &to_state.tree)?
150 } else if git_overlay_head_worktree_diff {
151 file_change_set_from_status(&repo.git_overlay_worktree_status()?.unwrap_or_default())
152 } else {
153 let tree = from_tree.clone().unwrap_or_default();
154 file_change_set_from_status(
155 &repo.compare_worktree_cached_with_options(&tree, &status_options)?,
156 )
157 };
158
159 let patch_text_needed = options.include_patch_text;
160 let want_hunks = patch_text_needed || !(options.name_only || options.stat);
161 let file_changes = file_changes_from_change_set(
162 repo,
163 from_tree.as_ref(),
164 to_tree.as_ref(),
165 &changes,
166 &options,
167 want_hunks,
168 patch_text_needed,
169 )?;
170
171 let semantic_changes = semantic_diff_result.map(|result| {
172 result
173 .changes
174 .into_iter()
175 .map(SemanticChangeEntry::from)
176 .collect()
177 });
178
179 let context_state = if options.show_context {
180 if let Some(ref state) = to_state {
181 Some(state.clone())
182 } else if let Some(state) = from_state.clone() {
183 Some(state)
184 } else {
185 repo.current_state()?
186 }
187 } else {
188 None
189 };
190
191 let stats = DiffStats::from_changes(&file_changes, semantic_changes.as_deref());
192 let mut output = DiffReport::with_stats(
193 from_id.map(|id| id.short()),
194 options.to.clone(),
195 file_changes,
196 semantic_changes,
197 None,
198 None,
199 stats,
200 );
201 output.worktree_mode = options.to.is_none();
202 let mut output = finalize_diff_report(output, &options)?;
203 if let Some(state) = context_state.as_ref() {
204 attach_show_context(repo, &mut output, state, &options.paths)?;
205 }
206 Ok(output)
207}
208
209fn file_changes_from_change_set(
210 repo: &Repository,
211 from_tree: Option<&Tree>,
212 to_tree: Option<&Tree>,
213 changes: &FileChangeSet,
214 options: &DiffOptions,
215 want_hunks: bool,
216 patch_text_needed: bool,
217) -> Result<Vec<FileChange>> {
218 let file_changes: Vec<FileChange> = if options.name_only && !patch_text_needed {
219 changes
220 .iter()
221 .map(|change| {
222 make_status_only_change(
223 Some(repo),
224 from_tree,
225 to_tree,
226 &change.path,
227 &change.kind.to_string(),
228 )
229 })
230 .collect()
231 } else {
232 changes
233 .iter()
234 .map(|change| {
235 let effective_kind = if to_tree.is_none() {
236 worktree_modified_type_change(repo.root(), &change.path, change.kind)
237 .map(|(_, diff_kind)| diff_kind)
238 .unwrap_or(change.kind)
239 } else {
240 change.kind
241 };
242 let diff_result = if let Some(tree) = to_tree {
243 get_state_diff(repo, from_tree, tree, &change.path, &effective_kind)
244 } else {
245 get_worktree_diff(repo, from_tree, &change.path, &effective_kind)
246 };
247 let binary = diff_result.as_ref().err().is_some_and(is_binary_diff_error);
248 let (raw_lines, eol) = match diff_result {
249 Ok((lines, eol)) => (Some(lines), eol),
250 Err(_) => (None, FileEolState::default()),
251 };
252 let (lines, line_counts) = if options.stat && !patch_text_needed {
253 let counts = change_line_counts(raw_lines.as_deref());
254 (None, Some(counts))
255 } else {
256 (
257 raw_lines.map(|lines| unified_hunks(lines, options.unified, &eol)),
258 None,
259 )
260 };
261
262 let kind = effective_kind.to_string();
263 let (old_mode, mode) =
264 change_file_modes(repo, from_tree, to_tree, &change.path, &kind);
265 let symlink = symlink_change_for_paths(
266 repo,
267 from_tree,
268 to_tree,
269 &kind,
270 &change.path,
271 &change.path,
272 old_mode,
273 mode,
274 );
275 FileChange {
276 path: change.path.clone(),
277 kind,
278 binary: binary && symlink.is_none(),
279 lines,
280 line_counts,
281 eol,
282 mode,
283 old_mode,
284 symlink,
285 ..Default::default()
286 }
287 })
288 .collect()
289 };
290 let file_changes = sort_changes_by_path(file_changes);
291 let file_changes = expand_type_changes(
292 repo,
293 from_tree,
294 to_tree,
295 file_changes,
296 want_hunks,
297 options.unified,
298 )?;
299 detect_clear_renames(
300 repo,
301 from_tree,
302 to_tree,
303 file_changes,
304 want_hunks,
305 options.unified,
306 )
307}
308
309pub fn diff_worktree_status(
311 status: &WorktreeStatus,
312 options: &DiffOptions,
313 repo: Option<&Repository>,
314 detect_renames: bool,
315) -> Result<DiffReport> {
316 let want_hunks = options.include_patch_text && repo.is_some();
317 let from_tree = match repo {
318 Some(repo) => head_from_tree(repo)?,
319 None => None,
320 };
321 let changes = file_changes_from_status(
322 status,
323 want_hunks,
324 repo,
325 from_tree.as_ref(),
326 options.unified,
327 );
328 let changes = match repo {
329 Some(repo) => expand_type_changes(
330 repo,
331 from_tree.as_ref(),
332 None,
333 changes,
334 want_hunks,
335 options.unified,
336 )?,
337 None => changes,
338 };
339 let changes = if detect_renames {
340 match repo {
341 Some(repo) => detect_clear_renames(
342 repo,
343 from_tree.as_ref(),
344 None,
345 changes,
346 want_hunks,
347 options.unified,
348 )?,
349 None => changes,
350 }
351 } else {
352 changes
353 };
354 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
355 output.worktree_mode = true;
356 let mut output = finalize_diff_report(output, options)?;
357 if options.show_context
358 && let Some(repo) = repo
359 && let Some(state) = worktree_context_state(repo)?
360 {
361 attach_show_context(repo, &mut output, &state, &options.paths)?;
362 }
363 Ok(output)
364}
365
366pub fn plain_git_head_diff(probe: &PlainGitDiffProbe, options: &DiffOptions) -> Result<DiffReport> {
369 if options.include_patch_text {
370 let changes = plain_git_file_changes_with_hunks(probe, options.unified)?;
371 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
372 output.worktree_mode = true;
373 return finalize_diff_report(output, options);
374 }
375 diff_worktree_status(&probe.changes, options, None, false)
376}
377
378fn finalize_diff_report(mut output: DiffReport, options: &DiffOptions) -> Result<DiffReport> {
379 path_filter::apply_path_filters(&mut output, &options.paths)?;
380 if options.include_patch_text {
381 populate_patch_text(&mut output);
382 }
383 if options.stat {
384 output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
385 }
386 Ok(output)
387}
388
389fn populate_patch_text(output: &mut DiffReport) {
391 let text = render_diff_patch(output);
392 if !text.is_empty() {
393 output.patch = Some(text);
394 }
395}
396
397fn file_change_set_from_status(status: &WorktreeStatus) -> FileChangeSet {
398 let mut changes = FileChangeSet::with_capacity(status.change_count());
399 for path in &status.modified {
400 changes.push_modified(path.display().to_string());
401 }
402 for path in &status.added {
403 changes.push_added(path.display().to_string());
404 }
405 for path in &status.deleted {
406 changes.push_deleted(path.display().to_string());
407 }
408 changes
409}
410
411fn resolve_state_id(repository: &Repository, spec: &str) -> Result<StateId> {
412 resolve_state_for_command(repository, spec, ResolvePolicy::minimal())
413 .map(|resolved| resolved.state_id)
414 .map_err(|error| match error {
415 StateResolveError::Repository(err) => err.into(),
416 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
417 anyhow!(HeddleError::recovery(RecoveryDetails::state_not_found(
418 spec
419 )))
420 }
421 StateResolveError::Failure(other) => anyhow!("{other}"),
422 })
423}
424
425fn require_resolved_state(repo: &Repository, id: &StateId) -> Result<State> {
426 repo.store().get_state(id)?.ok_or_else(|| {
427 anyhow!(HeddleError::MissingObject {
428 object_type: "state".to_string(),
429 id: id.to_string_full(),
430 })
431 })
432}
433
434#[cfg(feature = "semantic")]
435fn run_semantic_diff(
436 repo: &Repository,
437 from_tree_hash: &objects::object::ContentHash,
438 to_tree_hash: &objects::object::ContentHash,
439) -> Result<SemanticDiffResult> {
440 let options = SemanticDiffOptions::default();
441 let result =
442 semantic::diff::semantic_diff(repo.store(), from_tree_hash, to_tree_hash, &options)?;
443 Ok(SemanticDiffResult {
444 changes: result.changes,
445 file_changes: result.file_changes,
446 })
447}
448
449#[cfg(not(feature = "semantic"))]
450fn run_semantic_diff(
451 _repo: &Repository,
452 _from_tree_hash: &objects::object::ContentHash,
453 _to_tree_hash: &objects::object::ContentHash,
454) -> Result<SemanticDiffResult> {
455 Err(anyhow!(HeddleError::recovery(
456 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
457 )))
458}
459
460#[cfg(feature = "semantic")]
461fn run_semantic_worktree_diff(
462 repo: &Repository,
463 from_tree_hash: &objects::object::ContentHash,
464 status_options: &repo::WorktreeStatusOptions,
465) -> Result<SemanticDiffResult> {
466 let from_tree = repo.require_tree(from_tree_hash)?;
467 let status = repo.compare_worktree_cached_with_options(&from_tree, status_options)?;
468 let status = SemanticWorktreeStatus {
469 modified: status.modified,
470 added: status.added,
471 deleted: status.deleted,
472 };
473 let options = SemanticDiffOptions::default();
474 let result = semantic::diff::semantic_diff_worktree(
475 repo.store(),
476 from_tree_hash,
477 repo.root(),
478 &status,
479 &options,
480 )?;
481 Ok(SemanticDiffResult {
482 changes: result.changes,
483 file_changes: result.file_changes,
484 })
485}
486
487#[cfg(not(feature = "semantic"))]
488fn run_semantic_worktree_diff(
489 _repo: &Repository,
490 _from_tree_hash: &objects::object::ContentHash,
491 _status_options: &repo::WorktreeStatusOptions,
492) -> Result<SemanticDiffResult> {
493 Err(anyhow!(HeddleError::recovery(
494 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
495 )))
496}
497
498fn sort_changes_by_path(mut changes: Vec<FileChange>) -> Vec<FileChange> {
507 changes.sort_by(|a, b| a.path.cmp(&b.path));
508 changes
509}
510fn plain_git_file_changes_with_hunks(
521 probe: &PlainGitDiffProbe,
522 unified: usize,
523) -> Result<Vec<FileChange>> {
524 let git_repo = SleyRepository::discover(&probe.root)?;
525 let head_has_tree = !git_repo.head()?.is_unborn();
526 let added_set: BTreeSet<&Path> = probe.changes.added.iter().map(PathBuf::as_path).collect();
534 let deleted_set: BTreeSet<&Path> = probe.changes.deleted.iter().map(PathBuf::as_path).collect();
535
536 let mut changes = Vec::with_capacity(probe.changes.change_count());
537 for path in &probe.changes.modified {
538 push_plain_git_modified(
539 &git_repo,
540 head_has_tree,
541 &probe.root,
542 path,
543 unified,
544 &mut changes,
545 )?;
546 }
547 for path in &probe.changes.added {
548 if deleted_set.contains(path.as_path()) {
549 push_plain_git_modified(
553 &git_repo,
554 head_has_tree,
555 &probe.root,
556 path,
557 unified,
558 &mut changes,
559 )?;
560 } else {
561 changes.push(plain_git_file_change(
562 &git_repo,
563 head_has_tree,
564 &probe.root,
565 path,
566 "added",
567 DiffKind::Added,
568 unified,
569 )?);
570 }
571 }
572 for path in &probe.changes.deleted {
573 if added_set.contains(path.as_path()) {
575 continue;
576 }
577 changes.push(plain_git_file_change(
578 &git_repo,
579 head_has_tree,
580 &probe.root,
581 path,
582 "deleted",
583 DiffKind::Deleted,
584 unified,
585 )?);
586 }
587 Ok(changes)
588}
589
590#[allow(clippy::too_many_arguments)]
591fn plain_git_file_change(
592 git_repo: &SleyRepository,
593 head_has_tree: bool,
594 root: &Path,
595 path: &std::path::Path,
596 kind: &str,
597 diff_kind: DiffKind,
598 unified: usize,
599) -> Result<FileChange> {
600 let (old_blob, old_mode) = match (head_has_tree, &diff_kind) {
601 (true, DiffKind::Modified | DiffKind::Deleted) => {
602 match plain_git_lookup_blob_and_mode(git_repo, path)? {
603 Some((blob, mode)) => (Some(blob), Some(mode)),
604 None => (None, None),
605 }
606 }
607 _ => (None, None),
608 };
609 let new_blob = match diff_kind {
610 DiffKind::Added | DiffKind::Modified => {
611 read_worktree_blob_for_diff(&root.join(path)).ok()
615 }
616 _ => None,
617 };
618 let (old_mode_field, mode) = match diff_kind {
623 DiffKind::Added => (None, worktree_file_mode(&root.join(path))),
624 DiffKind::Deleted => (None, old_mode),
625 DiffKind::Modified => (old_mode, worktree_file_mode(&root.join(path))),
626 DiffKind::Unchanged => (None, None),
627 };
628 let (lines, eol, binary) =
629 compute_plain_git_hunks(old_blob.as_ref(), new_blob.as_ref(), &diff_kind, unified);
630 let symlink = symlink_change_from_blobs(
631 kind,
632 old_blob.as_ref(),
633 old_mode_field,
634 new_blob.as_ref(),
635 mode,
636 );
637 Ok(FileChange {
638 path: path.display().to_string(),
639 kind: kind.to_string(),
640 binary: binary && symlink.is_none(),
641 lines,
642 eol,
643 mode,
644 old_mode: old_mode_field,
645 symlink,
646 ..Default::default()
647 })
648}
649
650fn plain_git_lookup_blob_and_mode(
651 git_repo: &SleyRepository,
652 path: &std::path::Path,
653) -> Result<Option<(Blob, FileMode)>> {
654 let tree_path = plain_git_tree_path(path);
655 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
656 return Ok(None);
657 };
658 let Some(entry_mode) = entry.mode else {
659 return Ok(None);
660 };
661 let mode = match EntryKind::from_mode(entry_mode) {
662 Some(EntryKind::Symlink) => FileMode::Symlink,
663 Some(EntryKind::BlobExecutable) => FileMode::Executable,
664 Some(EntryKind::Blob) => FileMode::Normal,
665 _ => return Ok(None),
666 };
667 let object = git_repo.read_object(&entry.oid)?;
668 Ok(Some((Blob::new(object.body.clone()), mode)))
669}
670
671fn plain_git_tree_path(path: &std::path::Path) -> String {
672 path.components()
673 .map(|component| component.as_os_str().to_string_lossy())
674 .collect::<Vec<_>>()
675 .join("/")
676}
677
678fn plain_git_old_side_kind(
684 git_repo: &SleyRepository,
685 head_has_tree: bool,
686 path: &std::path::Path,
687) -> Result<SideKind> {
688 if !head_has_tree {
689 return Ok(SideKind::Absent);
690 }
691 let tree_path = plain_git_tree_path(path);
692 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
693 return Ok(SideKind::Absent);
694 };
695 Ok(match entry.mode.and_then(EntryKind::from_mode) {
696 Some(EntryKind::Symlink) => SideKind::Symlink,
697 Some(EntryKind::Tree) => SideKind::Dir,
698 _ => SideKind::Regular,
699 })
700}
701
702fn push_plain_git_modified(
714 git_repo: &SleyRepository,
715 head_has_tree: bool,
716 root: &Path,
717 path: &std::path::Path,
718 unified: usize,
719 out: &mut Vec<FileChange>,
720) -> Result<()> {
721 let new_kind = worktree_side_kind(&root.join(path));
722 let old_kind = plain_git_old_side_kind(git_repo, head_has_tree, path)?;
723 if is_type_change(old_kind, new_kind) {
724 out.push(plain_git_file_change(
725 git_repo,
726 head_has_tree,
727 root,
728 path,
729 "deleted",
730 DiffKind::Deleted,
731 unified,
732 )?);
733 if new_kind != SideKind::Dir {
736 out.push(plain_git_file_change(
737 git_repo,
738 head_has_tree,
739 root,
740 path,
741 "added",
742 DiffKind::Added,
743 unified,
744 )?);
745 }
746 } else {
747 out.push(plain_git_file_change(
748 git_repo,
749 head_has_tree,
750 root,
751 path,
752 "modified",
753 DiffKind::Modified,
754 unified,
755 )?);
756 }
757 Ok(())
758}
759
760fn compute_plain_git_hunks(
761 old: Option<&Blob>,
762 new: Option<&Blob>,
763 diff_kind: &DiffKind,
764 unified: usize,
765) -> (Option<Vec<LineDiff>>, FileEolState, bool) {
766 let attempt = || -> Result<(Vec<LineDiff>, FileEolState)> {
767 match diff_kind {
768 DiffKind::Added => {
769 let Some(new) = new else {
770 return Ok((Vec::new(), FileEolState::default()));
771 };
772 ensure_text_diffable(new)?;
773 let eol = eol_for_added(new);
774 Ok((number_lines(blob_lines(new, "+")?), eol))
775 }
776 DiffKind::Deleted => {
777 let Some(old) = old else {
778 return Ok((Vec::new(), FileEolState::default()));
779 };
780 ensure_text_diffable(old)?;
781 let eol = eol_for_deleted(old);
782 Ok((number_lines(blob_lines(old, "-")?), eol))
783 }
784 DiffKind::Modified => match (old, new) {
785 (Some(old), Some(new)) => modified_blob_hunks(old, new),
786 (None, Some(new)) => {
787 ensure_text_diffable(new)?;
788 let eol = eol_for_added(new);
789 Ok((number_lines(blob_lines(new, "+")?), eol))
790 }
791 (Some(old), None) => {
792 ensure_text_diffable(old)?;
793 let eol = eol_for_deleted(old);
794 Ok((number_lines(blob_lines(old, "-")?), eol))
795 }
796 (None, None) => Ok((Vec::new(), FileEolState::default())),
797 },
798 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
799 }
800 };
801 match attempt() {
802 Ok((lines, eol)) => (Some(unified_hunks(lines, unified, &eol)), eol, false),
803 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
804 Err(_) => (None, FileEolState::default(), false),
805 }
806}
807fn file_changes_from_status(
812 status: &objects::worktree::WorktreeStatus,
813 want_hunks: bool,
814 repo: Option<&Repository>,
815 from_tree: Option<&Tree>,
816 unified: usize,
817) -> Vec<FileChange> {
818 let mut changes = Vec::with_capacity(status.change_count());
819 for path in &status.modified {
820 changes.push(make_status_file_change(
821 path,
822 "modified",
823 DiffKind::Modified,
824 want_hunks,
825 repo,
826 from_tree,
827 unified,
828 ));
829 }
830 for path in &status.added {
831 changes.push(make_status_file_change(
832 path,
833 "added",
834 DiffKind::Added,
835 want_hunks,
836 repo,
837 from_tree,
838 unified,
839 ));
840 }
841 for path in &status.deleted {
842 changes.push(make_status_file_change(
843 path,
844 "deleted",
845 DiffKind::Deleted,
846 want_hunks,
847 repo,
848 from_tree,
849 unified,
850 ));
851 }
852 changes
853}
854
855#[allow(clippy::too_many_arguments)]
856fn make_status_file_change(
857 path: &std::path::Path,
858 kind: &str,
859 diff_kind: DiffKind,
860 want_hunks: bool,
861 repo: Option<&Repository>,
862 from_tree: Option<&Tree>,
863 unified: usize,
864) -> FileChange {
865 let path_str = path.display().to_string();
866 let (kind, diff_kind) = match repo
870 .and_then(|repo| worktree_modified_type_change(repo.root(), &path_str, diff_kind))
871 {
872 Some(reclassified) => reclassified,
873 None => (kind, diff_kind),
874 };
875 match repo {
876 Some(repo) if want_hunks => {
877 build_worktree_change(repo, from_tree, &path_str, kind, diff_kind, unified)
878 }
879 _ => make_status_only_change(repo, from_tree, None, &path_str, kind),
880 }
881}
882
883fn make_status_only_change(
897 repo: Option<&Repository>,
898 from_tree: Option<&Tree>,
899 to_tree: Option<&Tree>,
900 path_str: &str,
901 kind: &str,
902) -> FileChange {
903 let (old_mode, mode) = match repo {
904 Some(repo) => change_file_modes(repo, from_tree, to_tree, path_str, kind),
905 None => (None, None),
906 };
907 FileChange {
908 path: path_str.to_string(),
909 kind: kind.to_string(),
910 mode,
911 old_mode,
912 ..Default::default()
913 }
914}
915
916fn build_worktree_change(
921 repo: &Repository,
922 from_tree: Option<&Tree>,
923 path_str: &str,
924 kind: &str,
925 diff_kind: DiffKind,
926 unified: usize,
927) -> FileChange {
928 let (old_mode, mode) = change_file_modes(repo, from_tree, None, path_str, kind);
929 let (lines, eol, binary) = match get_worktree_diff(repo, from_tree, path_str, &diff_kind) {
930 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
931 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
932 Err(_) => (None, FileEolState::default(), false),
937 };
938 let symlink = symlink_change_for_paths(
939 repo, from_tree, None, kind, path_str, path_str, old_mode, mode,
940 );
941 FileChange {
942 path: path_str.to_string(),
943 kind: kind.to_string(),
944 binary: binary && symlink.is_none(),
945 lines,
946 eol,
947 mode,
948 old_mode,
949 symlink,
950 ..Default::default()
951 }
952}
953
954#[derive(Clone, Copy, PartialEq, Eq, Debug)]
956enum SideKind {
957 Absent,
958 Dir,
959 Regular,
961 Symlink,
962}
963
964fn tree_side_kind(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<SideKind> {
969 let Some(tree) = tree else {
970 return Ok(SideKind::Absent);
971 };
972 if let Some(entry) = find_entry_in_tree(repo, tree, path)? {
973 return Ok(if entry.entry_type() == EntryType::Symlink {
974 SideKind::Symlink
975 } else {
976 SideKind::Regular
977 });
978 }
979 if dir_subtree_in_tree(repo, tree, path)?.is_some() {
980 Ok(SideKind::Dir)
981 } else {
982 Ok(SideKind::Absent)
983 }
984}
985
986fn new_side_kind(repo: &Repository, to_tree: Option<&Tree>, path: &str) -> Result<SideKind> {
989 match to_tree {
990 Some(tree) => tree_side_kind(repo, Some(tree), path),
991 None => Ok(worktree_side_kind(&repo.root().join(path))),
992 }
993}
994
995fn worktree_side_kind(path: &Path) -> SideKind {
999 let Ok(meta) = std::fs::symlink_metadata(path) else {
1000 return SideKind::Absent;
1001 };
1002 if meta.file_type().is_symlink() {
1003 SideKind::Symlink
1004 } else if meta.is_dir() {
1005 SideKind::Dir
1006 } else {
1007 SideKind::Regular
1008 }
1009}
1010
1011fn is_type_change(old: SideKind, new: SideKind) -> bool {
1014 use SideKind::{Dir, Regular, Symlink};
1015 matches!(
1016 (old, new),
1017 (Dir, Regular)
1018 | (Dir, Symlink)
1019 | (Regular, Dir)
1020 | (Symlink, Dir)
1021 | (Regular, Symlink)
1022 | (Symlink, Regular)
1023 )
1024}
1025
1026fn expand_type_changes(
1053 repo: &Repository,
1054 from_tree: Option<&Tree>,
1055 to_tree: Option<&Tree>,
1056 changes: Vec<FileChange>,
1057 want_hunks: bool,
1058 unified: usize,
1059) -> Result<Vec<FileChange>> {
1060 let mut output = Vec::with_capacity(changes.len());
1061 for change in changes {
1062 if change.kind != "modified" {
1063 output.push(change);
1064 continue;
1065 }
1066 let old_kind = tree_side_kind(repo, from_tree, &change.path)?;
1067 let new_kind = new_side_kind(repo, to_tree, &change.path)?;
1068 if !is_type_change(old_kind, new_kind) {
1069 output.push(change);
1070 continue;
1071 }
1072
1073 if old_kind == SideKind::Dir {
1076 if let Some(from_tree) = from_tree
1077 && let Some(subtree) = dir_subtree_in_tree(repo, from_tree, &change.path)?
1078 {
1079 let mut nested = Vec::new();
1080 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1081 for nested_path in nested {
1082 output.push(make_type_change_part(
1083 repo,
1084 Some(from_tree),
1085 to_tree,
1086 &nested_path,
1087 DiffKind::Deleted,
1088 want_hunks,
1089 unified,
1090 ));
1091 }
1092 }
1093 } else {
1094 output.push(make_type_change_part(
1095 repo,
1096 from_tree,
1097 to_tree,
1098 &change.path,
1099 DiffKind::Deleted,
1100 want_hunks,
1101 unified,
1102 ));
1103 }
1104
1105 if new_kind == SideKind::Dir {
1110 if let Some(to_tree) = to_tree
1111 && let Some(subtree) = dir_subtree_in_tree(repo, to_tree, &change.path)?
1112 {
1113 let mut nested = Vec::new();
1114 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1115 for nested_path in nested {
1116 output.push(make_type_change_part(
1117 repo,
1118 from_tree,
1119 Some(to_tree),
1120 &nested_path,
1121 DiffKind::Added,
1122 want_hunks,
1123 unified,
1124 ));
1125 }
1126 }
1127 } else {
1128 output.push(make_type_change_part(
1129 repo,
1130 from_tree,
1131 to_tree,
1132 &change.path,
1133 DiffKind::Added,
1134 want_hunks,
1135 unified,
1136 ));
1137 }
1138 }
1139 Ok(output)
1140}
1141
1142fn make_type_change_part(
1143 repo: &Repository,
1144 from_tree: Option<&Tree>,
1145 to_tree: Option<&Tree>,
1146 path_str: &str,
1147 diff_kind: DiffKind,
1148 want_hunks: bool,
1149 unified: usize,
1150) -> FileChange {
1151 let kind = diff_kind.to_string();
1152 if !want_hunks {
1153 return make_status_only_change(Some(repo), from_tree, to_tree, path_str, &kind);
1154 }
1155 match to_tree {
1156 Some(to_tree) => build_state_change(
1157 repo, from_tree, to_tree, path_str, &kind, diff_kind, unified,
1158 ),
1159 None => build_worktree_change(repo, from_tree, path_str, &kind, diff_kind, unified),
1160 }
1161}
1162
1163fn build_state_change(
1167 repo: &Repository,
1168 from_tree: Option<&Tree>,
1169 to_tree: &Tree,
1170 path_str: &str,
1171 kind: &str,
1172 diff_kind: DiffKind,
1173 unified: usize,
1174) -> FileChange {
1175 let (old_mode, mode) = change_file_modes(repo, from_tree, Some(to_tree), path_str, kind);
1176 let (lines, eol, binary) = match get_state_diff(repo, from_tree, to_tree, path_str, &diff_kind)
1177 {
1178 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1179 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1180 Err(_) => (None, FileEolState::default(), false),
1181 };
1182 let symlink = symlink_change_for_paths(
1183 repo,
1184 from_tree,
1185 Some(to_tree),
1186 kind,
1187 path_str,
1188 path_str,
1189 old_mode,
1190 mode,
1191 );
1192 FileChange {
1193 path: path_str.to_string(),
1194 kind: kind.to_string(),
1195 binary: binary && symlink.is_none(),
1196 lines,
1197 eol,
1198 mode,
1199 old_mode,
1200 symlink,
1201 ..Default::default()
1202 }
1203}
1204
1205fn dir_subtree_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Tree>> {
1209 let mut current = tree.clone();
1210 let mut parts = path.split('/').peekable();
1211 while let Some(name) = parts.next() {
1212 let Some(entry) = current.get(name) else {
1213 return Ok(None);
1214 };
1215 if !entry.is_tree() {
1216 return Ok(None);
1217 }
1218 let Some(hash) = entry.tree_hash() else {
1219 return Ok(None);
1220 };
1221 let Some(subtree) = repo.store().get_tree(&hash)? else {
1222 return Ok(None);
1223 };
1224 if parts.peek().is_none() {
1225 return Ok(Some(subtree));
1226 }
1227 current = subtree;
1228 }
1229 Ok(None)
1230}
1231
1232fn collect_subtree_blob_paths(
1235 repo: &Repository,
1236 subtree: &Tree,
1237 prefix: &str,
1238 out: &mut Vec<String>,
1239) -> Result<()> {
1240 for entry in subtree.entries() {
1241 let child_path = format!("{prefix}/{}", entry.name());
1242 if entry.is_tree() {
1243 if let Some(hash) = entry.tree_hash()
1244 && let Some(nested) = repo.store().get_tree(&hash)?
1245 {
1246 collect_subtree_blob_paths(repo, &nested, &child_path, out)?;
1247 }
1248 } else {
1249 out.push(child_path);
1250 }
1251 }
1252 Ok(())
1253}
1254
1255fn head_from_tree(repo: &Repository) -> Result<Option<Tree>> {
1256 let Some(head_id) = repo.head()? else {
1257 return Ok(None);
1258 };
1259 let Some(state) = repo.store().get_state(&head_id)? else {
1260 return Ok(None);
1261 };
1262 Ok(repo.store().get_tree(&state.tree)?)
1263}
1264
1265pub fn compute_state_diff(
1280 repo: &Repository,
1281 from_state_id: &StateId,
1282 to_state_id: &StateId,
1283 semantic: bool,
1284 unified: usize,
1285) -> Result<DiffReport> {
1286 let from_state = repo.store().get_state(from_state_id)?;
1287 let from_tree = if let Some(ref state) = from_state {
1288 repo.store().get_tree(&state.tree)?
1289 } else {
1290 None
1291 };
1292
1293 let to_state = require_resolved_state(repo, to_state_id)?;
1294 let to_tree = repo
1295 .store()
1296 .get_tree(&to_state.tree)?
1297 .ok_or_else(|| anyhow!("Tree not found for state {}", to_state_id.short()))?;
1298
1299 let from_hash = from_state
1300 .as_ref()
1301 .map(|s| s.tree)
1302 .unwrap_or_else(|| Tree::new().hash());
1303
1304 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1305 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
1306 } else {
1307 None
1308 };
1309
1310 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1311 result.file_changes.clone()
1312 } else {
1313 repo.diff_trees(&from_hash, &to_state.tree)?
1314 };
1315
1316 let file_changes: Vec<FileChange> = changes
1317 .iter()
1318 .map(|change| {
1319 build_state_change(
1320 repo,
1321 from_tree.as_ref(),
1322 &to_tree,
1323 &change.path,
1324 &change.kind.to_string(),
1325 change.kind,
1326 unified,
1327 )
1328 })
1329 .collect();
1330 let file_changes = sort_changes_by_path(file_changes);
1331 let file_changes = expand_type_changes(
1332 repo,
1333 from_tree.as_ref(),
1334 Some(&to_tree),
1335 file_changes,
1336 true,
1337 unified,
1338 )?;
1339 let file_changes = detect_clear_renames(
1340 repo,
1341 from_tree.as_ref(),
1342 Some(&to_tree),
1343 file_changes,
1344 true,
1345 unified,
1346 )?;
1347
1348 let semantic_changes = semantic_diff_result.map(|r| {
1349 r.changes
1350 .into_iter()
1351 .map(SemanticChangeEntry::from)
1352 .collect()
1353 });
1354
1355 let mut output = DiffReport::new(
1356 Some(from_state_id.short()),
1357 Some(to_state_id.short()),
1358 file_changes,
1359 semantic_changes,
1360 None,
1361 None,
1362 );
1363 populate_patch_text(&mut output);
1364 Ok(output)
1365}
1366
1367pub fn compute_tree_diff(
1374 repo: &Repository,
1375 from_state_id: &StateId,
1376 to_tree: &Tree,
1377 to_label: impl Into<String>,
1378 semantic: bool,
1379 unified: usize,
1380) -> Result<DiffReport> {
1381 let from_state = repo.store().get_state(from_state_id)?;
1382 let from_tree = if let Some(ref state) = from_state {
1383 repo.store().get_tree(&state.tree)?
1384 } else {
1385 None
1386 };
1387 let from_hash = from_state
1388 .as_ref()
1389 .map(|s| s.tree)
1390 .unwrap_or_else(|| Tree::new().hash());
1391
1392 let to_hash = repo.store().put_tree(to_tree)?;
1393
1394 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1395 Some(run_semantic_diff(repo, &from_hash, &to_hash)?)
1396 } else {
1397 None
1398 };
1399
1400 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1401 result.file_changes.clone()
1402 } else {
1403 repo.diff_trees(&from_hash, &to_hash)?
1404 };
1405
1406 let file_changes: Vec<FileChange> = changes
1407 .iter()
1408 .map(|change| {
1409 build_state_change(
1410 repo,
1411 from_tree.as_ref(),
1412 to_tree,
1413 &change.path,
1414 &change.kind.to_string(),
1415 change.kind,
1416 unified,
1417 )
1418 })
1419 .collect();
1420 let file_changes = sort_changes_by_path(file_changes);
1421 let file_changes = expand_type_changes(
1422 repo,
1423 from_tree.as_ref(),
1424 Some(to_tree),
1425 file_changes,
1426 true,
1427 unified,
1428 )?;
1429 let file_changes = detect_clear_renames(
1430 repo,
1431 from_tree.as_ref(),
1432 Some(to_tree),
1433 file_changes,
1434 true,
1435 unified,
1436 )?;
1437
1438 let semantic_changes = semantic_diff_result.map(|r| {
1439 r.changes
1440 .into_iter()
1441 .map(SemanticChangeEntry::from)
1442 .collect()
1443 });
1444
1445 let mut output = DiffReport::new(
1446 Some(from_state_id.short()),
1447 Some(to_label.into()),
1448 file_changes,
1449 semantic_changes,
1450 None,
1451 None,
1452 );
1453 populate_patch_text(&mut output);
1454 Ok(output)
1455}
1456
1457fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
1458 changes
1459 .into_iter()
1460 .map(|mut change| {
1461 change.lines = None;
1462 change
1463 })
1464 .collect()
1465}
1466
1467fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
1468 if lines.is_empty() {
1469 return lines;
1470 }
1471 if !lines.iter().any(|line| line.prefix != " ") {
1472 if eol.old_has_final_newline == eol.new_has_final_newline {
1480 return lines;
1481 }
1482 return eol_only_tail_hunk(lines, context);
1483 }
1484
1485 let mut ranges = Vec::<(usize, usize)>::new();
1486 let mut cursor = 0usize;
1487 while cursor < lines.len() {
1488 while cursor < lines.len() && lines[cursor].prefix == " " {
1489 cursor += 1;
1490 }
1491 if cursor >= lines.len() {
1492 break;
1493 }
1494
1495 let start = cursor.saturating_sub(context);
1496 while cursor < lines.len() && lines[cursor].prefix != " " {
1497 cursor += 1;
1498 }
1499 let mut end = (cursor + context).min(lines.len());
1500
1501 while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
1502 cursor += 1;
1503 }
1504 while cursor < lines.len() && lines[cursor].prefix != " " {
1505 end = (cursor + 1 + context).min(lines.len());
1506 cursor += 1;
1507 }
1508
1509 if let Some((_, previous_end)) = ranges.last_mut()
1510 && start <= *previous_end
1511 {
1512 *previous_end = end;
1513 continue;
1514 }
1515 ranges.push((start, end));
1516 }
1517
1518 let mut output = Vec::new();
1519 for (start, end) in ranges {
1520 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1521 output.push(LineDiff {
1522 prefix: "@".to_string(),
1523 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1524 old_line: None,
1525 new_line: None,
1526 });
1527 output.extend_from_slice(&lines[start..end]);
1535 }
1536 output
1537}
1538
1539fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
1546 let end = lines.len();
1547 let start = end.saturating_sub(context + 1);
1548 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1549 let mut output = Vec::with_capacity(end - start + 1);
1550 output.push(LineDiff {
1551 prefix: "@".to_string(),
1552 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1553 old_line: None,
1554 new_line: None,
1555 });
1556 output.extend_from_slice(&lines[start..end]);
1557 output
1558}
1559
1560pub fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
1575 let mut output = Vec::with_capacity(lines.len());
1576 let mut body_start = 0usize;
1577 for (index, line) in lines.iter().enumerate() {
1578 if line.prefix == "@" {
1579 if body_start < index {
1580 output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
1581 }
1582 output.push(line.clone());
1583 body_start = index + 1;
1584 }
1585 }
1586 if body_start < lines.len() {
1587 output.extend(trim_trailing_added_decorations(&lines[body_start..]));
1588 }
1589 output
1590}
1591
1592fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
1593 let mut trimmed = Vec::with_capacity(lines.len());
1594 let mut index = 0usize;
1595 while index < lines.len() {
1596 if lines[index].prefix == "+"
1597 && is_visual_decoration_line(&lines[index].content)
1598 && let Some(next_context) = next_context_line(lines, index + 1)
1599 && next_context.content == lines[index].content
1600 {
1601 let added_block_has_code = lines[index + 1..next_context.index]
1602 .iter()
1603 .any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
1604 if added_block_has_code {
1605 index += 1;
1606 continue;
1607 }
1608 }
1609 trimmed.push(lines[index].clone());
1610 index += 1;
1611 }
1612 trimmed
1613}
1614
1615struct IndexedLine<'a> {
1616 index: usize,
1617 content: &'a str,
1618}
1619
1620fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
1621 lines[start..]
1622 .iter()
1623 .enumerate()
1624 .find(|(_, line)| line.prefix == " ")
1625 .map(|(offset, line)| IndexedLine {
1626 index: start + offset,
1627 content: &line.content,
1628 })
1629}
1630
1631fn is_blank_or_visual_decoration(line: &str) -> bool {
1632 line.trim().is_empty() || is_visual_decoration_line(line)
1633}
1634
1635fn is_visual_decoration_line(line: &str) -> bool {
1636 let trimmed = line.trim_start();
1637 trimmed.starts_with("#[")
1638 || trimmed.starts_with("#![")
1639 || trimmed.starts_with('@')
1640 || trimmed.starts_with("///")
1641 || trimmed.starts_with("//!")
1642}
1643
1644fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
1645 let old_before = lines[..start]
1646 .iter()
1647 .filter(|line| line.prefix != "+")
1648 .count();
1649 let new_before = lines[..start]
1650 .iter()
1651 .filter(|line| line.prefix != "-")
1652 .count();
1653 let old_len = lines[start..end]
1654 .iter()
1655 .filter(|line| line.prefix != "+")
1656 .count();
1657 let new_len = lines[start..end]
1658 .iter()
1659 .filter(|line| line.prefix != "-")
1660 .count();
1661
1662 let old_start = if old_len == 0 {
1663 old_before
1664 } else {
1665 old_before + 1
1666 };
1667 let new_start = if new_len == 0 {
1668 new_before
1669 } else {
1670 new_before + 1
1671 };
1672 (old_start, old_len, new_start, new_len)
1673}
1674
1675fn get_worktree_diff(
1676 repo: &Repository,
1677 from_tree: Option<&Tree>,
1678 path: &str,
1679 kind: &DiffKind,
1680) -> Result<(Vec<LineDiff>, FileEolState)> {
1681 let worktree_path = repo.root().join(path);
1682
1683 match kind {
1684 DiffKind::Added => {
1685 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1686 let eol = eol_for_added(&new_blob);
1687 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1688 }
1689 DiffKind::Deleted => {
1690 if let Some(tree) = from_tree
1694 && let Some(blob) = find_blob_in_tree(repo, tree, path)?
1695 {
1696 let eol = eol_for_deleted(&blob);
1697 return Ok((number_lines(blob_lines(&blob, "-")?), eol));
1698 }
1699 Ok((vec![], FileEolState::default()))
1700 }
1701 DiffKind::Modified => {
1702 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1703
1704 if let Some(tree) = from_tree
1705 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
1706 {
1707 return modified_blob_hunks(&old_blob, &new_blob);
1708 }
1709
1710 let eol = eol_for_added(&new_blob);
1711 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1712 }
1713 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
1714 }
1715}
1716
1717fn worktree_modified_type_change(
1733 repo_root: &Path,
1734 path: &str,
1735 diff_kind: DiffKind,
1736) -> Option<(&'static str, DiffKind)> {
1737 if matches!(diff_kind, DiffKind::Modified)
1738 && worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
1739 {
1740 Some(("deleted", DiffKind::Deleted))
1741 } else {
1742 None
1743 }
1744}
1745
1746fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
1747 let metadata = std::fs::symlink_metadata(path)?;
1748 if metadata.file_type().is_symlink() {
1749 let target = std::fs::read_link(path)?;
1750 return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
1751 }
1752 Ok(Blob::new(std::fs::read(path)?))
1753}
1754
1755fn is_symlink_mode(mode: Option<FileMode>) -> bool {
1756 matches!(mode, Some(FileMode::Symlink))
1757}
1758
1759fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
1767 match kind {
1768 "added" => (false, is_symlink_mode(mode)),
1769 "deleted" => (is_symlink_mode(mode), false),
1770 _ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
1771 }
1772}
1773
1774fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
1784 (old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
1785}
1786
1787fn symlink_change_from_blobs(
1791 kind: &str,
1792 old_blob: Option<&Blob>,
1793 old_mode: Option<FileMode>,
1794 new_blob: Option<&Blob>,
1795 mode: Option<FileMode>,
1796) -> Option<SymlinkChange> {
1797 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1798 let old = old_is_link
1799 .then(|| old_blob.map(|blob| blob.content().to_vec()))
1800 .flatten();
1801 let new = new_is_link
1802 .then(|| new_blob.map(|blob| blob.content().to_vec()))
1803 .flatten();
1804 make_symlink_change(old, new)
1805}
1806
1807#[allow(clippy::too_many_arguments)]
1814fn symlink_change_for_paths(
1815 repo: &Repository,
1816 from_tree: Option<&Tree>,
1817 to_tree: Option<&Tree>,
1818 kind: &str,
1819 old_path: &str,
1820 new_path: &str,
1821 old_mode: Option<FileMode>,
1822 mode: Option<FileMode>,
1823) -> Option<SymlinkChange> {
1824 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1825 let old = old_is_link
1826 .then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
1827 .flatten()
1828 .map(|blob| blob.content().to_vec());
1829 let new = new_is_link
1830 .then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
1831 .flatten()
1832 .map(|blob| blob.content().to_vec());
1833 make_symlink_change(old, new)
1834}
1835fn detect_clear_renames(
1836 repo: &Repository,
1837 from_tree: Option<&Tree>,
1838 to_tree: Option<&Tree>,
1839 changes: Vec<FileChange>,
1840 include_lines: bool,
1841 unified: usize,
1842) -> Result<Vec<FileChange>> {
1843 detect_clear_renames_with_stats(
1844 repo,
1845 from_tree,
1846 to_tree,
1847 changes,
1848 include_lines,
1849 unified,
1850 &mut RenameDetectionStats::default(),
1851 )
1852}
1853
1854#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1855struct RenameDetectionStats {
1856 blob_reads: usize,
1857 lcs_comparisons: usize,
1858 total_possible_pairs: usize,
1859 qualifying_candidate_pairs: usize,
1860}
1861
1862struct PreparedRenameBlob {
1863 blob: Blob,
1864 content_hash: ContentHash,
1865 text: Option<RenameTextFingerprint>,
1866}
1867
1868struct RenameTextFingerprint {
1869 line_count: usize,
1870 line_hash_counts: BTreeMap<u64, usize>,
1871}
1872
1873#[allow(clippy::too_many_arguments)]
1874fn detect_clear_renames_with_stats(
1875 repo: &Repository,
1876 from_tree: Option<&Tree>,
1877 to_tree: Option<&Tree>,
1878 changes: Vec<FileChange>,
1879 include_lines: bool,
1880 unified: usize,
1881 stats: &mut RenameDetectionStats,
1882) -> Result<Vec<FileChange>> {
1883 let mut deleted = changes
1884 .iter()
1885 .filter(|change| change.kind == "deleted")
1886 .map(|change| change.path.as_str())
1887 .collect::<Vec<_>>();
1888 let mut added = changes
1889 .iter()
1890 .filter(|change| change.kind == "added")
1891 .map(|change| change.path.as_str())
1892 .collect::<Vec<_>>();
1893 deleted.sort_unstable();
1894 added.sort_unstable();
1895 if deleted.is_empty() || added.is_empty() {
1896 return Ok(changes);
1897 }
1898 stats.total_possible_pairs = deleted.len().saturating_mul(added.len());
1899
1900 let deleted_side_modes = changes
1910 .iter()
1911 .filter(|change| change.kind == "deleted")
1912 .map(|change| (change.path.as_str(), change.mode))
1913 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
1914 let added_side_modes = changes
1915 .iter()
1916 .filter(|change| change.kind == "added")
1917 .map(|change| (change.path.as_str(), change.mode))
1918 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
1919
1920 let mut added_blobs = BTreeMap::new();
1921 for new_path in &added {
1922 stats.blob_reads += 1;
1923 if let Some(blob) = new_blob_for_rename(repo, to_tree, new_path)? {
1924 added_blobs.insert(*new_path, prepare_rename_blob(blob));
1925 }
1926 }
1927
1928 let mut candidates = RenameCandidateIndex::new(deleted.len(), added.len());
1929 for (old_index, old_path) in deleted.iter().enumerate() {
1930 stats.blob_reads += 1;
1931 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
1932 continue;
1933 };
1934 let old_blob = prepare_rename_blob(old_blob);
1935 for (new_index, new_path) in added.iter().enumerate() {
1936 if old_path == new_path {
1941 continue;
1942 }
1943 if !rename_mode_compatible(
1950 deleted_side_modes.get(old_path).copied().flatten(),
1951 added_side_modes.get(new_path).copied().flatten(),
1952 ) {
1953 continue;
1954 }
1955 let Some(new_blob) = added_blobs.get(new_path) else {
1956 continue;
1957 };
1958 let score = rename_similarity(&old_blob, new_blob, stats);
1959 if score >= RENAME_SIMILARITY_THRESHOLD {
1960 candidates.push(old_index, new_index, score);
1961 }
1962 }
1963 }
1964 stats.qualifying_candidate_pairs = candidates.candidate_count();
1965
1966 let renames = candidates
1967 .assign()
1968 .into_iter()
1969 .map(|assignment| {
1970 (
1971 deleted[assignment.source_index].to_string(),
1972 added[assignment.target_index].to_string(),
1973 assignment.score,
1974 )
1975 })
1976 .collect::<Vec<_>>();
1977 if renames.is_empty() {
1978 return Ok(changes);
1979 }
1980
1981 let rename_by_new = renames
1982 .iter()
1983 .map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
1984 .collect::<std::collections::BTreeMap<_, _>>();
1985 let removed_old = renames
1986 .iter()
1987 .map(|(old_path, _, _)| old_path.as_str())
1988 .collect::<BTreeSet<_>>();
1989 let deleted_modes = changes
1995 .iter()
1996 .filter(|change| change.kind == "deleted")
1997 .map(|change| (change.path.clone(), change.mode))
1998 .collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
1999
2000 let mut output = Vec::with_capacity(changes.len() - renames.len());
2001 for mut change in changes {
2002 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2003 continue;
2004 }
2005 if change.kind == "added"
2006 && let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
2007 {
2008 let (lines, eol) = if include_lines {
2009 match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
2010 Ok(Some((lines, eol))) => (Some(lines), eol),
2011 Ok(None) => (None, FileEolState::default()),
2012 Err(error) if is_binary_diff_error(&error) => {
2013 change.binary = true;
2014 (None, FileEolState::default())
2015 }
2016 Err(error) => return Err(error),
2017 }
2018 } else {
2019 (None, FileEolState::default())
2020 };
2021 change.kind = "renamed".to_string();
2022 change.old_path = Some(old_path.to_string());
2023 change.similarity_score = Some(score);
2024 change.lines = lines;
2025 change.eol = eol;
2026 change.old_mode = deleted_modes.get(old_path).copied().flatten();
2030 change.symlink = symlink_change_for_paths(
2037 repo,
2038 from_tree,
2039 to_tree,
2040 "renamed",
2041 old_path,
2042 &change.path,
2043 change.old_mode,
2044 change.mode,
2045 );
2046 if change.symlink.is_some() {
2047 change.binary = false;
2048 }
2049 change.line_counts = None;
2056 }
2057 output.push(change);
2058 }
2059 Ok(output)
2060}
2061
2062fn rename_lines(
2063 repo: &Repository,
2064 from_tree: Option<&Tree>,
2065 to_tree: Option<&Tree>,
2066 old_path: &str,
2067 new_path: &str,
2068 unified: usize,
2069) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
2070 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2071 return Ok(None);
2072 };
2073 let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
2074 return Ok(None);
2075 };
2076 ensure_text_diffable(&old_blob)?;
2077 ensure_text_diffable(&new_blob)?;
2078 let eol = eol_for_modified(&old_blob, &new_blob);
2079 let diff = diff_blobs(&old_blob, &new_blob);
2080 let lines = diff
2081 .iter()
2082 .map(|line| LineDiff::new(line.prefix(), line.content()))
2083 .collect();
2084 Ok(Some((
2085 unified_hunks(number_lines(lines), unified, &eol),
2086 eol,
2087 )))
2088}
2089
2090fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
2091 let Some(tree) = tree else {
2092 return Ok(None);
2093 };
2094 find_blob_in_tree(repo, tree, path)
2095}
2096
2097fn new_blob_for_rename(
2098 repo: &Repository,
2099 to_tree: Option<&Tree>,
2100 path: &str,
2101) -> Result<Option<Blob>> {
2102 if let Some(tree) = to_tree {
2103 return find_blob_in_tree(repo, tree, path);
2104 }
2105
2106 let worktree_path = repo.root().join(path);
2114 match std::fs::symlink_metadata(&worktree_path) {
2115 Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
2116 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2117 Err(error) => Err(error.into()),
2118 }
2119}
2120
2121fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
2131 let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
2132 is_symlink(old) == is_symlink(new)
2133}
2134
2135fn prepare_rename_blob(blob: Blob) -> PreparedRenameBlob {
2136 let content_hash = blob.hash();
2137 let text = blob.content_str().and_then(|text| {
2138 if text.chars().any(is_terminal_hostile_control) {
2139 return None;
2140 }
2141 let mut line_count = 0;
2142 let mut line_hash_counts = BTreeMap::new();
2143 for line in text.lines() {
2144 line_count += 1;
2145 *line_hash_counts.entry(cheap_line_hash(line)).or_insert(0) += 1;
2146 }
2147 Some(RenameTextFingerprint {
2148 line_count,
2149 line_hash_counts,
2150 })
2151 });
2152 PreparedRenameBlob {
2153 blob,
2154 content_hash,
2155 text,
2156 }
2157}
2158
2159fn cheap_line_hash(line: &str) -> u64 {
2160 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
2161 const FNV_PRIME: u64 = 0x100000001b3;
2162 line.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
2163 (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
2164 })
2165}
2166
2167fn can_reach_rename_threshold(
2168 old_text: &RenameTextFingerprint,
2169 new_text: &RenameTextFingerprint,
2170) -> bool {
2171 let total_lines = old_text.line_count + new_text.line_count;
2172 if old_text.line_count == 0 || new_text.line_count == 0 {
2173 return false;
2174 }
2175
2176 let length_upper_bound = old_text.line_count.min(new_text.line_count);
2177 if (length_upper_bound as u128) * 8 < (total_lines as u128) * 3 {
2178 return false;
2179 }
2180
2181 let shared_hash_upper_bound = old_text
2185 .line_hash_counts
2186 .iter()
2187 .filter_map(|(hash, old_count)| {
2188 new_text
2189 .line_hash_counts
2190 .get(hash)
2191 .map(|new_count| old_count.min(new_count))
2192 })
2193 .sum::<usize>();
2194 (shared_hash_upper_bound as u128) * 8 >= (total_lines as u128) * 3
2195}
2196
2197fn rename_similarity(
2198 old_blob: &PreparedRenameBlob,
2199 new_blob: &PreparedRenameBlob,
2200 stats: &mut RenameDetectionStats,
2201) -> f64 {
2202 if old_blob.content_hash == new_blob.content_hash
2203 && old_blob.blob.content() == new_blob.blob.content()
2204 {
2205 return 1.0;
2206 }
2207 let (Some(old_fingerprint), Some(new_fingerprint)) = (&old_blob.text, &new_blob.text) else {
2208 return 0.0;
2209 };
2210 if !can_reach_rename_threshold(old_fingerprint, new_fingerprint) {
2211 return 0.0;
2212 }
2213 let old_text = old_blob
2214 .blob
2215 .content_str()
2216 .expect("text fingerprint requires UTF-8 content");
2217 let new_text = new_blob
2218 .blob
2219 .content_str()
2220 .expect("text fingerprint requires UTF-8 content");
2221 let old_lines = old_text.lines().collect::<Vec<_>>();
2222 let new_lines = new_text.lines().collect::<Vec<_>>();
2223 stats.lcs_comparisons += 1;
2224 let shared = lcs_len(&old_lines, &new_lines);
2225 (shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
2226}
2227
2228fn lcs_len(left: &[&str], right: &[&str]) -> usize {
2229 let mut previous = vec![0usize; right.len() + 1];
2230 let mut current = vec![0usize; right.len() + 1];
2231 for left_line in left {
2232 for (index, right_line) in right.iter().enumerate() {
2233 current[index + 1] = if left_line == right_line {
2234 previous[index] + 1
2235 } else {
2236 previous[index + 1].max(current[index])
2237 };
2238 }
2239 std::mem::swap(&mut previous, &mut current);
2240 current.fill(0);
2241 }
2242 previous[right.len()]
2243}
2244
2245fn get_state_diff(
2257 repo: &Repository,
2258 from_tree: Option<&Tree>,
2259 to_tree: &Tree,
2260 path: &str,
2261 kind: &DiffKind,
2262) -> Result<(Vec<LineDiff>, FileEolState)> {
2263 match kind {
2264 DiffKind::Added => {
2265 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2266 return Ok((Vec::new(), FileEolState::default()));
2267 };
2268 let eol = eol_for_added(&new_blob);
2269 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2270 }
2271 DiffKind::Deleted => {
2272 let Some(tree) = from_tree else {
2273 return Ok((Vec::new(), FileEolState::default()));
2274 };
2275 let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
2276 return Ok((Vec::new(), FileEolState::default()));
2277 };
2278 let eol = eol_for_deleted(&old_blob);
2279 Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
2280 }
2281 DiffKind::Modified => {
2282 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2283 return Ok((Vec::new(), FileEolState::default()));
2284 };
2285 if let Some(tree) = from_tree
2286 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
2287 {
2288 return modified_blob_hunks(&old_blob, &new_blob);
2289 }
2290 let eol = eol_for_added(&new_blob);
2292 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2293 }
2294 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
2295 }
2296}
2297
2298fn eol_for_added(new_blob: &Blob) -> FileEolState {
2302 let (new_eol, new_count) = blob_eol_meta(new_blob);
2303 FileEolState {
2304 old_has_final_newline: true,
2305 new_has_final_newline: new_eol,
2306 old_line_count: 0,
2307 new_line_count: new_count,
2308 }
2309}
2310
2311fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
2312 let (old_eol, old_count) = blob_eol_meta(old_blob);
2313 FileEolState {
2314 old_has_final_newline: old_eol,
2315 new_has_final_newline: true,
2316 old_line_count: old_count,
2317 new_line_count: 0,
2318 }
2319}
2320
2321fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
2322 let (old_eol, old_count) = blob_eol_meta(old_blob);
2323 let (new_eol, new_count) = blob_eol_meta(new_blob);
2324 FileEolState {
2325 old_has_final_newline: old_eol,
2326 new_has_final_newline: new_eol,
2327 old_line_count: old_count,
2328 new_line_count: new_count,
2329 }
2330}
2331
2332fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
2337 let content = blob.content();
2338 if content.is_empty() {
2339 return (true, 0);
2340 }
2341 let has_eol = content.ends_with(b"\n");
2342 let line_count = blob
2343 .content_str()
2344 .map(|text| text.lines().count())
2345 .unwrap_or(0);
2346 (has_eol, line_count)
2347}
2348
2349fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
2350 let text = text_diff_content(blob)?;
2351 Ok(text
2352 .lines()
2353 .map(|line| LineDiff::new(prefix, line))
2354 .collect())
2355}
2356
2357fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
2371 if old.content() == new.content() {
2372 return Ok((Vec::new(), FileEolState::default()));
2373 }
2374 ensure_text_diffable(old)?;
2375 ensure_text_diffable(new)?;
2376 let eol = eol_for_modified(old, new);
2377 let diff = diff_blobs(old, new);
2378 let lines = diff
2379 .iter()
2380 .map(|l| LineDiff::new(l.prefix(), l.content()))
2381 .collect();
2382 Ok((number_lines(lines), eol))
2383}
2384
2385fn ensure_text_diffable(blob: &Blob) -> Result<()> {
2386 text_diff_content(blob).map(|_| ())
2387}
2388
2389fn text_diff_content(blob: &Blob) -> Result<&str> {
2390 let Some(text) = blob.content_str() else {
2391 return Err(anyhow!(BINARY_DIFF_ERROR));
2392 };
2393 if text.chars().any(is_terminal_hostile_control) {
2394 return Err(anyhow!(BINARY_DIFF_ERROR));
2395 }
2396 Ok(text)
2397}
2398
2399fn is_binary_diff_error(error: &anyhow::Error) -> bool {
2400 error.to_string() == BINARY_DIFF_ERROR
2401}
2402
2403fn is_terminal_hostile_control(ch: char) -> bool {
2404 ch.is_control() && ch != '\n' && ch != '\t'
2405}
2406
2407fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
2408 let mut old_line = 1usize;
2409 let mut new_line = 1usize;
2410
2411 lines
2412 .into_iter()
2413 .map(|line| {
2414 let old = if line.prefix != "+" {
2415 let current = Some(old_line);
2416 old_line += 1;
2417 current
2418 } else {
2419 None
2420 };
2421 let new = if line.prefix != "-" {
2422 let current = Some(new_line);
2423 new_line += 1;
2424 current
2425 } else {
2426 None
2427 };
2428 LineDiff::with_lines(line.prefix, line.content, old, new)
2429 })
2430 .collect()
2431}
2432
2433fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
2434 match find_entry_in_tree(repo, tree, path)? {
2435 Some(entry) => match entry.content_hash() {
2436 Some(hash) if entry.is_blob() || entry.is_symlink() => {
2437 Ok(Some(repo.require_blob(&hash)?))
2438 }
2439 _ => Ok(None),
2440 },
2441 None => Ok(None),
2442 }
2443}
2444
2445fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
2453 let parts: Vec<&str> = path.split('/').collect();
2454 find_entry_recursive(repo, tree, &parts)
2455}
2456
2457fn find_entry_recursive(
2458 repo: &Repository,
2459 tree: &Tree,
2460 parts: &[&str],
2461) -> Result<Option<TreeEntry>> {
2462 if parts.is_empty() {
2463 return Ok(None);
2464 }
2465
2466 let name = parts[0];
2467 let entry = match tree.get(name) {
2468 Some(e) => e,
2469 None => return Ok(None),
2470 };
2471
2472 if parts.len() == 1 {
2473 if entry.is_blob() || entry.entry_type() == EntryType::Symlink || entry.is_gitlink() {
2474 return Ok(Some(entry.clone()));
2475 }
2476 } else if entry.is_tree()
2477 && let Some(hash) = entry.tree_hash()
2478 && let Some(subtree) = repo.store().get_tree(&hash)?
2479 {
2480 return find_entry_recursive(repo, &subtree, &parts[1..]);
2481 }
2482
2483 Ok(None)
2484}
2485
2486fn worktree_file_mode(path: &Path) -> Option<FileMode> {
2491 let metadata = std::fs::symlink_metadata(path).ok()?;
2492 if metadata.file_type().is_symlink() {
2493 return Some(FileMode::Symlink);
2494 }
2495 #[cfg(unix)]
2496 {
2497 use std::os::unix::fs::PermissionsExt;
2498 if metadata.permissions().mode() & 0o111 != 0 {
2499 return Some(FileMode::Executable);
2500 }
2501 }
2502 Some(FileMode::Normal)
2503}
2504
2505fn change_file_modes(
2518 repo: &Repository,
2519 from_tree: Option<&Tree>,
2520 to_tree: Option<&Tree>,
2521 path: &str,
2522 kind: &str,
2523) -> (Option<FileMode>, Option<FileMode>) {
2524 let old_side = || {
2525 from_tree
2526 .and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
2527 .map(|entry| entry.mode())
2528 };
2529 let new_side = || match to_tree {
2530 Some(tree) => find_entry_in_tree(repo, tree, path)
2531 .ok()
2532 .flatten()
2533 .map(|entry| entry.mode()),
2534 None => worktree_file_mode(&repo.root().join(path)),
2535 };
2536 match kind {
2537 "added" => (None, new_side()),
2538 "deleted" => (None, old_side()),
2539 "modified" => (old_side(), new_side()),
2540 _ => (None, None),
2541 }
2542}
2543
2544#[cfg(test)]
2545mod tests {
2546 use objects::{
2547 object::{Blob, FileMode, Tree, TreeEntry},
2548 store::ObjectStore,
2549 };
2550 use repo::Repository;
2551 use tempfile::TempDir;
2552
2553 use super::{
2554 DiffStats, FileChange, FileEolState, LineCounts, LineDiff, RENAME_SIMILARITY_THRESHOLD,
2555 RenameDetectionStats, change_line_counts, detect_clear_renames_with_stats, lcs_len,
2556 prepare_rename_blob, rename_similarity, unified_hunks,
2557 };
2558
2559 type RenameSummary = Vec<(String, String, Option<String>, Option<f64>)>;
2560
2561 fn rename_fixture(
2562 shared_line_counts: &[usize],
2563 ) -> (TempDir, Repository, Tree, Tree, Vec<FileChange>) {
2564 let temp = TempDir::new().expect("create rename fixture");
2565 let repo = Repository::init_default(temp.path()).expect("initialize rename fixture");
2566 let mut old_entries = Vec::with_capacity(shared_line_counts.len());
2567 let mut new_entries = Vec::with_capacity(shared_line_counts.len());
2568 let mut changes = Vec::with_capacity(shared_line_counts.len() * 2);
2569
2570 for (file_index, shared_lines) in shared_line_counts.iter().copied().enumerate() {
2571 let old_content = (0..32)
2572 .map(|line_index| format!("file {file_index} original line {line_index}\n"))
2573 .collect::<String>();
2574 let new_content = (0..32)
2575 .map(|line_index| {
2576 if line_index < shared_lines {
2577 format!("file {file_index} original line {line_index}\n")
2578 } else {
2579 format!("file {file_index} replacement line {line_index}\n")
2580 }
2581 })
2582 .collect::<String>();
2583 let old_hash = repo
2584 .store()
2585 .put_blob(&Blob::from(old_content))
2586 .expect("store old rename blob");
2587 let new_hash = repo
2588 .store()
2589 .put_blob(&Blob::from(new_content))
2590 .expect("store new rename blob");
2591 let old_path = format!("old_{file_index:04}.txt");
2592 let new_path = format!("new_{file_index:04}.txt");
2593 old_entries
2594 .push(TreeEntry::file(&old_path, old_hash, false).expect("build old tree entry"));
2595 new_entries
2596 .push(TreeEntry::file(&new_path, new_hash, false).expect("build new tree entry"));
2597 changes.push(FileChange {
2598 path: old_path,
2599 kind: "deleted".to_string(),
2600 mode: Some(FileMode::Normal),
2601 ..Default::default()
2602 });
2603 changes.push(FileChange {
2604 path: new_path,
2605 kind: "added".to_string(),
2606 mode: Some(FileMode::Normal),
2607 ..Default::default()
2608 });
2609 }
2610
2611 (
2612 temp,
2613 repo,
2614 Tree::from_entries(old_entries),
2615 Tree::from_entries(new_entries),
2616 changes,
2617 )
2618 }
2619
2620 fn run_rename_fixture(shared_line_counts: &[usize]) -> (RenameSummary, RenameDetectionStats) {
2621 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(shared_line_counts);
2622 let mut stats = RenameDetectionStats::default();
2623 let output = detect_clear_renames_with_stats(
2624 &repo,
2625 Some(&old_tree),
2626 Some(&new_tree),
2627 changes,
2628 false,
2629 0,
2630 &mut stats,
2631 )
2632 .expect("detect fixture renames");
2633 let summary = output
2634 .into_iter()
2635 .map(|change| {
2636 (
2637 change.path,
2638 change.kind,
2639 change.old_path,
2640 change.similarity_score,
2641 )
2642 })
2643 .collect();
2644 (summary, stats)
2645 }
2646
2647 #[test]
2648 fn rename_fixture_characterizes_exact_threshold_and_rejected_pairs() {
2649 let (summary, _) = run_rename_fixture(&[32, 31, 24, 23]);
2650
2651 assert_eq!(
2652 summary,
2653 vec![
2654 (
2655 "new_0000.txt".to_string(),
2656 "renamed".to_string(),
2657 Some("old_0000.txt".to_string()),
2658 Some(1.0),
2659 ),
2660 (
2661 "new_0001.txt".to_string(),
2662 "renamed".to_string(),
2663 Some("old_0001.txt".to_string()),
2664 Some(31.0 / 32.0),
2665 ),
2666 (
2667 "new_0002.txt".to_string(),
2668 "renamed".to_string(),
2669 Some("old_0002.txt".to_string()),
2670 Some(0.75),
2671 ),
2672 (
2673 "old_0003.txt".to_string(),
2674 "deleted".to_string(),
2675 None,
2676 None,
2677 ),
2678 ("new_0003.txt".to_string(), "added".to_string(), None, None,),
2679 ]
2680 );
2681 }
2682
2683 #[test]
2684 fn many_rename_detection_reads_each_blob_once_and_limits_lcs_to_candidates() {
2685 const FILE_COUNT: usize = 32;
2686 let (summary, stats) = run_rename_fixture(&[31; FILE_COUNT]);
2687
2688 assert_eq!(summary.len(), FILE_COUNT);
2689 assert!(summary.iter().all(|(_, kind, _, _)| kind == "renamed"));
2690 assert_eq!(
2691 stats.blob_reads,
2692 FILE_COUNT * 2,
2693 "each old and added blob should be read once per diff"
2694 );
2695 assert_eq!(
2696 stats.lcs_comparisons, FILE_COUNT,
2697 "only the one plausible modified target per deleted file should reach LCS"
2698 );
2699 assert_eq!(stats.total_possible_pairs, FILE_COUNT * FILE_COUNT);
2700 assert_eq!(
2701 stats.qualifying_candidate_pairs, FILE_COUNT,
2702 "only threshold-qualified pairs should enter deterministic assignment"
2703 );
2704 assert!(stats.qualifying_candidate_pairs < stats.total_possible_pairs);
2705 }
2706
2707 #[test]
2708 fn rename_prefilter_preserves_all_qualifying_short_line_pairs() {
2709 let mut contents = vec![String::new()];
2710 for line_count in 1..=5 {
2711 for bits in 0..(1usize << line_count) {
2712 let content = (0..line_count)
2713 .map(|line| {
2714 if bits & (1 << line) == 0 {
2715 "alpha"
2716 } else {
2717 "beta"
2718 }
2719 })
2720 .collect::<Vec<_>>()
2721 .join("\n");
2722 contents.push(content);
2723 }
2724 }
2725
2726 for old_content in &contents {
2727 for new_content in &contents {
2728 let old_lines = old_content.lines().collect::<Vec<_>>();
2729 let new_lines = new_content.lines().collect::<Vec<_>>();
2730 let expected = if old_content == new_content {
2731 1.0
2732 } else if old_lines.is_empty() || new_lines.is_empty() {
2733 0.0
2734 } else {
2735 (lcs_len(&old_lines, &new_lines) * 2) as f64
2736 / (old_lines.len() + new_lines.len()) as f64
2737 };
2738 if expected < RENAME_SIMILARITY_THRESHOLD {
2739 continue;
2740 }
2741
2742 let old_blob = prepare_rename_blob(Blob::from(old_content.clone()));
2743 let new_blob = prepare_rename_blob(Blob::from(new_content.clone()));
2744 let actual =
2745 rename_similarity(&old_blob, &new_blob, &mut RenameDetectionStats::default());
2746 assert_eq!(
2747 actual, expected,
2748 "qualifying pair changed score: old={old_content:?}, new={new_content:?}"
2749 );
2750 }
2751 }
2752 }
2753
2754 #[test]
2755 #[ignore = "focused release-mode wall-time measurement"]
2756 fn benchmark_many_modified_renames() {
2757 use std::time::Instant;
2758
2759 const FILE_COUNT: usize = 128;
2760 const SAMPLES: usize = 7;
2761 let shared_line_counts = vec![31; FILE_COUNT];
2762 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(&shared_line_counts);
2763 let mut samples = Vec::with_capacity(SAMPLES);
2764 let mut final_stats = RenameDetectionStats::default();
2765
2766 for _ in 0..SAMPLES {
2767 let mut stats = RenameDetectionStats::default();
2768 let started = Instant::now();
2769 let output = detect_clear_renames_with_stats(
2770 &repo,
2771 Some(&old_tree),
2772 Some(&new_tree),
2773 changes.clone(),
2774 false,
2775 0,
2776 &mut stats,
2777 )
2778 .expect("benchmark rename detection");
2779 assert_eq!(output.len(), FILE_COUNT);
2780 samples.push(started.elapsed());
2781 final_stats = stats;
2782 }
2783 samples.sort();
2784 eprintln!(
2785 "rename_diff files={FILE_COUNT} samples={SAMPLES} median_ms={:.3} blob_reads={} lcs_comparisons={}",
2786 samples[SAMPLES / 2].as_secs_f64() * 1_000.0,
2787 final_stats.blob_reads,
2788 final_stats.lcs_comparisons,
2789 );
2790 }
2791
2792 fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
2793 FileChange {
2794 path: "notes.txt".to_string(),
2795 kind: kind.to_string(),
2796 line_counts: Some(counts),
2797 ..Default::default()
2798 }
2799 }
2800
2801 #[test]
2808 fn diff_stats_reads_line_counts_when_hunks_dropped() {
2809 let changes = vec![stat_change(
2810 "modified",
2811 LineCounts {
2812 added: 1,
2813 modified: 0,
2814 deleted: 0,
2815 },
2816 )];
2817
2818 let stats = DiffStats::from_changes(&changes, None);
2819
2820 assert_eq!(stats.files_changed, 1);
2821 assert_eq!(stats.additions, 1);
2822 assert_eq!(stats.modifications, 0);
2823 assert_eq!(stats.deletions, 0);
2824 assert_eq!(stats.renames, 0);
2825 }
2826
2827 #[test]
2832 fn diff_stats_treats_zero_line_counts_as_authoritative() {
2833 let changes = vec![stat_change(
2834 "modified",
2835 LineCounts {
2836 added: 0,
2837 modified: 0,
2838 deleted: 0,
2839 },
2840 )];
2841
2842 let stats = DiffStats::from_changes(&changes, None);
2843
2844 assert_eq!(stats.modifications, 0);
2845 assert_eq!(stats.additions, 0);
2846 assert_eq!(stats.deletions, 0);
2847 }
2848
2849 #[test]
2852 fn change_line_counts_pairs_modified_lines() {
2853 let lines = vec![
2854 LineDiff::with_lines("-", "alpha", Some(1), None),
2855 LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
2856 LineDiff::with_lines("+", "fresh", None, Some(2)),
2857 ];
2858 let counts = change_line_counts(Some(&lines));
2859 assert_eq!(counts.modified, 1);
2860 assert_eq!(counts.added, 1);
2861 assert_eq!(counts.deleted, 0);
2862 }
2863
2864 #[test]
2870 fn unified_hunks_keeps_added_decoration_in_canonical_body() {
2871 let lines = vec![
2872 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2873 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2874 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2875 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2876 ];
2877
2878 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2879
2880 let header = hunk
2881 .iter()
2882 .find(|line| line.prefix == "@")
2883 .expect("hunk should carry an `@@` header");
2884 assert_eq!(
2886 header.content, "@ -1,2 +1,4 @@",
2887 "header counts must match the untrimmed body: {hunk:?}"
2888 );
2889 assert!(
2890 hunk.iter()
2891 .any(|line| line.prefix == "+" && line.content == "#[test]"),
2892 "added decoration line must survive in the canonical body: {hunk:?}"
2893 );
2894 assert!(
2895 hunk.iter()
2896 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2897 "added function body should remain: {hunk:?}"
2898 );
2899 }
2900
2901 #[test]
2905 fn display_trim_drops_added_decoration_but_keeps_header() {
2906 use super::trim_added_decorations_for_display;
2907
2908 let lines = vec![
2909 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2910 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2911 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2912 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2913 ];
2914 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2915
2916 let display = trim_added_decorations_for_display(&hunk);
2917
2918 assert!(
2919 display
2920 .iter()
2921 .filter(|line| line.content == "#[test]")
2922 .all(|line| line.prefix == " "),
2923 "display trim should let existing context own the decoration: {display:?}"
2924 );
2925 assert!(
2926 display
2927 .iter()
2928 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2929 "added function body should remain after display trim: {display:?}"
2930 );
2931 assert_eq!(
2932 display
2933 .iter()
2934 .find(|line| line.prefix == "@")
2935 .map(|l| l.content.as_str()),
2936 Some("@ -1,2 +1,4 @@"),
2937 "display trim must not rewrite the `@@` header: {display:?}"
2938 );
2939 }
2940
2941 #[test]
2944 fn minimal_resolve_failure_maps_to_recovery_state_not_found() {
2945 use objects::{RecoveryDetails, error::HeddleError};
2946 use repo::{
2947 ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
2948 };
2949 use tempfile::TempDir;
2950
2951 let temp = TempDir::new().unwrap();
2952 let repo = repo::Repository::init_default(temp.path()).unwrap();
2953 std::fs::write(temp.path().join("a.txt"), "a").unwrap();
2954 repo.snapshot(Some("seed".into()), None).unwrap();
2955
2956 let err = resolve_state_for_command(&repo, "hs-zzzzzzzzzzzz", ResolvePolicy::minimal())
2957 .unwrap_err();
2958 let mapped = match err {
2959 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
2960 HeddleError::recovery(RecoveryDetails::state_not_found(spec))
2961 }
2962 other => panic!("expected not-found failure, got {other:?}"),
2963 };
2964 assert!(matches!(mapped, HeddleError::Recovery(_)));
2965 assert!(
2966 mapped.to_string().contains("State not found"),
2967 "unexpected message: {mapped}"
2968 );
2969 }
2970}