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 AnnotationStatus, Blob, ContentHash, ContextTarget, DiffKind, EntryType, FileChangeSet,
15 FileMode, SemanticChange, State, StateId, Tree, TreeEntry,
16 },
17 store::ObjectStore,
18 worktree::{WorktreeStatus, diff_blobs},
19};
20use repo::{
21 Repository, ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
22};
23#[cfg(feature = "semantic")]
24use semantic::diff::{SemanticDiffOptions, WorktreeStatus as SemanticWorktreeStatus};
25use sley::{EntryKind, Repository as SleyRepository};
26
27use crate::ExecutionContext;
28
29mod patch;
30mod path_filter;
31mod types;
32
33pub use patch::{render_diff_patch, render_diff_patch_bytes, write_diff_patch};
34pub use types::*;
35
36const BINARY_DIFF_ERROR: &str = "binary file";
37const RENAME_SIMILARITY_THRESHOLD: f64 = 0.75;
38
39#[derive(Clone, Debug, Default)]
40struct SemanticDiffResult {
41 changes: Vec<SemanticChange>,
42 file_changes: FileChangeSet,
43}
44
45#[derive(Clone, Debug)]
47pub struct DiffOptions {
48 pub from: Option<String>,
49 pub to: Option<String>,
50 pub semantic: bool,
51 pub stat: bool,
52 pub name_only: bool,
53 pub unified: usize,
54 pub show_context: bool,
55 pub include_patch_text: bool,
59 pub paths: Vec<String>,
61}
62
63impl Default for DiffOptions {
64 fn default() -> Self {
65 Self {
66 from: None,
67 to: None,
68 semantic: false,
69 stat: false,
70 name_only: false,
71 unified: 3,
72 show_context: false,
73 include_patch_text: false,
74 paths: Vec::new(),
75 }
76 }
77}
78
79#[derive(Debug)]
81pub struct PlainGitDiffProbe {
82 pub root: PathBuf,
83 pub changes: WorktreeStatus,
84}
85
86pub fn diff(ctx: &ExecutionContext, options: DiffOptions) -> Result<DiffReport> {
88 let repo = ctx.require_repo().map_err(anyhow::Error::new)?;
89 let to = options.to.as_ref();
90 let git_overlay_head_worktree_diff = repo.current_state()?.is_none()
91 && to.is_none()
92 && matches!(options.from.as_deref(), Some("HEAD" | "@"));
93
94 let from_id = if git_overlay_head_worktree_diff {
95 None
96 } else if let Some(ref spec) = options.from {
97 Some(resolve_state_id(repo, spec)?)
98 } else {
99 repo.head()?
100 };
101
102 let from_state = if let Some(id) = from_id {
103 Some(require_resolved_state(repo, &id)?)
104 } else {
105 None
106 };
107
108 let from_tree = if let Some(ref state) = from_state {
109 repo.store().get_tree(&state.tree)?
110 } else {
111 None
112 };
113 let to_state = if let Some(to_spec) = to {
114 let to_id = resolve_state_id(repo, to_spec)?;
115 Some(require_resolved_state(repo, &to_id)?)
116 } else {
117 None
118 };
119 let to_tree = if let Some(ref state) = to_state {
120 repo.store().get_tree(&state.tree)?
121 } else {
122 None
123 };
124 let status_options = ctx.config().worktree_status_options(Some(repo.config()));
125 let from_hash = from_state
126 .as_ref()
127 .map(|state| state.tree)
128 .unwrap_or_else(|| Tree::new().hash());
129
130 let semantic_diff_result = if options.semantic {
131 if let Some(ref to_state) = to_state {
132 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
133 } else {
134 Some(run_semantic_worktree_diff(
135 repo,
136 &from_hash,
137 &status_options,
138 )?)
139 }
140 } else {
141 None
142 };
143
144 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
145 result.file_changes.clone()
146 } else if let Some(ref to_state) = to_state {
147 repo.diff_trees(&from_hash, &to_state.tree)?
148 } else if git_overlay_head_worktree_diff {
149 file_change_set_from_status(&repo.git_overlay_worktree_status()?.unwrap_or_default())
150 } else {
151 let tree = from_tree.clone().unwrap_or_default();
152 file_change_set_from_status(
153 &repo.compare_worktree_cached_with_options(&tree, &status_options)?,
154 )
155 };
156
157 let patch_text_needed = options.include_patch_text;
158 let want_hunks = patch_text_needed || !(options.name_only || options.stat);
159 let file_changes = file_changes_from_change_set(
160 repo,
161 from_tree.as_ref(),
162 to_tree.as_ref(),
163 &changes,
164 &options,
165 want_hunks,
166 patch_text_needed,
167 )?;
168
169 let semantic_changes = semantic_diff_result.map(|result| {
170 result
171 .changes
172 .into_iter()
173 .map(SemanticChangeEntry::from)
174 .collect()
175 });
176
177 let context_state = if options.show_context {
178 if let Some(ref state) = to_state {
179 Some(state.clone())
180 } else if let Some(state) = from_state.clone() {
181 Some(state)
182 } else {
183 repo.current_state()?
184 }
185 } else {
186 None
187 };
188
189 let stats = DiffStats::from_changes(&file_changes, semantic_changes.as_deref());
190 let mut output = DiffReport::with_stats(
191 from_id.map(|id| id.short()),
192 options.to.clone(),
193 file_changes,
194 semantic_changes,
195 context_state
196 .as_ref()
197 .map(|state| collect_file_context(repo, state, &changes))
198 .transpose()?,
199 context_state
200 .as_ref()
201 .map(|state| collect_state_guidance(repo, state))
202 .transpose()?,
203 stats,
204 );
205 output.worktree_mode = options.to.is_none();
206 finalize_diff_report(output, &options)
207}
208
209fn file_changes_from_change_set(
210 repo: &Repository,
211 from_tree: Option<&Tree>,
212 to_tree: Option<&Tree>,
213 changes: &FileChangeSet,
214 options: &DiffOptions,
215 want_hunks: bool,
216 patch_text_needed: bool,
217) -> Result<Vec<FileChange>> {
218 let file_changes: Vec<FileChange> = if options.name_only && !patch_text_needed {
219 changes
220 .iter()
221 .map(|change| {
222 make_status_only_change(
223 Some(repo),
224 from_tree,
225 to_tree,
226 &change.path,
227 &change.kind.to_string(),
228 )
229 })
230 .collect()
231 } else {
232 changes
233 .iter()
234 .map(|change| {
235 let effective_kind = if to_tree.is_none() {
236 worktree_modified_type_change(repo.root(), &change.path, change.kind)
237 .map(|(_, diff_kind)| diff_kind)
238 .unwrap_or(change.kind)
239 } else {
240 change.kind
241 };
242 let diff_result = if let Some(tree) = to_tree {
243 get_state_diff(repo, from_tree, tree, &change.path, &effective_kind)
244 } else {
245 get_worktree_diff(repo, from_tree, &change.path, &effective_kind)
246 };
247 let binary = diff_result.as_ref().err().is_some_and(is_binary_diff_error);
248 let (raw_lines, eol) = match diff_result {
249 Ok((lines, eol)) => (Some(lines), eol),
250 Err(_) => (None, FileEolState::default()),
251 };
252 let (lines, line_counts) = if options.stat && !patch_text_needed {
253 let counts = change_line_counts(raw_lines.as_deref());
254 (None, Some(counts))
255 } else {
256 (
257 raw_lines.map(|lines| unified_hunks(lines, options.unified, &eol)),
258 None,
259 )
260 };
261
262 let kind = effective_kind.to_string();
263 let (old_mode, mode) =
264 change_file_modes(repo, from_tree, to_tree, &change.path, &kind);
265 let symlink = symlink_change_for_paths(
266 repo,
267 from_tree,
268 to_tree,
269 &kind,
270 &change.path,
271 &change.path,
272 old_mode,
273 mode,
274 );
275 FileChange {
276 path: change.path.clone(),
277 kind,
278 binary: binary && symlink.is_none(),
279 lines,
280 line_counts,
281 eol,
282 mode,
283 old_mode,
284 symlink,
285 ..Default::default()
286 }
287 })
288 .collect()
289 };
290 let file_changes = sort_changes_by_path(file_changes);
291 let file_changes = expand_type_changes(
292 repo,
293 from_tree,
294 to_tree,
295 file_changes,
296 want_hunks,
297 options.unified,
298 )?;
299 detect_clear_renames(
300 repo,
301 from_tree,
302 to_tree,
303 file_changes,
304 want_hunks,
305 options.unified,
306 )
307}
308
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 finalize_diff_report(output, options)
357}
358
359pub fn plain_git_head_diff(probe: &PlainGitDiffProbe, options: &DiffOptions) -> Result<DiffReport> {
362 if options.include_patch_text {
363 let changes = plain_git_file_changes_with_hunks(probe, options.unified)?;
364 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
365 output.worktree_mode = true;
366 return finalize_diff_report(output, options);
367 }
368 diff_worktree_status(&probe.changes, options, None, false)
369}
370
371fn finalize_diff_report(mut output: DiffReport, options: &DiffOptions) -> Result<DiffReport> {
372 path_filter::apply_path_filters(&mut output, &options.paths)?;
373 if options.include_patch_text {
374 populate_patch_text(&mut output);
375 }
376 if options.stat {
377 output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
378 }
379 Ok(output)
380}
381
382fn populate_patch_text(output: &mut DiffReport) {
384 let text = render_diff_patch(output);
385 if !text.is_empty() {
386 output.patch = Some(text);
387 }
388}
389
390fn file_change_set_from_status(status: &WorktreeStatus) -> FileChangeSet {
391 let mut changes = FileChangeSet::with_capacity(status.change_count());
392 for path in &status.modified {
393 changes.push_modified(path.display().to_string());
394 }
395 for path in &status.added {
396 changes.push_added(path.display().to_string());
397 }
398 for path in &status.deleted {
399 changes.push_deleted(path.display().to_string());
400 }
401 changes
402}
403
404fn resolve_state_id(repository: &Repository, spec: &str) -> Result<StateId> {
405 resolve_state_for_command(repository, spec, ResolvePolicy::minimal())
406 .map(|resolved| resolved.state_id)
407 .map_err(|error| match error {
408 StateResolveError::Repository(err) => err.into(),
409 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
410 anyhow!(HeddleError::recovery(RecoveryDetails::state_not_found(
411 spec
412 )))
413 }
414 StateResolveError::Failure(other) => anyhow!("{other}"),
415 })
416}
417
418fn require_resolved_state(repo: &Repository, id: &StateId) -> Result<State> {
419 repo.store().get_state(id)?.ok_or_else(|| {
420 anyhow!(HeddleError::MissingObject {
421 object_type: "state".to_string(),
422 id: id.to_string_full(),
423 })
424 })
425}
426
427#[cfg(feature = "semantic")]
428fn run_semantic_diff(
429 repo: &Repository,
430 from_tree_hash: &objects::object::ContentHash,
431 to_tree_hash: &objects::object::ContentHash,
432) -> Result<SemanticDiffResult> {
433 let options = SemanticDiffOptions::default();
434 let result =
435 semantic::diff::semantic_diff(repo.store(), from_tree_hash, to_tree_hash, &options)?;
436 Ok(SemanticDiffResult {
437 changes: result.changes,
438 file_changes: result.file_changes,
439 })
440}
441
442#[cfg(not(feature = "semantic"))]
443fn run_semantic_diff(
444 _repo: &Repository,
445 _from_tree_hash: &objects::object::ContentHash,
446 _to_tree_hash: &objects::object::ContentHash,
447) -> Result<SemanticDiffResult> {
448 Err(anyhow!(HeddleError::recovery(
449 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
450 )))
451}
452
453#[cfg(feature = "semantic")]
454fn run_semantic_worktree_diff(
455 repo: &Repository,
456 from_tree_hash: &objects::object::ContentHash,
457 status_options: &repo::WorktreeStatusOptions,
458) -> Result<SemanticDiffResult> {
459 let from_tree = repo.require_tree(from_tree_hash)?;
460 let status = repo.compare_worktree_cached_with_options(&from_tree, status_options)?;
461 let status = SemanticWorktreeStatus {
462 modified: status.modified,
463 added: status.added,
464 deleted: status.deleted,
465 };
466 let options = SemanticDiffOptions::default();
467 let result = semantic::diff::semantic_diff_worktree(
468 repo.store(),
469 from_tree_hash,
470 repo.root(),
471 &status,
472 &options,
473 )?;
474 Ok(SemanticDiffResult {
475 changes: result.changes,
476 file_changes: result.file_changes,
477 })
478}
479
480#[cfg(not(feature = "semantic"))]
481fn run_semantic_worktree_diff(
482 _repo: &Repository,
483 _from_tree_hash: &objects::object::ContentHash,
484 _status_options: &repo::WorktreeStatusOptions,
485) -> Result<SemanticDiffResult> {
486 Err(anyhow!(HeddleError::recovery(
487 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
488 )))
489}
490
491fn sort_changes_by_path(mut changes: Vec<FileChange>) -> Vec<FileChange> {
500 changes.sort_by(|a, b| a.path.cmp(&b.path));
501 changes
502}
503fn plain_git_file_changes_with_hunks(
514 probe: &PlainGitDiffProbe,
515 unified: usize,
516) -> Result<Vec<FileChange>> {
517 let git_repo = SleyRepository::discover(&probe.root)?;
518 let head_has_tree = !git_repo.head()?.is_unborn();
519 let added_set: BTreeSet<&Path> = probe.changes.added.iter().map(PathBuf::as_path).collect();
527 let deleted_set: BTreeSet<&Path> = probe.changes.deleted.iter().map(PathBuf::as_path).collect();
528
529 let mut changes = Vec::with_capacity(probe.changes.change_count());
530 for path in &probe.changes.modified {
531 push_plain_git_modified(
532 &git_repo,
533 head_has_tree,
534 &probe.root,
535 path,
536 unified,
537 &mut changes,
538 )?;
539 }
540 for path in &probe.changes.added {
541 if deleted_set.contains(path.as_path()) {
542 push_plain_git_modified(
546 &git_repo,
547 head_has_tree,
548 &probe.root,
549 path,
550 unified,
551 &mut changes,
552 )?;
553 } else {
554 changes.push(plain_git_file_change(
555 &git_repo,
556 head_has_tree,
557 &probe.root,
558 path,
559 "added",
560 DiffKind::Added,
561 unified,
562 )?);
563 }
564 }
565 for path in &probe.changes.deleted {
566 if added_set.contains(path.as_path()) {
568 continue;
569 }
570 changes.push(plain_git_file_change(
571 &git_repo,
572 head_has_tree,
573 &probe.root,
574 path,
575 "deleted",
576 DiffKind::Deleted,
577 unified,
578 )?);
579 }
580 Ok(changes)
581}
582
583#[allow(clippy::too_many_arguments)]
584fn plain_git_file_change(
585 git_repo: &SleyRepository,
586 head_has_tree: bool,
587 root: &Path,
588 path: &std::path::Path,
589 kind: &str,
590 diff_kind: DiffKind,
591 unified: usize,
592) -> Result<FileChange> {
593 let (old_blob, old_mode) = match (head_has_tree, &diff_kind) {
594 (true, DiffKind::Modified | DiffKind::Deleted) => {
595 match plain_git_lookup_blob_and_mode(git_repo, path)? {
596 Some((blob, mode)) => (Some(blob), Some(mode)),
597 None => (None, None),
598 }
599 }
600 _ => (None, None),
601 };
602 let new_blob = match diff_kind {
603 DiffKind::Added | DiffKind::Modified => {
604 read_worktree_blob_for_diff(&root.join(path)).ok()
608 }
609 _ => None,
610 };
611 let (old_mode_field, mode) = match diff_kind {
616 DiffKind::Added => (None, worktree_file_mode(&root.join(path))),
617 DiffKind::Deleted => (None, old_mode),
618 DiffKind::Modified => (old_mode, worktree_file_mode(&root.join(path))),
619 DiffKind::Unchanged => (None, None),
620 };
621 let (lines, eol, binary) =
622 compute_plain_git_hunks(old_blob.as_ref(), new_blob.as_ref(), &diff_kind, unified);
623 let symlink = symlink_change_from_blobs(
624 kind,
625 old_blob.as_ref(),
626 old_mode_field,
627 new_blob.as_ref(),
628 mode,
629 );
630 Ok(FileChange {
631 path: path.display().to_string(),
632 kind: kind.to_string(),
633 binary: binary && symlink.is_none(),
634 lines,
635 eol,
636 mode,
637 old_mode: old_mode_field,
638 symlink,
639 ..Default::default()
640 })
641}
642
643fn plain_git_lookup_blob_and_mode(
644 git_repo: &SleyRepository,
645 path: &std::path::Path,
646) -> Result<Option<(Blob, FileMode)>> {
647 let tree_path = plain_git_tree_path(path);
648 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
649 return Ok(None);
650 };
651 let Some(entry_mode) = entry.mode else {
652 return Ok(None);
653 };
654 let mode = match EntryKind::from_mode(entry_mode) {
655 Some(EntryKind::Symlink) => FileMode::Symlink,
656 Some(EntryKind::BlobExecutable) => FileMode::Executable,
657 Some(EntryKind::Blob) => FileMode::Normal,
658 _ => return Ok(None),
659 };
660 let object = git_repo.read_object(&entry.oid)?;
661 Ok(Some((Blob::new(object.body.clone()), mode)))
662}
663
664fn plain_git_tree_path(path: &std::path::Path) -> String {
665 path.components()
666 .map(|component| component.as_os_str().to_string_lossy())
667 .collect::<Vec<_>>()
668 .join("/")
669}
670
671fn plain_git_old_side_kind(
677 git_repo: &SleyRepository,
678 head_has_tree: bool,
679 path: &std::path::Path,
680) -> Result<SideKind> {
681 if !head_has_tree {
682 return Ok(SideKind::Absent);
683 }
684 let tree_path = plain_git_tree_path(path);
685 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
686 return Ok(SideKind::Absent);
687 };
688 Ok(match entry.mode.and_then(EntryKind::from_mode) {
689 Some(EntryKind::Symlink) => SideKind::Symlink,
690 Some(EntryKind::Tree) => SideKind::Dir,
691 _ => SideKind::Regular,
692 })
693}
694
695fn push_plain_git_modified(
707 git_repo: &SleyRepository,
708 head_has_tree: bool,
709 root: &Path,
710 path: &std::path::Path,
711 unified: usize,
712 out: &mut Vec<FileChange>,
713) -> Result<()> {
714 let new_kind = worktree_side_kind(&root.join(path));
715 let old_kind = plain_git_old_side_kind(git_repo, head_has_tree, path)?;
716 if is_type_change(old_kind, new_kind) {
717 out.push(plain_git_file_change(
718 git_repo,
719 head_has_tree,
720 root,
721 path,
722 "deleted",
723 DiffKind::Deleted,
724 unified,
725 )?);
726 if new_kind != SideKind::Dir {
729 out.push(plain_git_file_change(
730 git_repo,
731 head_has_tree,
732 root,
733 path,
734 "added",
735 DiffKind::Added,
736 unified,
737 )?);
738 }
739 } else {
740 out.push(plain_git_file_change(
741 git_repo,
742 head_has_tree,
743 root,
744 path,
745 "modified",
746 DiffKind::Modified,
747 unified,
748 )?);
749 }
750 Ok(())
751}
752
753fn compute_plain_git_hunks(
754 old: Option<&Blob>,
755 new: Option<&Blob>,
756 diff_kind: &DiffKind,
757 unified: usize,
758) -> (Option<Vec<LineDiff>>, FileEolState, bool) {
759 let attempt = || -> Result<(Vec<LineDiff>, FileEolState)> {
760 match diff_kind {
761 DiffKind::Added => {
762 let Some(new) = new else {
763 return Ok((Vec::new(), FileEolState::default()));
764 };
765 ensure_text_diffable(new)?;
766 let eol = eol_for_added(new);
767 Ok((number_lines(blob_lines(new, "+")?), eol))
768 }
769 DiffKind::Deleted => {
770 let Some(old) = old else {
771 return Ok((Vec::new(), FileEolState::default()));
772 };
773 ensure_text_diffable(old)?;
774 let eol = eol_for_deleted(old);
775 Ok((number_lines(blob_lines(old, "-")?), eol))
776 }
777 DiffKind::Modified => match (old, new) {
778 (Some(old), Some(new)) => modified_blob_hunks(old, new),
779 (None, Some(new)) => {
780 ensure_text_diffable(new)?;
781 let eol = eol_for_added(new);
782 Ok((number_lines(blob_lines(new, "+")?), eol))
783 }
784 (Some(old), None) => {
785 ensure_text_diffable(old)?;
786 let eol = eol_for_deleted(old);
787 Ok((number_lines(blob_lines(old, "-")?), eol))
788 }
789 (None, None) => Ok((Vec::new(), FileEolState::default())),
790 },
791 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
792 }
793 };
794 match attempt() {
795 Ok((lines, eol)) => (Some(unified_hunks(lines, unified, &eol)), eol, false),
796 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
797 Err(_) => (None, FileEolState::default(), false),
798 }
799}
800fn file_changes_from_status(
805 status: &objects::worktree::WorktreeStatus,
806 want_hunks: bool,
807 repo: Option<&Repository>,
808 from_tree: Option<&Tree>,
809 unified: usize,
810) -> Vec<FileChange> {
811 let mut changes = Vec::with_capacity(status.change_count());
812 for path in &status.modified {
813 changes.push(make_status_file_change(
814 path,
815 "modified",
816 DiffKind::Modified,
817 want_hunks,
818 repo,
819 from_tree,
820 unified,
821 ));
822 }
823 for path in &status.added {
824 changes.push(make_status_file_change(
825 path,
826 "added",
827 DiffKind::Added,
828 want_hunks,
829 repo,
830 from_tree,
831 unified,
832 ));
833 }
834 for path in &status.deleted {
835 changes.push(make_status_file_change(
836 path,
837 "deleted",
838 DiffKind::Deleted,
839 want_hunks,
840 repo,
841 from_tree,
842 unified,
843 ));
844 }
845 changes
846}
847
848#[allow(clippy::too_many_arguments)]
849fn make_status_file_change(
850 path: &std::path::Path,
851 kind: &str,
852 diff_kind: DiffKind,
853 want_hunks: bool,
854 repo: Option<&Repository>,
855 from_tree: Option<&Tree>,
856 unified: usize,
857) -> FileChange {
858 let path_str = path.display().to_string();
859 let (kind, diff_kind) = match repo
863 .and_then(|repo| worktree_modified_type_change(repo.root(), &path_str, diff_kind))
864 {
865 Some(reclassified) => reclassified,
866 None => (kind, diff_kind),
867 };
868 match repo {
869 Some(repo) if want_hunks => {
870 build_worktree_change(repo, from_tree, &path_str, kind, diff_kind, unified)
871 }
872 _ => make_status_only_change(repo, from_tree, None, &path_str, kind),
873 }
874}
875
876fn make_status_only_change(
890 repo: Option<&Repository>,
891 from_tree: Option<&Tree>,
892 to_tree: Option<&Tree>,
893 path_str: &str,
894 kind: &str,
895) -> FileChange {
896 let (old_mode, mode) = match repo {
897 Some(repo) => change_file_modes(repo, from_tree, to_tree, path_str, kind),
898 None => (None, None),
899 };
900 FileChange {
901 path: path_str.to_string(),
902 kind: kind.to_string(),
903 mode,
904 old_mode,
905 ..Default::default()
906 }
907}
908
909fn build_worktree_change(
914 repo: &Repository,
915 from_tree: Option<&Tree>,
916 path_str: &str,
917 kind: &str,
918 diff_kind: DiffKind,
919 unified: usize,
920) -> FileChange {
921 let (old_mode, mode) = change_file_modes(repo, from_tree, None, path_str, kind);
922 let (lines, eol, binary) = match get_worktree_diff(repo, from_tree, path_str, &diff_kind) {
923 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
924 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
925 Err(_) => (None, FileEolState::default(), false),
930 };
931 let symlink = symlink_change_for_paths(
932 repo, from_tree, None, kind, path_str, path_str, old_mode, mode,
933 );
934 FileChange {
935 path: path_str.to_string(),
936 kind: kind.to_string(),
937 binary: binary && symlink.is_none(),
938 lines,
939 eol,
940 mode,
941 old_mode,
942 symlink,
943 ..Default::default()
944 }
945}
946
947#[derive(Clone, Copy, PartialEq, Eq, Debug)]
949enum SideKind {
950 Absent,
951 Dir,
952 Regular,
954 Symlink,
955}
956
957fn tree_side_kind(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<SideKind> {
962 let Some(tree) = tree else {
963 return Ok(SideKind::Absent);
964 };
965 if let Some(entry) = find_entry_in_tree(repo, tree, path)? {
966 return Ok(if entry.entry_type() == EntryType::Symlink {
967 SideKind::Symlink
968 } else {
969 SideKind::Regular
970 });
971 }
972 if dir_subtree_in_tree(repo, tree, path)?.is_some() {
973 Ok(SideKind::Dir)
974 } else {
975 Ok(SideKind::Absent)
976 }
977}
978
979fn new_side_kind(repo: &Repository, to_tree: Option<&Tree>, path: &str) -> Result<SideKind> {
982 match to_tree {
983 Some(tree) => tree_side_kind(repo, Some(tree), path),
984 None => Ok(worktree_side_kind(&repo.root().join(path))),
985 }
986}
987
988fn worktree_side_kind(path: &Path) -> SideKind {
992 let Ok(meta) = std::fs::symlink_metadata(path) else {
993 return SideKind::Absent;
994 };
995 if meta.file_type().is_symlink() {
996 SideKind::Symlink
997 } else if meta.is_dir() {
998 SideKind::Dir
999 } else {
1000 SideKind::Regular
1001 }
1002}
1003
1004fn is_type_change(old: SideKind, new: SideKind) -> bool {
1007 use SideKind::{Dir, Regular, Symlink};
1008 matches!(
1009 (old, new),
1010 (Dir, Regular)
1011 | (Dir, Symlink)
1012 | (Regular, Dir)
1013 | (Symlink, Dir)
1014 | (Regular, Symlink)
1015 | (Symlink, Regular)
1016 )
1017}
1018
1019fn expand_type_changes(
1046 repo: &Repository,
1047 from_tree: Option<&Tree>,
1048 to_tree: Option<&Tree>,
1049 changes: Vec<FileChange>,
1050 want_hunks: bool,
1051 unified: usize,
1052) -> Result<Vec<FileChange>> {
1053 let mut output = Vec::with_capacity(changes.len());
1054 for change in changes {
1055 if change.kind != "modified" {
1056 output.push(change);
1057 continue;
1058 }
1059 let old_kind = tree_side_kind(repo, from_tree, &change.path)?;
1060 let new_kind = new_side_kind(repo, to_tree, &change.path)?;
1061 if !is_type_change(old_kind, new_kind) {
1062 output.push(change);
1063 continue;
1064 }
1065
1066 if old_kind == SideKind::Dir {
1069 if let Some(from_tree) = from_tree
1070 && let Some(subtree) = dir_subtree_in_tree(repo, from_tree, &change.path)?
1071 {
1072 let mut nested = Vec::new();
1073 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1074 for nested_path in nested {
1075 output.push(make_type_change_part(
1076 repo,
1077 Some(from_tree),
1078 to_tree,
1079 &nested_path,
1080 DiffKind::Deleted,
1081 want_hunks,
1082 unified,
1083 ));
1084 }
1085 }
1086 } else {
1087 output.push(make_type_change_part(
1088 repo,
1089 from_tree,
1090 to_tree,
1091 &change.path,
1092 DiffKind::Deleted,
1093 want_hunks,
1094 unified,
1095 ));
1096 }
1097
1098 if new_kind == SideKind::Dir {
1103 if let Some(to_tree) = to_tree
1104 && let Some(subtree) = dir_subtree_in_tree(repo, to_tree, &change.path)?
1105 {
1106 let mut nested = Vec::new();
1107 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1108 for nested_path in nested {
1109 output.push(make_type_change_part(
1110 repo,
1111 from_tree,
1112 Some(to_tree),
1113 &nested_path,
1114 DiffKind::Added,
1115 want_hunks,
1116 unified,
1117 ));
1118 }
1119 }
1120 } else {
1121 output.push(make_type_change_part(
1122 repo,
1123 from_tree,
1124 to_tree,
1125 &change.path,
1126 DiffKind::Added,
1127 want_hunks,
1128 unified,
1129 ));
1130 }
1131 }
1132 Ok(output)
1133}
1134
1135fn make_type_change_part(
1136 repo: &Repository,
1137 from_tree: Option<&Tree>,
1138 to_tree: Option<&Tree>,
1139 path_str: &str,
1140 diff_kind: DiffKind,
1141 want_hunks: bool,
1142 unified: usize,
1143) -> FileChange {
1144 let kind = diff_kind.to_string();
1145 if !want_hunks {
1146 return make_status_only_change(Some(repo), from_tree, to_tree, path_str, &kind);
1147 }
1148 match to_tree {
1149 Some(to_tree) => build_state_change(
1150 repo, from_tree, to_tree, path_str, &kind, diff_kind, unified,
1151 ),
1152 None => build_worktree_change(repo, from_tree, path_str, &kind, diff_kind, unified),
1153 }
1154}
1155
1156fn build_state_change(
1160 repo: &Repository,
1161 from_tree: Option<&Tree>,
1162 to_tree: &Tree,
1163 path_str: &str,
1164 kind: &str,
1165 diff_kind: DiffKind,
1166 unified: usize,
1167) -> FileChange {
1168 let (old_mode, mode) = change_file_modes(repo, from_tree, Some(to_tree), path_str, kind);
1169 let (lines, eol, binary) = match get_state_diff(repo, from_tree, to_tree, path_str, &diff_kind)
1170 {
1171 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1172 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1173 Err(_) => (None, FileEolState::default(), false),
1174 };
1175 let symlink = symlink_change_for_paths(
1176 repo,
1177 from_tree,
1178 Some(to_tree),
1179 kind,
1180 path_str,
1181 path_str,
1182 old_mode,
1183 mode,
1184 );
1185 FileChange {
1186 path: path_str.to_string(),
1187 kind: kind.to_string(),
1188 binary: binary && symlink.is_none(),
1189 lines,
1190 eol,
1191 mode,
1192 old_mode,
1193 symlink,
1194 ..Default::default()
1195 }
1196}
1197
1198fn dir_subtree_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Tree>> {
1202 let mut current = tree.clone();
1203 let mut parts = path.split('/').peekable();
1204 while let Some(name) = parts.next() {
1205 let Some(entry) = current.get(name) else {
1206 return Ok(None);
1207 };
1208 if !entry.is_tree() {
1209 return Ok(None);
1210 }
1211 let Some(hash) = entry.tree_hash() else {
1212 return Ok(None);
1213 };
1214 let Some(subtree) = repo.store().get_tree(&hash)? else {
1215 return Ok(None);
1216 };
1217 if parts.peek().is_none() {
1218 return Ok(Some(subtree));
1219 }
1220 current = subtree;
1221 }
1222 Ok(None)
1223}
1224
1225fn collect_subtree_blob_paths(
1228 repo: &Repository,
1229 subtree: &Tree,
1230 prefix: &str,
1231 out: &mut Vec<String>,
1232) -> Result<()> {
1233 for entry in subtree.entries() {
1234 let child_path = format!("{prefix}/{}", entry.name());
1235 if entry.is_tree() {
1236 if let Some(hash) = entry.tree_hash()
1237 && let Some(nested) = repo.store().get_tree(&hash)?
1238 {
1239 collect_subtree_blob_paths(repo, &nested, &child_path, out)?;
1240 }
1241 } else {
1242 out.push(child_path);
1243 }
1244 }
1245 Ok(())
1246}
1247
1248fn head_from_tree(repo: &Repository) -> Result<Option<Tree>> {
1249 let Some(head_id) = repo.head()? else {
1250 return Ok(None);
1251 };
1252 let Some(state) = repo.store().get_state(&head_id)? else {
1253 return Ok(None);
1254 };
1255 Ok(repo.store().get_tree(&state.tree)?)
1256}
1257
1258pub fn compute_state_diff(
1273 repo: &Repository,
1274 from_state_id: &StateId,
1275 to_state_id: &StateId,
1276 semantic: bool,
1277 unified: usize,
1278) -> Result<DiffReport> {
1279 let from_state = repo.store().get_state(from_state_id)?;
1280 let from_tree = if let Some(ref state) = from_state {
1281 repo.store().get_tree(&state.tree)?
1282 } else {
1283 None
1284 };
1285
1286 let to_state = require_resolved_state(repo, to_state_id)?;
1287 let to_tree = repo
1288 .store()
1289 .get_tree(&to_state.tree)?
1290 .ok_or_else(|| anyhow!("Tree not found for state {}", to_state_id.short()))?;
1291
1292 let from_hash = from_state
1293 .as_ref()
1294 .map(|s| s.tree)
1295 .unwrap_or_else(|| Tree::new().hash());
1296
1297 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1298 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
1299 } else {
1300 None
1301 };
1302
1303 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1304 result.file_changes.clone()
1305 } else {
1306 repo.diff_trees(&from_hash, &to_state.tree)?
1307 };
1308
1309 let file_changes: Vec<FileChange> = changes
1310 .iter()
1311 .map(|change| {
1312 build_state_change(
1313 repo,
1314 from_tree.as_ref(),
1315 &to_tree,
1316 &change.path,
1317 &change.kind.to_string(),
1318 change.kind,
1319 unified,
1320 )
1321 })
1322 .collect();
1323 let file_changes = sort_changes_by_path(file_changes);
1324 let file_changes = expand_type_changes(
1325 repo,
1326 from_tree.as_ref(),
1327 Some(&to_tree),
1328 file_changes,
1329 true,
1330 unified,
1331 )?;
1332 let file_changes = detect_clear_renames(
1333 repo,
1334 from_tree.as_ref(),
1335 Some(&to_tree),
1336 file_changes,
1337 true,
1338 unified,
1339 )?;
1340
1341 let semantic_changes = semantic_diff_result.map(|r| {
1342 r.changes
1343 .into_iter()
1344 .map(SemanticChangeEntry::from)
1345 .collect()
1346 });
1347
1348 let mut output = DiffReport::new(
1349 Some(from_state_id.short()),
1350 Some(to_state_id.short()),
1351 file_changes,
1352 semantic_changes,
1353 None,
1354 None,
1355 );
1356 populate_patch_text(&mut output);
1357 Ok(output)
1358}
1359
1360pub fn compute_tree_diff(
1367 repo: &Repository,
1368 from_state_id: &StateId,
1369 to_tree: &Tree,
1370 to_label: impl Into<String>,
1371 semantic: bool,
1372 unified: usize,
1373) -> Result<DiffReport> {
1374 let from_state = repo.store().get_state(from_state_id)?;
1375 let from_tree = if let Some(ref state) = from_state {
1376 repo.store().get_tree(&state.tree)?
1377 } else {
1378 None
1379 };
1380 let from_hash = from_state
1381 .as_ref()
1382 .map(|s| s.tree)
1383 .unwrap_or_else(|| Tree::new().hash());
1384
1385 let to_hash = repo.store().put_tree(to_tree)?;
1386
1387 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1388 Some(run_semantic_diff(repo, &from_hash, &to_hash)?)
1389 } else {
1390 None
1391 };
1392
1393 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1394 result.file_changes.clone()
1395 } else {
1396 repo.diff_trees(&from_hash, &to_hash)?
1397 };
1398
1399 let file_changes: Vec<FileChange> = changes
1400 .iter()
1401 .map(|change| {
1402 build_state_change(
1403 repo,
1404 from_tree.as_ref(),
1405 to_tree,
1406 &change.path,
1407 &change.kind.to_string(),
1408 change.kind,
1409 unified,
1410 )
1411 })
1412 .collect();
1413 let file_changes = sort_changes_by_path(file_changes);
1414 let file_changes = expand_type_changes(
1415 repo,
1416 from_tree.as_ref(),
1417 Some(to_tree),
1418 file_changes,
1419 true,
1420 unified,
1421 )?;
1422 let file_changes = detect_clear_renames(
1423 repo,
1424 from_tree.as_ref(),
1425 Some(to_tree),
1426 file_changes,
1427 true,
1428 unified,
1429 )?;
1430
1431 let semantic_changes = semantic_diff_result.map(|r| {
1432 r.changes
1433 .into_iter()
1434 .map(SemanticChangeEntry::from)
1435 .collect()
1436 });
1437
1438 let mut output = DiffReport::new(
1439 Some(from_state_id.short()),
1440 Some(to_label.into()),
1441 file_changes,
1442 semantic_changes,
1443 None,
1444 None,
1445 );
1446 populate_patch_text(&mut output);
1447 Ok(output)
1448}
1449
1450fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
1451 changes
1452 .into_iter()
1453 .map(|mut change| {
1454 change.lines = None;
1455 change
1456 })
1457 .collect()
1458}
1459
1460fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
1461 if lines.is_empty() {
1462 return lines;
1463 }
1464 if !lines.iter().any(|line| line.prefix != " ") {
1465 if eol.old_has_final_newline == eol.new_has_final_newline {
1473 return lines;
1474 }
1475 return eol_only_tail_hunk(lines, context);
1476 }
1477
1478 let mut ranges = Vec::<(usize, usize)>::new();
1479 let mut cursor = 0usize;
1480 while cursor < lines.len() {
1481 while cursor < lines.len() && lines[cursor].prefix == " " {
1482 cursor += 1;
1483 }
1484 if cursor >= lines.len() {
1485 break;
1486 }
1487
1488 let start = cursor.saturating_sub(context);
1489 while cursor < lines.len() && lines[cursor].prefix != " " {
1490 cursor += 1;
1491 }
1492 let mut end = (cursor + context).min(lines.len());
1493
1494 while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
1495 cursor += 1;
1496 }
1497 while cursor < lines.len() && lines[cursor].prefix != " " {
1498 end = (cursor + 1 + context).min(lines.len());
1499 cursor += 1;
1500 }
1501
1502 if let Some((_, previous_end)) = ranges.last_mut()
1503 && start <= *previous_end
1504 {
1505 *previous_end = end;
1506 continue;
1507 }
1508 ranges.push((start, end));
1509 }
1510
1511 let mut output = Vec::new();
1512 for (start, end) in ranges {
1513 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1514 output.push(LineDiff {
1515 prefix: "@".to_string(),
1516 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1517 old_line: None,
1518 new_line: None,
1519 });
1520 output.extend_from_slice(&lines[start..end]);
1528 }
1529 output
1530}
1531
1532fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
1539 let end = lines.len();
1540 let start = end.saturating_sub(context + 1);
1541 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1542 let mut output = Vec::with_capacity(end - start + 1);
1543 output.push(LineDiff {
1544 prefix: "@".to_string(),
1545 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1546 old_line: None,
1547 new_line: None,
1548 });
1549 output.extend_from_slice(&lines[start..end]);
1550 output
1551}
1552
1553pub fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
1568 let mut output = Vec::with_capacity(lines.len());
1569 let mut body_start = 0usize;
1570 for (index, line) in lines.iter().enumerate() {
1571 if line.prefix == "@" {
1572 if body_start < index {
1573 output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
1574 }
1575 output.push(line.clone());
1576 body_start = index + 1;
1577 }
1578 }
1579 if body_start < lines.len() {
1580 output.extend(trim_trailing_added_decorations(&lines[body_start..]));
1581 }
1582 output
1583}
1584
1585fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
1586 let mut trimmed = Vec::with_capacity(lines.len());
1587 let mut index = 0usize;
1588 while index < lines.len() {
1589 if lines[index].prefix == "+"
1590 && is_visual_decoration_line(&lines[index].content)
1591 && let Some(next_context) = next_context_line(lines, index + 1)
1592 && next_context.content == lines[index].content
1593 {
1594 let added_block_has_code = lines[index + 1..next_context.index]
1595 .iter()
1596 .any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
1597 if added_block_has_code {
1598 index += 1;
1599 continue;
1600 }
1601 }
1602 trimmed.push(lines[index].clone());
1603 index += 1;
1604 }
1605 trimmed
1606}
1607
1608struct IndexedLine<'a> {
1609 index: usize,
1610 content: &'a str,
1611}
1612
1613fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
1614 lines[start..]
1615 .iter()
1616 .enumerate()
1617 .find(|(_, line)| line.prefix == " ")
1618 .map(|(offset, line)| IndexedLine {
1619 index: start + offset,
1620 content: &line.content,
1621 })
1622}
1623
1624fn is_blank_or_visual_decoration(line: &str) -> bool {
1625 line.trim().is_empty() || is_visual_decoration_line(line)
1626}
1627
1628fn is_visual_decoration_line(line: &str) -> bool {
1629 let trimmed = line.trim_start();
1630 trimmed.starts_with("#[")
1631 || trimmed.starts_with("#![")
1632 || trimmed.starts_with('@')
1633 || trimmed.starts_with("///")
1634 || trimmed.starts_with("//!")
1635}
1636
1637fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
1638 let old_before = lines[..start]
1639 .iter()
1640 .filter(|line| line.prefix != "+")
1641 .count();
1642 let new_before = lines[..start]
1643 .iter()
1644 .filter(|line| line.prefix != "-")
1645 .count();
1646 let old_len = lines[start..end]
1647 .iter()
1648 .filter(|line| line.prefix != "+")
1649 .count();
1650 let new_len = lines[start..end]
1651 .iter()
1652 .filter(|line| line.prefix != "-")
1653 .count();
1654
1655 let old_start = if old_len == 0 {
1656 old_before
1657 } else {
1658 old_before + 1
1659 };
1660 let new_start = if new_len == 0 {
1661 new_before
1662 } else {
1663 new_before + 1
1664 };
1665 (old_start, old_len, new_start, new_len)
1666}
1667
1668fn collect_file_context(
1669 repo: &Repository,
1670 state: &State,
1671 changes: &FileChangeSet,
1672) -> Result<Vec<FileContextEntry>> {
1673 let Some(context_root) = repo.inherit_parent_context(state)? else {
1674 return Ok(Vec::new());
1675 };
1676
1677 let mut entries = Vec::new();
1678 for change in changes {
1679 let target = ContextTarget::file(change.path.clone())?;
1680 let Some(blob) = repo.get_context_blob(&context_root, &target)? else {
1681 continue;
1682 };
1683 let annotations = blob
1684 .annotations
1685 .iter()
1686 .filter(|annotation| annotation.status == AnnotationStatus::Active)
1687 .filter_map(|annotation| {
1688 annotation
1689 .current_revision()
1690 .map(|revision| ContextSnippet {
1691 annotation_id: annotation.annotation_id.clone(),
1692 kind: revision.kind.to_string(),
1693 content: summarize_context(&revision.content),
1694 revision_count: annotation.revisions.len(),
1695 })
1696 })
1697 .collect::<Vec<_>>();
1698 if !annotations.is_empty() {
1699 entries.push(FileContextEntry {
1700 path: change.path.clone(),
1701 annotations,
1702 });
1703 }
1704 }
1705 Ok(entries)
1706}
1707
1708fn collect_state_guidance(repo: &Repository, state: &State) -> Result<Vec<ContextSnippet>> {
1709 let Some(context_root) = repo.inherit_parent_context(state)? else {
1710 return Ok(Vec::new());
1711 };
1712 let target = ContextTarget::state(state.state_id);
1713 let Some(blob) = repo.get_context_blob(&context_root, &target)? else {
1714 return Ok(Vec::new());
1715 };
1716 Ok(blob
1717 .annotations
1718 .iter()
1719 .filter(|annotation| annotation.status == AnnotationStatus::Active)
1720 .filter_map(|annotation| {
1721 annotation
1722 .current_revision()
1723 .map(|revision| ContextSnippet {
1724 annotation_id: annotation.annotation_id.clone(),
1725 kind: revision.kind.to_string(),
1726 content: summarize_context(&revision.content),
1727 revision_count: annotation.revisions.len(),
1728 })
1729 })
1730 .collect())
1731}
1732
1733fn summarize_context(content: &str) -> String {
1734 let first_line = content
1735 .lines()
1736 .find(|line| !line.trim().is_empty())
1737 .unwrap_or("");
1738 let char_count = first_line.chars().count();
1739 if char_count <= 88 {
1740 first_line.to_string()
1741 } else {
1742 format!("{}...", first_line.chars().take(85).collect::<String>())
1743 }
1744}
1745
1746fn get_worktree_diff(
1747 repo: &Repository,
1748 from_tree: Option<&Tree>,
1749 path: &str,
1750 kind: &DiffKind,
1751) -> Result<(Vec<LineDiff>, FileEolState)> {
1752 let worktree_path = repo.root().join(path);
1753
1754 match kind {
1755 DiffKind::Added => {
1756 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1757 let eol = eol_for_added(&new_blob);
1758 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1759 }
1760 DiffKind::Deleted => {
1761 if let Some(tree) = from_tree
1765 && let Some(blob) = find_blob_in_tree(repo, tree, path)?
1766 {
1767 let eol = eol_for_deleted(&blob);
1768 return Ok((number_lines(blob_lines(&blob, "-")?), eol));
1769 }
1770 Ok((vec![], FileEolState::default()))
1771 }
1772 DiffKind::Modified => {
1773 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1774
1775 if let Some(tree) = from_tree
1776 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
1777 {
1778 return modified_blob_hunks(&old_blob, &new_blob);
1779 }
1780
1781 let eol = eol_for_added(&new_blob);
1782 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1783 }
1784 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
1785 }
1786}
1787
1788fn worktree_modified_type_change(
1804 repo_root: &Path,
1805 path: &str,
1806 diff_kind: DiffKind,
1807) -> Option<(&'static str, DiffKind)> {
1808 if matches!(diff_kind, DiffKind::Modified)
1809 && worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
1810 {
1811 Some(("deleted", DiffKind::Deleted))
1812 } else {
1813 None
1814 }
1815}
1816
1817fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
1818 let metadata = std::fs::symlink_metadata(path)?;
1819 if metadata.file_type().is_symlink() {
1820 let target = std::fs::read_link(path)?;
1821 return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
1822 }
1823 Ok(Blob::new(std::fs::read(path)?))
1824}
1825
1826fn is_symlink_mode(mode: Option<FileMode>) -> bool {
1827 matches!(mode, Some(FileMode::Symlink))
1828}
1829
1830fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
1838 match kind {
1839 "added" => (false, is_symlink_mode(mode)),
1840 "deleted" => (is_symlink_mode(mode), false),
1841 _ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
1842 }
1843}
1844
1845fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
1855 (old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
1856}
1857
1858fn symlink_change_from_blobs(
1862 kind: &str,
1863 old_blob: Option<&Blob>,
1864 old_mode: Option<FileMode>,
1865 new_blob: Option<&Blob>,
1866 mode: Option<FileMode>,
1867) -> Option<SymlinkChange> {
1868 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1869 let old = old_is_link
1870 .then(|| old_blob.map(|blob| blob.content().to_vec()))
1871 .flatten();
1872 let new = new_is_link
1873 .then(|| new_blob.map(|blob| blob.content().to_vec()))
1874 .flatten();
1875 make_symlink_change(old, new)
1876}
1877
1878#[allow(clippy::too_many_arguments)]
1885fn symlink_change_for_paths(
1886 repo: &Repository,
1887 from_tree: Option<&Tree>,
1888 to_tree: Option<&Tree>,
1889 kind: &str,
1890 old_path: &str,
1891 new_path: &str,
1892 old_mode: Option<FileMode>,
1893 mode: Option<FileMode>,
1894) -> Option<SymlinkChange> {
1895 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1896 let old = old_is_link
1897 .then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
1898 .flatten()
1899 .map(|blob| blob.content().to_vec());
1900 let new = new_is_link
1901 .then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
1902 .flatten()
1903 .map(|blob| blob.content().to_vec());
1904 make_symlink_change(old, new)
1905}
1906fn detect_clear_renames(
1907 repo: &Repository,
1908 from_tree: Option<&Tree>,
1909 to_tree: Option<&Tree>,
1910 changes: Vec<FileChange>,
1911 include_lines: bool,
1912 unified: usize,
1913) -> Result<Vec<FileChange>> {
1914 detect_clear_renames_with_stats(
1915 repo,
1916 from_tree,
1917 to_tree,
1918 changes,
1919 include_lines,
1920 unified,
1921 &mut RenameDetectionStats::default(),
1922 )
1923}
1924
1925#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1926struct RenameDetectionStats {
1927 blob_reads: usize,
1928 lcs_comparisons: usize,
1929 total_possible_pairs: usize,
1930 qualifying_candidate_pairs: usize,
1931}
1932
1933struct PreparedRenameBlob {
1934 blob: Blob,
1935 content_hash: ContentHash,
1936 text: Option<RenameTextFingerprint>,
1937}
1938
1939struct RenameTextFingerprint {
1940 line_count: usize,
1941 line_hash_counts: BTreeMap<u64, usize>,
1942}
1943
1944#[allow(clippy::too_many_arguments)]
1945fn detect_clear_renames_with_stats(
1946 repo: &Repository,
1947 from_tree: Option<&Tree>,
1948 to_tree: Option<&Tree>,
1949 changes: Vec<FileChange>,
1950 include_lines: bool,
1951 unified: usize,
1952 stats: &mut RenameDetectionStats,
1953) -> Result<Vec<FileChange>> {
1954 let mut deleted = changes
1955 .iter()
1956 .filter(|change| change.kind == "deleted")
1957 .map(|change| change.path.as_str())
1958 .collect::<Vec<_>>();
1959 let mut added = changes
1960 .iter()
1961 .filter(|change| change.kind == "added")
1962 .map(|change| change.path.as_str())
1963 .collect::<Vec<_>>();
1964 deleted.sort_unstable();
1965 added.sort_unstable();
1966 if deleted.is_empty() || added.is_empty() {
1967 return Ok(changes);
1968 }
1969 stats.total_possible_pairs = deleted.len().saturating_mul(added.len());
1970
1971 let deleted_side_modes = changes
1981 .iter()
1982 .filter(|change| change.kind == "deleted")
1983 .map(|change| (change.path.as_str(), change.mode))
1984 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
1985 let added_side_modes = changes
1986 .iter()
1987 .filter(|change| change.kind == "added")
1988 .map(|change| (change.path.as_str(), change.mode))
1989 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
1990
1991 let mut added_blobs = BTreeMap::new();
1992 for new_path in &added {
1993 stats.blob_reads += 1;
1994 if let Some(blob) = new_blob_for_rename(repo, to_tree, new_path)? {
1995 added_blobs.insert(*new_path, prepare_rename_blob(blob));
1996 }
1997 }
1998
1999 let mut candidates = RenameCandidateIndex::new(deleted.len(), added.len());
2000 for (old_index, old_path) in deleted.iter().enumerate() {
2001 stats.blob_reads += 1;
2002 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2003 continue;
2004 };
2005 let old_blob = prepare_rename_blob(old_blob);
2006 for (new_index, new_path) in added.iter().enumerate() {
2007 if old_path == new_path {
2012 continue;
2013 }
2014 if !rename_mode_compatible(
2021 deleted_side_modes.get(old_path).copied().flatten(),
2022 added_side_modes.get(new_path).copied().flatten(),
2023 ) {
2024 continue;
2025 }
2026 let Some(new_blob) = added_blobs.get(new_path) else {
2027 continue;
2028 };
2029 let score = rename_similarity(&old_blob, new_blob, stats);
2030 if score >= RENAME_SIMILARITY_THRESHOLD {
2031 candidates.push(old_index, new_index, score);
2032 }
2033 }
2034 }
2035 stats.qualifying_candidate_pairs = candidates.candidate_count();
2036
2037 let renames = candidates
2038 .assign()
2039 .into_iter()
2040 .map(|assignment| {
2041 (
2042 deleted[assignment.source_index].to_string(),
2043 added[assignment.target_index].to_string(),
2044 assignment.score,
2045 )
2046 })
2047 .collect::<Vec<_>>();
2048 if renames.is_empty() {
2049 return Ok(changes);
2050 }
2051
2052 let rename_by_new = renames
2053 .iter()
2054 .map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
2055 .collect::<std::collections::BTreeMap<_, _>>();
2056 let removed_old = renames
2057 .iter()
2058 .map(|(old_path, _, _)| old_path.as_str())
2059 .collect::<BTreeSet<_>>();
2060 let deleted_modes = changes
2066 .iter()
2067 .filter(|change| change.kind == "deleted")
2068 .map(|change| (change.path.clone(), change.mode))
2069 .collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
2070
2071 let mut output = Vec::with_capacity(changes.len() - renames.len());
2072 for mut change in changes {
2073 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2074 continue;
2075 }
2076 if change.kind == "added"
2077 && let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
2078 {
2079 let (lines, eol) = if include_lines {
2080 match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
2081 Ok(Some((lines, eol))) => (Some(lines), eol),
2082 Ok(None) => (None, FileEolState::default()),
2083 Err(error) if is_binary_diff_error(&error) => {
2084 change.binary = true;
2085 (None, FileEolState::default())
2086 }
2087 Err(error) => return Err(error),
2088 }
2089 } else {
2090 (None, FileEolState::default())
2091 };
2092 change.kind = "renamed".to_string();
2093 change.old_path = Some(old_path.to_string());
2094 change.similarity_score = Some(score);
2095 change.lines = lines;
2096 change.eol = eol;
2097 change.old_mode = deleted_modes.get(old_path).copied().flatten();
2101 change.symlink = symlink_change_for_paths(
2108 repo,
2109 from_tree,
2110 to_tree,
2111 "renamed",
2112 old_path,
2113 &change.path,
2114 change.old_mode,
2115 change.mode,
2116 );
2117 if change.symlink.is_some() {
2118 change.binary = false;
2119 }
2120 change.line_counts = None;
2127 }
2128 output.push(change);
2129 }
2130 Ok(output)
2131}
2132
2133fn rename_lines(
2134 repo: &Repository,
2135 from_tree: Option<&Tree>,
2136 to_tree: Option<&Tree>,
2137 old_path: &str,
2138 new_path: &str,
2139 unified: usize,
2140) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
2141 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2142 return Ok(None);
2143 };
2144 let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
2145 return Ok(None);
2146 };
2147 ensure_text_diffable(&old_blob)?;
2148 ensure_text_diffable(&new_blob)?;
2149 let eol = eol_for_modified(&old_blob, &new_blob);
2150 let diff = diff_blobs(&old_blob, &new_blob);
2151 let lines = diff
2152 .iter()
2153 .map(|line| LineDiff::new(line.prefix(), line.content()))
2154 .collect();
2155 Ok(Some((
2156 unified_hunks(number_lines(lines), unified, &eol),
2157 eol,
2158 )))
2159}
2160
2161fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
2162 let Some(tree) = tree else {
2163 return Ok(None);
2164 };
2165 find_blob_in_tree(repo, tree, path)
2166}
2167
2168fn new_blob_for_rename(
2169 repo: &Repository,
2170 to_tree: Option<&Tree>,
2171 path: &str,
2172) -> Result<Option<Blob>> {
2173 if let Some(tree) = to_tree {
2174 return find_blob_in_tree(repo, tree, path);
2175 }
2176
2177 let worktree_path = repo.root().join(path);
2185 match std::fs::symlink_metadata(&worktree_path) {
2186 Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
2187 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2188 Err(error) => Err(error.into()),
2189 }
2190}
2191
2192fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
2202 let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
2203 is_symlink(old) == is_symlink(new)
2204}
2205
2206fn prepare_rename_blob(blob: Blob) -> PreparedRenameBlob {
2207 let content_hash = blob.hash();
2208 let text = blob.content_str().and_then(|text| {
2209 if text.chars().any(is_terminal_hostile_control) {
2210 return None;
2211 }
2212 let mut line_count = 0;
2213 let mut line_hash_counts = BTreeMap::new();
2214 for line in text.lines() {
2215 line_count += 1;
2216 *line_hash_counts.entry(cheap_line_hash(line)).or_insert(0) += 1;
2217 }
2218 Some(RenameTextFingerprint {
2219 line_count,
2220 line_hash_counts,
2221 })
2222 });
2223 PreparedRenameBlob {
2224 blob,
2225 content_hash,
2226 text,
2227 }
2228}
2229
2230fn cheap_line_hash(line: &str) -> u64 {
2231 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
2232 const FNV_PRIME: u64 = 0x100000001b3;
2233 line.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
2234 (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
2235 })
2236}
2237
2238fn can_reach_rename_threshold(
2239 old_text: &RenameTextFingerprint,
2240 new_text: &RenameTextFingerprint,
2241) -> bool {
2242 let total_lines = old_text.line_count + new_text.line_count;
2243 if old_text.line_count == 0 || new_text.line_count == 0 {
2244 return false;
2245 }
2246
2247 let length_upper_bound = old_text.line_count.min(new_text.line_count);
2248 if (length_upper_bound as u128) * 8 < (total_lines as u128) * 3 {
2249 return false;
2250 }
2251
2252 let shared_hash_upper_bound = old_text
2256 .line_hash_counts
2257 .iter()
2258 .filter_map(|(hash, old_count)| {
2259 new_text
2260 .line_hash_counts
2261 .get(hash)
2262 .map(|new_count| old_count.min(new_count))
2263 })
2264 .sum::<usize>();
2265 (shared_hash_upper_bound as u128) * 8 >= (total_lines as u128) * 3
2266}
2267
2268fn rename_similarity(
2269 old_blob: &PreparedRenameBlob,
2270 new_blob: &PreparedRenameBlob,
2271 stats: &mut RenameDetectionStats,
2272) -> f64 {
2273 if old_blob.content_hash == new_blob.content_hash
2274 && old_blob.blob.content() == new_blob.blob.content()
2275 {
2276 return 1.0;
2277 }
2278 let (Some(old_fingerprint), Some(new_fingerprint)) = (&old_blob.text, &new_blob.text) else {
2279 return 0.0;
2280 };
2281 if !can_reach_rename_threshold(old_fingerprint, new_fingerprint) {
2282 return 0.0;
2283 }
2284 let old_text = old_blob
2285 .blob
2286 .content_str()
2287 .expect("text fingerprint requires UTF-8 content");
2288 let new_text = new_blob
2289 .blob
2290 .content_str()
2291 .expect("text fingerprint requires UTF-8 content");
2292 let old_lines = old_text.lines().collect::<Vec<_>>();
2293 let new_lines = new_text.lines().collect::<Vec<_>>();
2294 stats.lcs_comparisons += 1;
2295 let shared = lcs_len(&old_lines, &new_lines);
2296 (shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
2297}
2298
2299fn lcs_len(left: &[&str], right: &[&str]) -> usize {
2300 let mut previous = vec![0usize; right.len() + 1];
2301 let mut current = vec![0usize; right.len() + 1];
2302 for left_line in left {
2303 for (index, right_line) in right.iter().enumerate() {
2304 current[index + 1] = if left_line == right_line {
2305 previous[index] + 1
2306 } else {
2307 previous[index + 1].max(current[index])
2308 };
2309 }
2310 std::mem::swap(&mut previous, &mut current);
2311 current.fill(0);
2312 }
2313 previous[right.len()]
2314}
2315
2316fn get_state_diff(
2328 repo: &Repository,
2329 from_tree: Option<&Tree>,
2330 to_tree: &Tree,
2331 path: &str,
2332 kind: &DiffKind,
2333) -> Result<(Vec<LineDiff>, FileEolState)> {
2334 match kind {
2335 DiffKind::Added => {
2336 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2337 return Ok((Vec::new(), FileEolState::default()));
2338 };
2339 let eol = eol_for_added(&new_blob);
2340 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2341 }
2342 DiffKind::Deleted => {
2343 let Some(tree) = from_tree else {
2344 return Ok((Vec::new(), FileEolState::default()));
2345 };
2346 let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
2347 return Ok((Vec::new(), FileEolState::default()));
2348 };
2349 let eol = eol_for_deleted(&old_blob);
2350 Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
2351 }
2352 DiffKind::Modified => {
2353 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2354 return Ok((Vec::new(), FileEolState::default()));
2355 };
2356 if let Some(tree) = from_tree
2357 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
2358 {
2359 return modified_blob_hunks(&old_blob, &new_blob);
2360 }
2361 let eol = eol_for_added(&new_blob);
2363 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2364 }
2365 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
2366 }
2367}
2368
2369fn eol_for_added(new_blob: &Blob) -> FileEolState {
2373 let (new_eol, new_count) = blob_eol_meta(new_blob);
2374 FileEolState {
2375 old_has_final_newline: true,
2376 new_has_final_newline: new_eol,
2377 old_line_count: 0,
2378 new_line_count: new_count,
2379 }
2380}
2381
2382fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
2383 let (old_eol, old_count) = blob_eol_meta(old_blob);
2384 FileEolState {
2385 old_has_final_newline: old_eol,
2386 new_has_final_newline: true,
2387 old_line_count: old_count,
2388 new_line_count: 0,
2389 }
2390}
2391
2392fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
2393 let (old_eol, old_count) = blob_eol_meta(old_blob);
2394 let (new_eol, new_count) = blob_eol_meta(new_blob);
2395 FileEolState {
2396 old_has_final_newline: old_eol,
2397 new_has_final_newline: new_eol,
2398 old_line_count: old_count,
2399 new_line_count: new_count,
2400 }
2401}
2402
2403fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
2408 let content = blob.content();
2409 if content.is_empty() {
2410 return (true, 0);
2411 }
2412 let has_eol = content.ends_with(b"\n");
2413 let line_count = blob
2414 .content_str()
2415 .map(|text| text.lines().count())
2416 .unwrap_or(0);
2417 (has_eol, line_count)
2418}
2419
2420fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
2421 let text = text_diff_content(blob)?;
2422 Ok(text
2423 .lines()
2424 .map(|line| LineDiff::new(prefix, line))
2425 .collect())
2426}
2427
2428fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
2442 if old.content() == new.content() {
2443 return Ok((Vec::new(), FileEolState::default()));
2444 }
2445 ensure_text_diffable(old)?;
2446 ensure_text_diffable(new)?;
2447 let eol = eol_for_modified(old, new);
2448 let diff = diff_blobs(old, new);
2449 let lines = diff
2450 .iter()
2451 .map(|l| LineDiff::new(l.prefix(), l.content()))
2452 .collect();
2453 Ok((number_lines(lines), eol))
2454}
2455
2456fn ensure_text_diffable(blob: &Blob) -> Result<()> {
2457 text_diff_content(blob).map(|_| ())
2458}
2459
2460fn text_diff_content(blob: &Blob) -> Result<&str> {
2461 let Some(text) = blob.content_str() else {
2462 return Err(anyhow!(BINARY_DIFF_ERROR));
2463 };
2464 if text.chars().any(is_terminal_hostile_control) {
2465 return Err(anyhow!(BINARY_DIFF_ERROR));
2466 }
2467 Ok(text)
2468}
2469
2470fn is_binary_diff_error(error: &anyhow::Error) -> bool {
2471 error.to_string() == BINARY_DIFF_ERROR
2472}
2473
2474fn is_terminal_hostile_control(ch: char) -> bool {
2475 ch.is_control() && ch != '\n' && ch != '\t'
2476}
2477
2478fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
2479 let mut old_line = 1usize;
2480 let mut new_line = 1usize;
2481
2482 lines
2483 .into_iter()
2484 .map(|line| {
2485 let old = if line.prefix != "+" {
2486 let current = Some(old_line);
2487 old_line += 1;
2488 current
2489 } else {
2490 None
2491 };
2492 let new = if line.prefix != "-" {
2493 let current = Some(new_line);
2494 new_line += 1;
2495 current
2496 } else {
2497 None
2498 };
2499 LineDiff::with_lines(line.prefix, line.content, old, new)
2500 })
2501 .collect()
2502}
2503
2504fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
2505 match find_entry_in_tree(repo, tree, path)? {
2506 Some(entry) => match entry.content_hash() {
2507 Some(hash) if entry.is_blob() || entry.is_symlink() => {
2508 Ok(Some(repo.require_blob(&hash)?))
2509 }
2510 _ => Ok(None),
2511 },
2512 None => Ok(None),
2513 }
2514}
2515
2516fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
2524 let parts: Vec<&str> = path.split('/').collect();
2525 find_entry_recursive(repo, tree, &parts)
2526}
2527
2528fn find_entry_recursive(
2529 repo: &Repository,
2530 tree: &Tree,
2531 parts: &[&str],
2532) -> Result<Option<TreeEntry>> {
2533 if parts.is_empty() {
2534 return Ok(None);
2535 }
2536
2537 let name = parts[0];
2538 let entry = match tree.get(name) {
2539 Some(e) => e,
2540 None => return Ok(None),
2541 };
2542
2543 if parts.len() == 1 {
2544 if entry.is_blob() || entry.entry_type() == EntryType::Symlink || entry.is_gitlink() {
2545 return Ok(Some(entry.clone()));
2546 }
2547 } else if entry.is_tree()
2548 && let Some(hash) = entry.tree_hash()
2549 && let Some(subtree) = repo.store().get_tree(&hash)?
2550 {
2551 return find_entry_recursive(repo, &subtree, &parts[1..]);
2552 }
2553
2554 Ok(None)
2555}
2556
2557fn worktree_file_mode(path: &Path) -> Option<FileMode> {
2562 let metadata = std::fs::symlink_metadata(path).ok()?;
2563 if metadata.file_type().is_symlink() {
2564 return Some(FileMode::Symlink);
2565 }
2566 #[cfg(unix)]
2567 {
2568 use std::os::unix::fs::PermissionsExt;
2569 if metadata.permissions().mode() & 0o111 != 0 {
2570 return Some(FileMode::Executable);
2571 }
2572 }
2573 Some(FileMode::Normal)
2574}
2575
2576fn change_file_modes(
2589 repo: &Repository,
2590 from_tree: Option<&Tree>,
2591 to_tree: Option<&Tree>,
2592 path: &str,
2593 kind: &str,
2594) -> (Option<FileMode>, Option<FileMode>) {
2595 let old_side = || {
2596 from_tree
2597 .and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
2598 .map(|entry| entry.mode())
2599 };
2600 let new_side = || match to_tree {
2601 Some(tree) => find_entry_in_tree(repo, tree, path)
2602 .ok()
2603 .flatten()
2604 .map(|entry| entry.mode()),
2605 None => worktree_file_mode(&repo.root().join(path)),
2606 };
2607 match kind {
2608 "added" => (None, new_side()),
2609 "deleted" => (None, old_side()),
2610 "modified" => (old_side(), new_side()),
2611 _ => (None, None),
2612 }
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617 use objects::{
2618 object::{Blob, FileMode, Tree, TreeEntry},
2619 store::ObjectStore,
2620 };
2621 use repo::Repository;
2622 use tempfile::TempDir;
2623
2624 use super::{
2625 DiffStats, FileChange, FileEolState, LineCounts, LineDiff, RENAME_SIMILARITY_THRESHOLD,
2626 RenameDetectionStats, change_line_counts, detect_clear_renames_with_stats, lcs_len,
2627 prepare_rename_blob, rename_similarity, summarize_context, unified_hunks,
2628 };
2629
2630 type RenameSummary = Vec<(String, String, Option<String>, Option<f64>)>;
2631
2632 fn rename_fixture(
2633 shared_line_counts: &[usize],
2634 ) -> (TempDir, Repository, Tree, Tree, Vec<FileChange>) {
2635 let temp = TempDir::new().expect("create rename fixture");
2636 let repo = Repository::init_default(temp.path()).expect("initialize rename fixture");
2637 let mut old_entries = Vec::with_capacity(shared_line_counts.len());
2638 let mut new_entries = Vec::with_capacity(shared_line_counts.len());
2639 let mut changes = Vec::with_capacity(shared_line_counts.len() * 2);
2640
2641 for (file_index, shared_lines) in shared_line_counts.iter().copied().enumerate() {
2642 let old_content = (0..32)
2643 .map(|line_index| format!("file {file_index} original line {line_index}\n"))
2644 .collect::<String>();
2645 let new_content = (0..32)
2646 .map(|line_index| {
2647 if line_index < shared_lines {
2648 format!("file {file_index} original line {line_index}\n")
2649 } else {
2650 format!("file {file_index} replacement line {line_index}\n")
2651 }
2652 })
2653 .collect::<String>();
2654 let old_hash = repo
2655 .store()
2656 .put_blob(&Blob::from(old_content))
2657 .expect("store old rename blob");
2658 let new_hash = repo
2659 .store()
2660 .put_blob(&Blob::from(new_content))
2661 .expect("store new rename blob");
2662 let old_path = format!("old_{file_index:04}.txt");
2663 let new_path = format!("new_{file_index:04}.txt");
2664 old_entries
2665 .push(TreeEntry::file(&old_path, old_hash, false).expect("build old tree entry"));
2666 new_entries
2667 .push(TreeEntry::file(&new_path, new_hash, false).expect("build new tree entry"));
2668 changes.push(FileChange {
2669 path: old_path,
2670 kind: "deleted".to_string(),
2671 mode: Some(FileMode::Normal),
2672 ..Default::default()
2673 });
2674 changes.push(FileChange {
2675 path: new_path,
2676 kind: "added".to_string(),
2677 mode: Some(FileMode::Normal),
2678 ..Default::default()
2679 });
2680 }
2681
2682 (
2683 temp,
2684 repo,
2685 Tree::from_entries(old_entries),
2686 Tree::from_entries(new_entries),
2687 changes,
2688 )
2689 }
2690
2691 fn run_rename_fixture(shared_line_counts: &[usize]) -> (RenameSummary, RenameDetectionStats) {
2692 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(shared_line_counts);
2693 let mut stats = RenameDetectionStats::default();
2694 let output = detect_clear_renames_with_stats(
2695 &repo,
2696 Some(&old_tree),
2697 Some(&new_tree),
2698 changes,
2699 false,
2700 0,
2701 &mut stats,
2702 )
2703 .expect("detect fixture renames");
2704 let summary = output
2705 .into_iter()
2706 .map(|change| {
2707 (
2708 change.path,
2709 change.kind,
2710 change.old_path,
2711 change.similarity_score,
2712 )
2713 })
2714 .collect();
2715 (summary, stats)
2716 }
2717
2718 #[test]
2719 fn rename_fixture_characterizes_exact_threshold_and_rejected_pairs() {
2720 let (summary, _) = run_rename_fixture(&[32, 31, 24, 23]);
2721
2722 assert_eq!(
2723 summary,
2724 vec![
2725 (
2726 "new_0000.txt".to_string(),
2727 "renamed".to_string(),
2728 Some("old_0000.txt".to_string()),
2729 Some(1.0),
2730 ),
2731 (
2732 "new_0001.txt".to_string(),
2733 "renamed".to_string(),
2734 Some("old_0001.txt".to_string()),
2735 Some(31.0 / 32.0),
2736 ),
2737 (
2738 "new_0002.txt".to_string(),
2739 "renamed".to_string(),
2740 Some("old_0002.txt".to_string()),
2741 Some(0.75),
2742 ),
2743 (
2744 "old_0003.txt".to_string(),
2745 "deleted".to_string(),
2746 None,
2747 None,
2748 ),
2749 ("new_0003.txt".to_string(), "added".to_string(), None, None,),
2750 ]
2751 );
2752 }
2753
2754 #[test]
2755 fn many_rename_detection_reads_each_blob_once_and_limits_lcs_to_candidates() {
2756 const FILE_COUNT: usize = 32;
2757 let (summary, stats) = run_rename_fixture(&[31; FILE_COUNT]);
2758
2759 assert_eq!(summary.len(), FILE_COUNT);
2760 assert!(summary.iter().all(|(_, kind, _, _)| kind == "renamed"));
2761 assert_eq!(
2762 stats.blob_reads,
2763 FILE_COUNT * 2,
2764 "each old and added blob should be read once per diff"
2765 );
2766 assert_eq!(
2767 stats.lcs_comparisons, FILE_COUNT,
2768 "only the one plausible modified target per deleted file should reach LCS"
2769 );
2770 assert_eq!(stats.total_possible_pairs, FILE_COUNT * FILE_COUNT);
2771 assert_eq!(
2772 stats.qualifying_candidate_pairs, FILE_COUNT,
2773 "only threshold-qualified pairs should enter deterministic assignment"
2774 );
2775 assert!(stats.qualifying_candidate_pairs < stats.total_possible_pairs);
2776 }
2777
2778 #[test]
2779 fn rename_prefilter_preserves_all_qualifying_short_line_pairs() {
2780 let mut contents = vec![String::new()];
2781 for line_count in 1..=5 {
2782 for bits in 0..(1usize << line_count) {
2783 let content = (0..line_count)
2784 .map(|line| {
2785 if bits & (1 << line) == 0 {
2786 "alpha"
2787 } else {
2788 "beta"
2789 }
2790 })
2791 .collect::<Vec<_>>()
2792 .join("\n");
2793 contents.push(content);
2794 }
2795 }
2796
2797 for old_content in &contents {
2798 for new_content in &contents {
2799 let old_lines = old_content.lines().collect::<Vec<_>>();
2800 let new_lines = new_content.lines().collect::<Vec<_>>();
2801 let expected = if old_content == new_content {
2802 1.0
2803 } else if old_lines.is_empty() || new_lines.is_empty() {
2804 0.0
2805 } else {
2806 (lcs_len(&old_lines, &new_lines) * 2) as f64
2807 / (old_lines.len() + new_lines.len()) as f64
2808 };
2809 if expected < RENAME_SIMILARITY_THRESHOLD {
2810 continue;
2811 }
2812
2813 let old_blob = prepare_rename_blob(Blob::from(old_content.clone()));
2814 let new_blob = prepare_rename_blob(Blob::from(new_content.clone()));
2815 let actual =
2816 rename_similarity(&old_blob, &new_blob, &mut RenameDetectionStats::default());
2817 assert_eq!(
2818 actual, expected,
2819 "qualifying pair changed score: old={old_content:?}, new={new_content:?}"
2820 );
2821 }
2822 }
2823 }
2824
2825 #[test]
2826 #[ignore = "focused release-mode wall-time measurement"]
2827 fn benchmark_many_modified_renames() {
2828 use std::time::Instant;
2829
2830 const FILE_COUNT: usize = 128;
2831 const SAMPLES: usize = 7;
2832 let shared_line_counts = vec![31; FILE_COUNT];
2833 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(&shared_line_counts);
2834 let mut samples = Vec::with_capacity(SAMPLES);
2835 let mut final_stats = RenameDetectionStats::default();
2836
2837 for _ in 0..SAMPLES {
2838 let mut stats = RenameDetectionStats::default();
2839 let started = Instant::now();
2840 let output = detect_clear_renames_with_stats(
2841 &repo,
2842 Some(&old_tree),
2843 Some(&new_tree),
2844 changes.clone(),
2845 false,
2846 0,
2847 &mut stats,
2848 )
2849 .expect("benchmark rename detection");
2850 assert_eq!(output.len(), FILE_COUNT);
2851 samples.push(started.elapsed());
2852 final_stats = stats;
2853 }
2854 samples.sort();
2855 eprintln!(
2856 "rename_diff files={FILE_COUNT} samples={SAMPLES} median_ms={:.3} blob_reads={} lcs_comparisons={}",
2857 samples[SAMPLES / 2].as_secs_f64() * 1_000.0,
2858 final_stats.blob_reads,
2859 final_stats.lcs_comparisons,
2860 );
2861 }
2862
2863 fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
2864 FileChange {
2865 path: "notes.txt".to_string(),
2866 kind: kind.to_string(),
2867 line_counts: Some(counts),
2868 ..Default::default()
2869 }
2870 }
2871
2872 #[test]
2879 fn diff_stats_reads_line_counts_when_hunks_dropped() {
2880 let changes = vec![stat_change(
2881 "modified",
2882 LineCounts {
2883 added: 1,
2884 modified: 0,
2885 deleted: 0,
2886 },
2887 )];
2888
2889 let stats = DiffStats::from_changes(&changes, None);
2890
2891 assert_eq!(stats.files_changed, 1);
2892 assert_eq!(stats.additions, 1);
2893 assert_eq!(stats.modifications, 0);
2894 assert_eq!(stats.deletions, 0);
2895 assert_eq!(stats.renames, 0);
2896 }
2897
2898 #[test]
2903 fn diff_stats_treats_zero_line_counts_as_authoritative() {
2904 let changes = vec![stat_change(
2905 "modified",
2906 LineCounts {
2907 added: 0,
2908 modified: 0,
2909 deleted: 0,
2910 },
2911 )];
2912
2913 let stats = DiffStats::from_changes(&changes, None);
2914
2915 assert_eq!(stats.modifications, 0);
2916 assert_eq!(stats.additions, 0);
2917 assert_eq!(stats.deletions, 0);
2918 }
2919
2920 #[test]
2923 fn change_line_counts_pairs_modified_lines() {
2924 let lines = vec![
2925 LineDiff::with_lines("-", "alpha", Some(1), None),
2926 LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
2927 LineDiff::with_lines("+", "fresh", None, Some(2)),
2928 ];
2929 let counts = change_line_counts(Some(&lines));
2930 assert_eq!(counts.modified, 1);
2931 assert_eq!(counts.added, 1);
2932 assert_eq!(counts.deleted, 0);
2933 }
2934
2935 #[test]
2941 fn unified_hunks_keeps_added_decoration_in_canonical_body() {
2942 let lines = vec![
2943 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2944 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2945 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2946 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2947 ];
2948
2949 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2950
2951 let header = hunk
2952 .iter()
2953 .find(|line| line.prefix == "@")
2954 .expect("hunk should carry an `@@` header");
2955 assert_eq!(
2957 header.content, "@ -1,2 +1,4 @@",
2958 "header counts must match the untrimmed body: {hunk:?}"
2959 );
2960 assert!(
2961 hunk.iter()
2962 .any(|line| line.prefix == "+" && line.content == "#[test]"),
2963 "added decoration line must survive in the canonical body: {hunk:?}"
2964 );
2965 assert!(
2966 hunk.iter()
2967 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2968 "added function body should remain: {hunk:?}"
2969 );
2970 }
2971
2972 #[test]
2976 fn display_trim_drops_added_decoration_but_keeps_header() {
2977 use super::trim_added_decorations_for_display;
2978
2979 let lines = vec![
2980 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2981 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2982 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2983 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2984 ];
2985 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2986
2987 let display = trim_added_decorations_for_display(&hunk);
2988
2989 assert!(
2990 display
2991 .iter()
2992 .filter(|line| line.content == "#[test]")
2993 .all(|line| line.prefix == " "),
2994 "display trim should let existing context own the decoration: {display:?}"
2995 );
2996 assert!(
2997 display
2998 .iter()
2999 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
3000 "added function body should remain after display trim: {display:?}"
3001 );
3002 assert_eq!(
3003 display
3004 .iter()
3005 .find(|line| line.prefix == "@")
3006 .map(|l| l.content.as_str()),
3007 Some("@ -1,2 +1,4 @@"),
3008 "display trim must not rewrite the `@@` header: {display:?}"
3009 );
3010 }
3011
3012 #[test]
3015 fn summarize_context_truncates_on_char_boundary_not_byte_index() {
3016 let first_line = format!("{}中中", "a".repeat(83));
3017 assert!(first_line.len() > 88);
3018 assert!(!first_line.is_char_boundary(85));
3019
3020 let summary = summarize_context(&format!("{first_line}\nsecond line"));
3021 assert_eq!(summary, first_line);
3022 }
3023
3024 #[test]
3025 fn summarize_context_char_cap_truncates_multibyte_line() {
3026 let first_line = format!("{}中中中", "a".repeat(86));
3027 assert!(first_line.chars().count() > 88);
3028
3029 let summary = summarize_context(&first_line);
3030 let expected = format!("{}...", "a".repeat(85));
3031 assert_eq!(summary, expected);
3032 }
3033
3034 #[test]
3035 fn summarize_context_ascii_truncation_unchanged() {
3036 let line = "b".repeat(90);
3037 let summary = summarize_context(&line);
3038 assert_eq!(summary, format!("{}...", "b".repeat(85)));
3039 }
3040
3041 #[test]
3044 fn minimal_resolve_failure_maps_to_recovery_state_not_found() {
3045 use objects::{RecoveryDetails, error::HeddleError};
3046 use repo::{
3047 ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
3048 };
3049 use tempfile::TempDir;
3050
3051 let temp = TempDir::new().unwrap();
3052 let repo = repo::Repository::init_default(temp.path()).unwrap();
3053 std::fs::write(temp.path().join("a.txt"), "a").unwrap();
3054 repo.snapshot(Some("seed".into()), None).unwrap();
3055
3056 let err = resolve_state_for_command(&repo, "hs-zzzzzzzzzzzz", ResolvePolicy::minimal())
3057 .unwrap_err();
3058 let mapped = match err {
3059 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
3060 HeddleError::recovery(RecoveryDetails::state_not_found(spec))
3061 }
3062 other => panic!("expected not-found failure, got {other:?}"),
3063 };
3064 assert!(matches!(mapped, HeddleError::Recovery(_)));
3065 assert!(
3066 mapped.to_string().contains("State not found"),
3067 "unexpected message: {mapped}"
3068 );
3069 }
3070}