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 lock::RepositoryLockExt,
14 object::{
15 Blob, ContentHash, DiffKind, EntryType, FileChangeSet, FileMode, SemanticChange, State,
16 StateId, Tree, TreeEntry,
17 },
18 store::ObjectStore,
19 worktree::{WorktreeStatus, diff_blobs},
20};
21use repo::{
22 Repository, ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
23};
24#[cfg(feature = "semantic")]
25use semantic::diff::{SemanticDiffOptions, WorktreeStatus as SemanticWorktreeStatus};
26use sley::{EntryKind, Repository as SleyRepository};
27
28use crate::{
29 ExecutionContext, LastTurnAnchor, read_identity_cursor, read_last_turn_anchor,
30 write_last_turn_anchor,
31};
32
33mod context;
34mod patch;
35mod path_filter;
36mod types;
37
38pub use context::{attach_show_context, worktree_context_state};
39pub use patch::{render_diff_patch, render_diff_patch_bytes, write_diff_patch};
40pub use types::*;
41
42const BINARY_DIFF_ERROR: &str = "binary file";
43const RENAME_SIMILARITY_THRESHOLD: f64 = 0.75;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum DiffBase {
48 LastTurn,
49}
50
51impl DiffBase {
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Self::LastTurn => "last-turn",
55 }
56 }
57}
58
59#[derive(Clone, Debug, Default)]
60struct SemanticDiffResult {
61 changes: Vec<SemanticChange>,
62 file_changes: FileChangeSet,
63}
64
65#[derive(Clone, Debug)]
67pub struct DiffOptions {
68 pub from: Option<String>,
69 pub to: Option<String>,
70 pub base: Option<DiffBase>,
71 pub semantic: bool,
72 pub stat: bool,
73 pub name_only: bool,
74 pub unified: usize,
75 pub show_context: bool,
76 pub include_patch_text: bool,
80 pub paths: Vec<String>,
82}
83
84impl Default for DiffOptions {
85 fn default() -> Self {
86 Self {
87 from: None,
88 to: None,
89 base: None,
90 semantic: false,
91 stat: false,
92 name_only: false,
93 unified: 3,
94 show_context: false,
95 include_patch_text: false,
96 paths: Vec::new(),
97 }
98 }
99}
100
101#[derive(Debug)]
103pub struct PlainGitDiffProbe {
104 pub root: PathBuf,
105 pub changes: WorktreeStatus,
106}
107
108pub fn diff(ctx: &ExecutionContext, options: DiffOptions) -> Result<DiffReport> {
110 let repo = ctx.require_repo().map_err(anyhow::Error::new)?;
111 let to = options.to.as_ref();
112 let git_overlay_head_worktree_diff = repo.current_state()?.is_none()
113 && to.is_none()
114 && options.base.is_none()
115 && matches!(options.from.as_deref(), Some("HEAD" | "@"));
116
117 let to_state = if let Some(to_spec) = to {
118 let to_id = resolve_state_id(repo, to_spec)?;
119 Some(require_resolved_state(repo, &to_id)?)
120 } else {
121 None
122 };
123
124 let from_id = if git_overlay_head_worktree_diff {
125 None
126 } else if options.base == Some(DiffBase::LastTurn) {
127 if options.from.is_some() {
128 return Err(anyhow!(
129 "--base last-turn cannot be combined with an explicit from state"
130 ));
131 }
132 let target = to_state
133 .as_ref()
134 .map(|state| state.state_id)
135 .or(repo.head()?)
136 .ok_or_else(|| anyhow!("no agent turn is available for the last-turn base"))?;
137 Some(resolve_last_turn_base(repo, target)?)
138 } else if let Some(ref spec) = options.from {
139 Some(resolve_state_id(repo, spec)?)
140 } else {
141 repo.head()?
142 };
143
144 let from_state = if let Some(id) = from_id {
145 Some(require_resolved_state(repo, &id)?)
146 } else {
147 None
148 };
149
150 let from_tree = if let Some(ref state) = from_state {
151 repo.store().get_tree(&state.tree)?
152 } else {
153 None
154 };
155 let to_tree = if let Some(ref state) = to_state {
156 repo.store().get_tree(&state.tree)?
157 } else {
158 None
159 };
160 let status_options = ctx.worktree_status_options();
161 let from_hash = from_state
162 .as_ref()
163 .map(|state| state.tree)
164 .unwrap_or_else(|| Tree::new().hash());
165
166 let semantic_diff_result = if options.semantic {
167 if let Some(ref to_state) = to_state {
168 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
169 } else {
170 Some(run_semantic_worktree_diff(
171 repo,
172 &from_hash,
173 &status_options,
174 )?)
175 }
176 } else {
177 None
178 };
179
180 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
181 result.file_changes.clone()
182 } else if let Some(ref to_state) = to_state {
183 repo.diff_trees(&from_hash, &to_state.tree)?
184 } else if git_overlay_head_worktree_diff {
185 file_change_set_from_status(&repo.git_overlay_worktree_status()?.unwrap_or_default())
186 } else {
187 let tree = from_tree.clone().unwrap_or_default();
188 file_change_set_from_status(
189 &repo.compare_worktree_cached_with_options(&tree, &status_options)?,
190 )
191 };
192
193 let patch_text_needed = options.include_patch_text;
194 let want_hunks = patch_text_needed || !(options.name_only || options.stat);
195 let file_changes = file_changes_from_change_set(
196 repo,
197 from_tree.as_ref(),
198 to_tree.as_ref(),
199 &changes,
200 &options,
201 want_hunks,
202 patch_text_needed,
203 )?;
204
205 let semantic_changes = semantic_diff_result.map(|result| {
206 result
207 .changes
208 .into_iter()
209 .map(SemanticChangeEntry::from)
210 .collect()
211 });
212
213 let context_state = if options.show_context {
214 if let Some(ref state) = to_state {
215 Some(state.clone())
216 } else if let Some(state) = from_state.clone() {
217 Some(state)
218 } else {
219 repo.current_state()?
220 }
221 } else {
222 None
223 };
224
225 let stats = DiffStats::from_changes(&file_changes, semantic_changes.as_deref());
226 let mut output = DiffReport::with_stats(
227 from_id.map(|id| id.short()),
228 options.to.clone(),
229 file_changes,
230 semantic_changes,
231 None,
232 None,
233 stats,
234 );
235 output.base = options.base.map(DiffBase::as_str);
236 output.worktree_mode = options.to.is_none();
237 let mut output = finalize_diff_report(output, &options)?;
238 if let Some(state) = context_state.as_ref() {
239 attach_show_context(repo, &mut output, state, &options.paths)?;
240 }
241 Ok(output)
242}
243
244pub fn record_last_turn_capture(
248 repo: &Repository,
249 session_id: &str,
250 captured_state: StateId,
251) -> Result<()> {
252 let _guard = repo.locker().write()?;
253 let keep_existing = match read_last_turn_anchor(repo.root()) {
254 Some(anchor) if anchor.session_id == session_id => {
255 first_parent_contains(repo, captured_state, anchor.state_id)?
256 }
257 Some(_) | None => false,
258 };
259 if keep_existing {
260 return Ok(());
261 }
262 write_last_turn_anchor(
263 repo.root(),
264 &LastTurnAnchor {
265 session_id: session_id.to_string(),
266 state_id: captured_state,
267 },
268 )?;
269 Ok(())
270}
271
272pub fn resolve_last_turn_base(repo: &Repository, target: StateId) -> Result<StateId> {
275 let session_id = read_identity_cursor(repo.root())
276 .session
277 .filter(|session| !session.trim().is_empty())
278 .ok_or_else(|| anyhow!("no agent turn is available for the last-turn base"))?;
279 let anchor = read_last_turn_anchor(repo.root())
280 .filter(|anchor| anchor.session_id == session_id)
281 .ok_or_else(|| anyhow!("no captured agent turn matches the last-turn session stamp"))?;
282 if repo.store().get_state(&anchor.state_id)?.is_none() {
283 return Err(anyhow!("the captured last-turn base state is unavailable"));
284 }
285 if !first_parent_contains(repo, target, anchor.state_id)? {
286 return Err(anyhow!(
287 "the last-turn base is not on the selected thread history"
288 ));
289 }
290 Ok(anchor.state_id)
291}
292
293fn first_parent_contains(repo: &Repository, start: StateId, expected: StateId) -> Result<bool> {
294 let mut current = Some(start);
295 while let Some(state_id) = current {
296 if state_id == expected {
297 return Ok(true);
298 }
299 let Some(state) = repo.store().get_state(&state_id)? else {
300 return Ok(false);
301 };
302 current = state.parents.first().copied();
303 }
304 Ok(false)
305}
306
307fn file_changes_from_change_set(
308 repo: &Repository,
309 from_tree: Option<&Tree>,
310 to_tree: Option<&Tree>,
311 changes: &FileChangeSet,
312 options: &DiffOptions,
313 want_hunks: bool,
314 patch_text_needed: bool,
315) -> Result<Vec<FileChange>> {
316 let file_changes: Vec<FileChange> = if options.name_only && !patch_text_needed {
317 changes
318 .iter()
319 .map(|change| {
320 make_status_only_change(
321 Some(repo),
322 from_tree,
323 to_tree,
324 &change.path,
325 &change.kind.to_string(),
326 )
327 })
328 .collect()
329 } else {
330 changes
331 .iter()
332 .map(|change| {
333 let effective_kind = if to_tree.is_none() {
334 worktree_modified_type_change(repo.root(), &change.path, change.kind)
335 .map(|(_, diff_kind)| diff_kind)
336 .unwrap_or(change.kind)
337 } else {
338 change.kind
339 };
340 let diff_result = if let Some(tree) = to_tree {
341 get_state_diff(repo, from_tree, tree, &change.path, &effective_kind)
342 } else {
343 get_worktree_diff(repo, from_tree, &change.path, &effective_kind)
344 };
345 let binary = diff_result.as_ref().err().is_some_and(is_binary_diff_error);
346 let (raw_lines, eol) = match diff_result {
347 Ok((lines, eol)) => (Some(lines), eol),
348 Err(_) => (None, FileEolState::default()),
349 };
350 let (lines, line_counts) = if options.stat && !patch_text_needed {
351 let counts = change_line_counts(raw_lines.as_deref());
352 (None, Some(counts))
353 } else {
354 (
355 raw_lines.map(|lines| unified_hunks(lines, options.unified, &eol)),
356 None,
357 )
358 };
359
360 let kind = effective_kind.to_string();
361 let (old_mode, mode) =
362 change_file_modes(repo, from_tree, to_tree, &change.path, &kind);
363 let symlink = symlink_change_for_paths(
364 repo,
365 from_tree,
366 to_tree,
367 &kind,
368 &change.path,
369 &change.path,
370 old_mode,
371 mode,
372 );
373 FileChange {
374 path: change.path.clone(),
375 kind,
376 binary: binary && symlink.is_none(),
377 lines,
378 line_counts,
379 eol,
380 mode,
381 old_mode,
382 symlink,
383 ..Default::default()
384 }
385 })
386 .collect()
387 };
388 let file_changes = sort_changes_by_path(file_changes);
389 let file_changes = expand_type_changes(
390 repo,
391 from_tree,
392 to_tree,
393 file_changes,
394 want_hunks,
395 options.unified,
396 )?;
397 detect_clear_renames(
398 repo,
399 from_tree,
400 to_tree,
401 file_changes,
402 want_hunks,
403 options.unified,
404 )
405}
406
407pub fn diff_worktree_status(
409 status: &WorktreeStatus,
410 options: &DiffOptions,
411 repo: Option<&Repository>,
412 detect_renames: bool,
413) -> Result<DiffReport> {
414 let want_hunks = options.include_patch_text && repo.is_some();
415 let from_tree = match repo {
416 Some(repo) => head_from_tree(repo)?,
417 None => None,
418 };
419 let changes = file_changes_from_status(
420 status,
421 want_hunks,
422 repo,
423 from_tree.as_ref(),
424 options.unified,
425 );
426 let changes = match repo {
427 Some(repo) => expand_type_changes(
428 repo,
429 from_tree.as_ref(),
430 None,
431 changes,
432 want_hunks,
433 options.unified,
434 )?,
435 None => changes,
436 };
437 let changes = if detect_renames {
438 match repo {
439 Some(repo) => detect_clear_renames(
440 repo,
441 from_tree.as_ref(),
442 None,
443 changes,
444 want_hunks,
445 options.unified,
446 )?,
447 None => changes,
448 }
449 } else {
450 changes
451 };
452 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
453 output.worktree_mode = true;
454 let mut output = finalize_diff_report(output, options)?;
455 if options.show_context
456 && let Some(repo) = repo
457 && let Some(state) = worktree_context_state(repo)?
458 {
459 attach_show_context(repo, &mut output, &state, &options.paths)?;
460 }
461 Ok(output)
462}
463
464pub fn plain_git_head_diff(probe: &PlainGitDiffProbe, options: &DiffOptions) -> Result<DiffReport> {
467 if options.include_patch_text {
468 let changes = plain_git_file_changes_with_hunks(probe, options.unified)?;
469 let mut output = DiffReport::new(Some("HEAD".to_string()), None, changes, None, None, None);
470 output.worktree_mode = true;
471 return finalize_diff_report(output, options);
472 }
473 diff_worktree_status(&probe.changes, options, None, false)
474}
475
476fn finalize_diff_report(mut output: DiffReport, options: &DiffOptions) -> Result<DiffReport> {
477 path_filter::apply_path_filters(&mut output, &options.paths)?;
478 if options.include_patch_text {
479 populate_patch_text(&mut output);
480 }
481 if options.stat {
482 output.changes = strip_line_hunks(std::mem::take(&mut output.changes));
483 }
484 Ok(output)
485}
486
487fn populate_patch_text(output: &mut DiffReport) {
489 let text = render_diff_patch(output);
490 if !text.is_empty() {
491 output.patch = Some(text);
492 }
493}
494
495fn file_change_set_from_status(status: &WorktreeStatus) -> FileChangeSet {
496 let mut changes = FileChangeSet::with_capacity(status.change_count());
497 for path in &status.modified {
498 changes.push_modified(path.display().to_string());
499 }
500 for path in &status.added {
501 changes.push_added(path.display().to_string());
502 }
503 for path in &status.deleted {
504 changes.push_deleted(path.display().to_string());
505 }
506 changes
507}
508
509fn resolve_state_id(repository: &Repository, spec: &str) -> Result<StateId> {
510 resolve_state_for_command(repository, spec, ResolvePolicy::minimal())
511 .map(|resolved| resolved.state_id)
512 .map_err(|error| match error {
513 StateResolveError::Repository(err) => err.into(),
514 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
515 anyhow!(HeddleError::recovery(RecoveryDetails::state_not_found(
516 spec
517 )))
518 }
519 StateResolveError::Failure(other) => anyhow!("{other}"),
520 })
521}
522
523fn require_resolved_state(repo: &Repository, id: &StateId) -> Result<State> {
524 repo.store().get_state(id)?.ok_or_else(|| {
525 anyhow!(HeddleError::MissingObject {
526 object_type: "state".to_string(),
527 id: id.to_string_full(),
528 })
529 })
530}
531
532#[cfg(feature = "semantic")]
533fn run_semantic_diff(
534 repo: &Repository,
535 from_tree_hash: &objects::object::ContentHash,
536 to_tree_hash: &objects::object::ContentHash,
537) -> Result<SemanticDiffResult> {
538 let options = SemanticDiffOptions::default();
539 let result =
540 semantic::diff::semantic_diff(repo.store(), from_tree_hash, to_tree_hash, &options)?;
541 Ok(SemanticDiffResult {
542 changes: result.changes,
543 file_changes: result.file_changes,
544 })
545}
546
547#[cfg(not(feature = "semantic"))]
548fn run_semantic_diff(
549 _repo: &Repository,
550 _from_tree_hash: &objects::object::ContentHash,
551 _to_tree_hash: &objects::object::ContentHash,
552) -> Result<SemanticDiffResult> {
553 Err(anyhow!(HeddleError::recovery(
554 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
555 )))
556}
557
558#[cfg(feature = "semantic")]
559fn run_semantic_worktree_diff(
560 repo: &Repository,
561 from_tree_hash: &objects::object::ContentHash,
562 status_options: &repo::WorktreeStatusOptions,
563) -> Result<SemanticDiffResult> {
564 let from_tree = repo.require_tree(from_tree_hash)?;
565 let status = repo.compare_worktree_cached_with_options(&from_tree, status_options)?;
566 let status = SemanticWorktreeStatus {
567 modified: status.modified,
568 added: status.added,
569 deleted: status.deleted,
570 };
571 let options = SemanticDiffOptions::default();
572 let result = semantic::diff::semantic_diff_worktree(
573 repo.store(),
574 from_tree_hash,
575 repo.root(),
576 &status,
577 &options,
578 )?;
579 Ok(SemanticDiffResult {
580 changes: result.changes,
581 file_changes: result.file_changes,
582 })
583}
584
585#[cfg(not(feature = "semantic"))]
586fn run_semantic_worktree_diff(
587 _repo: &Repository,
588 _from_tree_hash: &objects::object::ContentHash,
589 _status_options: &repo::WorktreeStatusOptions,
590) -> Result<SemanticDiffResult> {
591 Err(anyhow!(HeddleError::recovery(
592 RecoveryDetails::feature_unavailable("semantic diff", "semantic")
593 )))
594}
595
596fn sort_changes_by_path(mut changes: Vec<FileChange>) -> Vec<FileChange> {
605 changes.sort_by(|a, b| a.path.cmp(&b.path));
606 changes
607}
608fn plain_git_file_changes_with_hunks(
619 probe: &PlainGitDiffProbe,
620 unified: usize,
621) -> Result<Vec<FileChange>> {
622 let git_repo = SleyRepository::discover(&probe.root)?;
623 let head_has_tree = !git_repo.head()?.is_unborn();
624 let added_set: BTreeSet<&Path> = probe.changes.added.iter().map(PathBuf::as_path).collect();
632 let deleted_set: BTreeSet<&Path> = probe.changes.deleted.iter().map(PathBuf::as_path).collect();
633
634 let mut changes = Vec::with_capacity(probe.changes.change_count());
635 for path in &probe.changes.modified {
636 push_plain_git_modified(
637 &git_repo,
638 head_has_tree,
639 &probe.root,
640 path,
641 unified,
642 &mut changes,
643 )?;
644 }
645 for path in &probe.changes.added {
646 if deleted_set.contains(path.as_path()) {
647 push_plain_git_modified(
651 &git_repo,
652 head_has_tree,
653 &probe.root,
654 path,
655 unified,
656 &mut changes,
657 )?;
658 } else {
659 changes.push(plain_git_file_change(
660 &git_repo,
661 head_has_tree,
662 &probe.root,
663 path,
664 "added",
665 DiffKind::Added,
666 unified,
667 )?);
668 }
669 }
670 for path in &probe.changes.deleted {
671 if added_set.contains(path.as_path()) {
673 continue;
674 }
675 changes.push(plain_git_file_change(
676 &git_repo,
677 head_has_tree,
678 &probe.root,
679 path,
680 "deleted",
681 DiffKind::Deleted,
682 unified,
683 )?);
684 }
685 Ok(changes)
686}
687
688#[allow(clippy::too_many_arguments)]
689fn plain_git_file_change(
690 git_repo: &SleyRepository,
691 head_has_tree: bool,
692 root: &Path,
693 path: &std::path::Path,
694 kind: &str,
695 diff_kind: DiffKind,
696 unified: usize,
697) -> Result<FileChange> {
698 let (old_blob, old_mode) = match (head_has_tree, &diff_kind) {
699 (true, DiffKind::Modified | DiffKind::Deleted) => {
700 match plain_git_lookup_blob_and_mode(git_repo, path)? {
701 Some((blob, mode)) => (Some(blob), Some(mode)),
702 None => (None, None),
703 }
704 }
705 _ => (None, None),
706 };
707 let new_blob = match diff_kind {
708 DiffKind::Added | DiffKind::Modified => {
709 read_worktree_blob_for_diff(&root.join(path)).ok()
713 }
714 _ => None,
715 };
716 let (old_mode_field, mode) = match diff_kind {
721 DiffKind::Added => (None, worktree_file_mode(&root.join(path))),
722 DiffKind::Deleted => (None, old_mode),
723 DiffKind::Modified => (old_mode, worktree_file_mode(&root.join(path))),
724 DiffKind::Unchanged => (None, None),
725 };
726 let (lines, eol, binary) =
727 compute_plain_git_hunks(old_blob.as_ref(), new_blob.as_ref(), &diff_kind, unified);
728 let symlink = symlink_change_from_blobs(
729 kind,
730 old_blob.as_ref(),
731 old_mode_field,
732 new_blob.as_ref(),
733 mode,
734 );
735 Ok(FileChange {
736 path: path.display().to_string(),
737 kind: kind.to_string(),
738 binary: binary && symlink.is_none(),
739 lines,
740 eol,
741 mode,
742 old_mode: old_mode_field,
743 symlink,
744 ..Default::default()
745 })
746}
747
748fn plain_git_lookup_blob_and_mode(
749 git_repo: &SleyRepository,
750 path: &std::path::Path,
751) -> Result<Option<(Blob, FileMode)>> {
752 let tree_path = plain_git_tree_path(path);
753 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
754 return Ok(None);
755 };
756 let Some(entry_mode) = entry.mode else {
757 return Ok(None);
758 };
759 let mode = match EntryKind::from_mode(entry_mode) {
760 Some(EntryKind::Symlink) => FileMode::Symlink,
761 Some(EntryKind::BlobExecutable) => FileMode::Executable,
762 Some(EntryKind::Blob) => FileMode::Normal,
763 _ => return Ok(None),
764 };
765 let object = git_repo.read_object(&entry.oid)?;
766 Ok(Some((Blob::new(object.body.clone()), mode)))
767}
768
769fn plain_git_tree_path(path: &std::path::Path) -> String {
770 path.components()
771 .map(|component| component.as_os_str().to_string_lossy())
772 .collect::<Vec<_>>()
773 .join("/")
774}
775
776fn plain_git_old_side_kind(
782 git_repo: &SleyRepository,
783 head_has_tree: bool,
784 path: &std::path::Path,
785) -> Result<SideKind> {
786 if !head_has_tree {
787 return Ok(SideKind::Absent);
788 }
789 let tree_path = plain_git_tree_path(path);
790 let Ok(entry) = git_repo.resolve_path("HEAD", &tree_path) else {
791 return Ok(SideKind::Absent);
792 };
793 Ok(match entry.mode.and_then(EntryKind::from_mode) {
794 Some(EntryKind::Symlink) => SideKind::Symlink,
795 Some(EntryKind::Tree) => SideKind::Dir,
796 _ => SideKind::Regular,
797 })
798}
799
800fn push_plain_git_modified(
812 git_repo: &SleyRepository,
813 head_has_tree: bool,
814 root: &Path,
815 path: &std::path::Path,
816 unified: usize,
817 out: &mut Vec<FileChange>,
818) -> Result<()> {
819 let new_kind = worktree_side_kind(&root.join(path));
820 let old_kind = plain_git_old_side_kind(git_repo, head_has_tree, path)?;
821 if is_type_change(old_kind, new_kind) {
822 out.push(plain_git_file_change(
823 git_repo,
824 head_has_tree,
825 root,
826 path,
827 "deleted",
828 DiffKind::Deleted,
829 unified,
830 )?);
831 if new_kind != SideKind::Dir {
834 out.push(plain_git_file_change(
835 git_repo,
836 head_has_tree,
837 root,
838 path,
839 "added",
840 DiffKind::Added,
841 unified,
842 )?);
843 }
844 } else {
845 out.push(plain_git_file_change(
846 git_repo,
847 head_has_tree,
848 root,
849 path,
850 "modified",
851 DiffKind::Modified,
852 unified,
853 )?);
854 }
855 Ok(())
856}
857
858fn compute_plain_git_hunks(
859 old: Option<&Blob>,
860 new: Option<&Blob>,
861 diff_kind: &DiffKind,
862 unified: usize,
863) -> (Option<Vec<LineDiff>>, FileEolState, bool) {
864 let attempt = || -> Result<(Vec<LineDiff>, FileEolState)> {
865 match diff_kind {
866 DiffKind::Added => {
867 let Some(new) = new else {
868 return Ok((Vec::new(), FileEolState::default()));
869 };
870 ensure_text_diffable(new)?;
871 let eol = eol_for_added(new);
872 Ok((number_lines(blob_lines(new, "+")?), eol))
873 }
874 DiffKind::Deleted => {
875 let Some(old) = old else {
876 return Ok((Vec::new(), FileEolState::default()));
877 };
878 ensure_text_diffable(old)?;
879 let eol = eol_for_deleted(old);
880 Ok((number_lines(blob_lines(old, "-")?), eol))
881 }
882 DiffKind::Modified => match (old, new) {
883 (Some(old), Some(new)) => modified_blob_hunks(old, new),
884 (None, Some(new)) => {
885 ensure_text_diffable(new)?;
886 let eol = eol_for_added(new);
887 Ok((number_lines(blob_lines(new, "+")?), eol))
888 }
889 (Some(old), None) => {
890 ensure_text_diffable(old)?;
891 let eol = eol_for_deleted(old);
892 Ok((number_lines(blob_lines(old, "-")?), eol))
893 }
894 (None, None) => Ok((Vec::new(), FileEolState::default())),
895 },
896 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
897 }
898 };
899 match attempt() {
900 Ok((lines, eol)) => (Some(unified_hunks(lines, unified, &eol)), eol, false),
901 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
902 Err(_) => (None, FileEolState::default(), false),
903 }
904}
905fn file_changes_from_status(
910 status: &objects::worktree::WorktreeStatus,
911 want_hunks: bool,
912 repo: Option<&Repository>,
913 from_tree: Option<&Tree>,
914 unified: usize,
915) -> Vec<FileChange> {
916 let mut changes = Vec::with_capacity(status.change_count());
917 for path in &status.modified {
918 changes.push(make_status_file_change(
919 path,
920 "modified",
921 DiffKind::Modified,
922 want_hunks,
923 repo,
924 from_tree,
925 unified,
926 ));
927 }
928 for path in &status.added {
929 changes.push(make_status_file_change(
930 path,
931 "added",
932 DiffKind::Added,
933 want_hunks,
934 repo,
935 from_tree,
936 unified,
937 ));
938 }
939 for path in &status.deleted {
940 changes.push(make_status_file_change(
941 path,
942 "deleted",
943 DiffKind::Deleted,
944 want_hunks,
945 repo,
946 from_tree,
947 unified,
948 ));
949 }
950 changes
951}
952
953#[allow(clippy::too_many_arguments)]
954fn make_status_file_change(
955 path: &std::path::Path,
956 kind: &str,
957 diff_kind: DiffKind,
958 want_hunks: bool,
959 repo: Option<&Repository>,
960 from_tree: Option<&Tree>,
961 unified: usize,
962) -> FileChange {
963 let path_str = path.display().to_string();
964 let (kind, diff_kind) = match repo
968 .and_then(|repo| worktree_modified_type_change(repo.root(), &path_str, diff_kind))
969 {
970 Some(reclassified) => reclassified,
971 None => (kind, diff_kind),
972 };
973 match repo {
974 Some(repo) if want_hunks => {
975 build_worktree_change(repo, from_tree, &path_str, kind, diff_kind, unified)
976 }
977 _ => make_status_only_change(repo, from_tree, None, &path_str, kind),
978 }
979}
980
981fn make_status_only_change(
995 repo: Option<&Repository>,
996 from_tree: Option<&Tree>,
997 to_tree: Option<&Tree>,
998 path_str: &str,
999 kind: &str,
1000) -> FileChange {
1001 let (old_mode, mode) = match repo {
1002 Some(repo) => change_file_modes(repo, from_tree, to_tree, path_str, kind),
1003 None => (None, None),
1004 };
1005 FileChange {
1006 path: path_str.to_string(),
1007 kind: kind.to_string(),
1008 mode,
1009 old_mode,
1010 ..Default::default()
1011 }
1012}
1013
1014fn build_worktree_change(
1019 repo: &Repository,
1020 from_tree: Option<&Tree>,
1021 path_str: &str,
1022 kind: &str,
1023 diff_kind: DiffKind,
1024 unified: usize,
1025) -> FileChange {
1026 let (old_mode, mode) = change_file_modes(repo, from_tree, None, path_str, kind);
1027 let (lines, eol, binary) = match get_worktree_diff(repo, from_tree, path_str, &diff_kind) {
1028 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1029 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1030 Err(_) => (None, FileEolState::default(), false),
1035 };
1036 let symlink = symlink_change_for_paths(
1037 repo, from_tree, None, kind, path_str, path_str, old_mode, mode,
1038 );
1039 FileChange {
1040 path: path_str.to_string(),
1041 kind: kind.to_string(),
1042 binary: binary && symlink.is_none(),
1043 lines,
1044 eol,
1045 mode,
1046 old_mode,
1047 symlink,
1048 ..Default::default()
1049 }
1050}
1051
1052#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1054enum SideKind {
1055 Absent,
1056 Dir,
1057 Regular,
1059 Symlink,
1060}
1061
1062fn tree_side_kind(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<SideKind> {
1067 let Some(tree) = tree else {
1068 return Ok(SideKind::Absent);
1069 };
1070 if let Some(entry) = find_entry_in_tree(repo, tree, path)? {
1071 return Ok(if entry.entry_type() == EntryType::Symlink {
1072 SideKind::Symlink
1073 } else {
1074 SideKind::Regular
1075 });
1076 }
1077 if dir_subtree_in_tree(repo, tree, path)?.is_some() {
1078 Ok(SideKind::Dir)
1079 } else {
1080 Ok(SideKind::Absent)
1081 }
1082}
1083
1084fn new_side_kind(repo: &Repository, to_tree: Option<&Tree>, path: &str) -> Result<SideKind> {
1087 match to_tree {
1088 Some(tree) => tree_side_kind(repo, Some(tree), path),
1089 None => Ok(worktree_side_kind(&repo.root().join(path))),
1090 }
1091}
1092
1093fn worktree_side_kind(path: &Path) -> SideKind {
1097 let Ok(meta) = std::fs::symlink_metadata(path) else {
1098 return SideKind::Absent;
1099 };
1100 if meta.file_type().is_symlink() {
1101 SideKind::Symlink
1102 } else if meta.is_dir() {
1103 SideKind::Dir
1104 } else {
1105 SideKind::Regular
1106 }
1107}
1108
1109fn is_type_change(old: SideKind, new: SideKind) -> bool {
1112 use SideKind::{Dir, Regular, Symlink};
1113 matches!(
1114 (old, new),
1115 (Dir, Regular)
1116 | (Dir, Symlink)
1117 | (Regular, Dir)
1118 | (Symlink, Dir)
1119 | (Regular, Symlink)
1120 | (Symlink, Regular)
1121 )
1122}
1123
1124fn expand_type_changes(
1151 repo: &Repository,
1152 from_tree: Option<&Tree>,
1153 to_tree: Option<&Tree>,
1154 changes: Vec<FileChange>,
1155 want_hunks: bool,
1156 unified: usize,
1157) -> Result<Vec<FileChange>> {
1158 let mut output = Vec::with_capacity(changes.len());
1159 for change in changes {
1160 if change.kind != "modified" {
1161 output.push(change);
1162 continue;
1163 }
1164 let old_kind = tree_side_kind(repo, from_tree, &change.path)?;
1165 let new_kind = new_side_kind(repo, to_tree, &change.path)?;
1166 if !is_type_change(old_kind, new_kind) {
1167 output.push(change);
1168 continue;
1169 }
1170
1171 if old_kind == SideKind::Dir {
1174 if let Some(from_tree) = from_tree
1175 && let Some(subtree) = dir_subtree_in_tree(repo, from_tree, &change.path)?
1176 {
1177 let mut nested = Vec::new();
1178 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1179 for nested_path in nested {
1180 output.push(make_type_change_part(
1181 repo,
1182 Some(from_tree),
1183 to_tree,
1184 &nested_path,
1185 DiffKind::Deleted,
1186 want_hunks,
1187 unified,
1188 ));
1189 }
1190 }
1191 } else {
1192 output.push(make_type_change_part(
1193 repo,
1194 from_tree,
1195 to_tree,
1196 &change.path,
1197 DiffKind::Deleted,
1198 want_hunks,
1199 unified,
1200 ));
1201 }
1202
1203 if new_kind == SideKind::Dir {
1208 if let Some(to_tree) = to_tree
1209 && let Some(subtree) = dir_subtree_in_tree(repo, to_tree, &change.path)?
1210 {
1211 let mut nested = Vec::new();
1212 collect_subtree_blob_paths(repo, &subtree, &change.path, &mut nested)?;
1213 for nested_path in nested {
1214 output.push(make_type_change_part(
1215 repo,
1216 from_tree,
1217 Some(to_tree),
1218 &nested_path,
1219 DiffKind::Added,
1220 want_hunks,
1221 unified,
1222 ));
1223 }
1224 }
1225 } else {
1226 output.push(make_type_change_part(
1227 repo,
1228 from_tree,
1229 to_tree,
1230 &change.path,
1231 DiffKind::Added,
1232 want_hunks,
1233 unified,
1234 ));
1235 }
1236 }
1237 Ok(output)
1238}
1239
1240fn make_type_change_part(
1241 repo: &Repository,
1242 from_tree: Option<&Tree>,
1243 to_tree: Option<&Tree>,
1244 path_str: &str,
1245 diff_kind: DiffKind,
1246 want_hunks: bool,
1247 unified: usize,
1248) -> FileChange {
1249 let kind = diff_kind.to_string();
1250 if !want_hunks {
1251 return make_status_only_change(Some(repo), from_tree, to_tree, path_str, &kind);
1252 }
1253 match to_tree {
1254 Some(to_tree) => build_state_change(
1255 repo, from_tree, to_tree, path_str, &kind, diff_kind, unified,
1256 ),
1257 None => build_worktree_change(repo, from_tree, path_str, &kind, diff_kind, unified),
1258 }
1259}
1260
1261fn build_state_change(
1265 repo: &Repository,
1266 from_tree: Option<&Tree>,
1267 to_tree: &Tree,
1268 path_str: &str,
1269 kind: &str,
1270 diff_kind: DiffKind,
1271 unified: usize,
1272) -> FileChange {
1273 let (old_mode, mode) = change_file_modes(repo, from_tree, Some(to_tree), path_str, kind);
1274 let (lines, eol, binary) = match get_state_diff(repo, from_tree, to_tree, path_str, &diff_kind)
1275 {
1276 Ok((raw, eol)) => (Some(unified_hunks(raw, unified, &eol)), eol, false),
1277 Err(error) if is_binary_diff_error(&error) => (None, FileEolState::default(), true),
1278 Err(_) => (None, FileEolState::default(), false),
1279 };
1280 let symlink = symlink_change_for_paths(
1281 repo,
1282 from_tree,
1283 Some(to_tree),
1284 kind,
1285 path_str,
1286 path_str,
1287 old_mode,
1288 mode,
1289 );
1290 FileChange {
1291 path: path_str.to_string(),
1292 kind: kind.to_string(),
1293 binary: binary && symlink.is_none(),
1294 lines,
1295 eol,
1296 mode,
1297 old_mode,
1298 symlink,
1299 ..Default::default()
1300 }
1301}
1302
1303fn dir_subtree_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Tree>> {
1307 let mut current = tree.clone();
1308 let mut parts = path.split('/').peekable();
1309 while let Some(name) = parts.next() {
1310 let Some(entry) = current.get(name) else {
1311 return Ok(None);
1312 };
1313 if !entry.is_tree() {
1314 return Ok(None);
1315 }
1316 let Some(hash) = entry.tree_hash() else {
1317 return Ok(None);
1318 };
1319 let Some(subtree) = repo.store().get_tree(&hash)? else {
1320 return Ok(None);
1321 };
1322 if parts.peek().is_none() {
1323 return Ok(Some(subtree));
1324 }
1325 current = subtree;
1326 }
1327 Ok(None)
1328}
1329
1330fn collect_subtree_blob_paths(
1333 repo: &Repository,
1334 subtree: &Tree,
1335 prefix: &str,
1336 out: &mut Vec<String>,
1337) -> Result<()> {
1338 for entry in subtree.entries() {
1339 let child_path = format!("{prefix}/{}", entry.name());
1340 if entry.is_tree() {
1341 if let Some(hash) = entry.tree_hash()
1342 && let Some(nested) = repo.store().get_tree(&hash)?
1343 {
1344 collect_subtree_blob_paths(repo, &nested, &child_path, out)?;
1345 }
1346 } else {
1347 out.push(child_path);
1348 }
1349 }
1350 Ok(())
1351}
1352
1353fn head_from_tree(repo: &Repository) -> Result<Option<Tree>> {
1354 let Some(head_id) = repo.head()? else {
1355 return Ok(None);
1356 };
1357 let Some(state) = repo.store().get_state(&head_id)? else {
1358 return Ok(None);
1359 };
1360 Ok(repo.store().get_tree(&state.tree)?)
1361}
1362
1363pub fn compute_state_diff(
1378 repo: &Repository,
1379 from_state_id: &StateId,
1380 to_state_id: &StateId,
1381 semantic: bool,
1382 unified: usize,
1383) -> Result<DiffReport> {
1384 let from_state = repo.store().get_state(from_state_id)?;
1385 let from_tree = if let Some(ref state) = from_state {
1386 repo.store().get_tree(&state.tree)?
1387 } else {
1388 None
1389 };
1390
1391 let to_state = require_resolved_state(repo, to_state_id)?;
1392 let to_tree = repo
1393 .store()
1394 .get_tree(&to_state.tree)?
1395 .ok_or_else(|| anyhow!("Tree not found for state {}", to_state_id.short()))?;
1396
1397 let from_hash = from_state
1398 .as_ref()
1399 .map(|s| s.tree)
1400 .unwrap_or_else(|| Tree::new().hash());
1401
1402 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1403 Some(run_semantic_diff(repo, &from_hash, &to_state.tree)?)
1404 } else {
1405 None
1406 };
1407
1408 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1409 result.file_changes.clone()
1410 } else {
1411 repo.diff_trees(&from_hash, &to_state.tree)?
1412 };
1413
1414 let file_changes: Vec<FileChange> = changes
1415 .iter()
1416 .map(|change| {
1417 build_state_change(
1418 repo,
1419 from_tree.as_ref(),
1420 &to_tree,
1421 &change.path,
1422 &change.kind.to_string(),
1423 change.kind,
1424 unified,
1425 )
1426 })
1427 .collect();
1428 let file_changes = sort_changes_by_path(file_changes);
1429 let file_changes = expand_type_changes(
1430 repo,
1431 from_tree.as_ref(),
1432 Some(&to_tree),
1433 file_changes,
1434 true,
1435 unified,
1436 )?;
1437 let file_changes = detect_clear_renames(
1438 repo,
1439 from_tree.as_ref(),
1440 Some(&to_tree),
1441 file_changes,
1442 true,
1443 unified,
1444 )?;
1445
1446 let semantic_changes = semantic_diff_result.map(|r| {
1447 r.changes
1448 .into_iter()
1449 .map(SemanticChangeEntry::from)
1450 .collect()
1451 });
1452
1453 let mut output = DiffReport::new(
1454 Some(from_state_id.short()),
1455 Some(to_state_id.short()),
1456 file_changes,
1457 semantic_changes,
1458 None,
1459 None,
1460 );
1461 populate_patch_text(&mut output);
1462 Ok(output)
1463}
1464
1465pub fn compute_tree_diff(
1472 repo: &Repository,
1473 from_state_id: &StateId,
1474 to_tree: &Tree,
1475 to_label: impl Into<String>,
1476 semantic: bool,
1477 unified: usize,
1478) -> Result<DiffReport> {
1479 let from_state = repo.store().get_state(from_state_id)?;
1480 let from_tree = if let Some(ref state) = from_state {
1481 repo.store().get_tree(&state.tree)?
1482 } else {
1483 None
1484 };
1485 let from_hash = from_state
1486 .as_ref()
1487 .map(|s| s.tree)
1488 .unwrap_or_else(|| Tree::new().hash());
1489
1490 let to_hash = repo.store().put_tree(to_tree)?;
1491
1492 let semantic_diff_result: Option<SemanticDiffResult> = if semantic {
1493 Some(run_semantic_diff(repo, &from_hash, &to_hash)?)
1494 } else {
1495 None
1496 };
1497
1498 let changes: FileChangeSet = if let Some(ref result) = semantic_diff_result {
1499 result.file_changes.clone()
1500 } else {
1501 repo.diff_trees(&from_hash, &to_hash)?
1502 };
1503
1504 let file_changes: Vec<FileChange> = changes
1505 .iter()
1506 .map(|change| {
1507 build_state_change(
1508 repo,
1509 from_tree.as_ref(),
1510 to_tree,
1511 &change.path,
1512 &change.kind.to_string(),
1513 change.kind,
1514 unified,
1515 )
1516 })
1517 .collect();
1518 let file_changes = sort_changes_by_path(file_changes);
1519 let file_changes = expand_type_changes(
1520 repo,
1521 from_tree.as_ref(),
1522 Some(to_tree),
1523 file_changes,
1524 true,
1525 unified,
1526 )?;
1527 let file_changes = detect_clear_renames(
1528 repo,
1529 from_tree.as_ref(),
1530 Some(to_tree),
1531 file_changes,
1532 true,
1533 unified,
1534 )?;
1535
1536 let semantic_changes = semantic_diff_result.map(|r| {
1537 r.changes
1538 .into_iter()
1539 .map(SemanticChangeEntry::from)
1540 .collect()
1541 });
1542
1543 let mut output = DiffReport::new(
1544 Some(from_state_id.short()),
1545 Some(to_label.into()),
1546 file_changes,
1547 semantic_changes,
1548 None,
1549 None,
1550 );
1551 populate_patch_text(&mut output);
1552 Ok(output)
1553}
1554
1555fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
1556 changes
1557 .into_iter()
1558 .map(|mut change| {
1559 change.lines = None;
1560 change
1561 })
1562 .collect()
1563}
1564
1565fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
1566 if lines.is_empty() {
1567 return lines;
1568 }
1569 if !lines.iter().any(|line| line.prefix != " ") {
1570 if eol.old_has_final_newline == eol.new_has_final_newline {
1578 return lines;
1579 }
1580 return eol_only_tail_hunk(lines, context);
1581 }
1582
1583 let mut ranges = Vec::<(usize, usize)>::new();
1584 let mut cursor = 0usize;
1585 while cursor < lines.len() {
1586 while cursor < lines.len() && lines[cursor].prefix == " " {
1587 cursor += 1;
1588 }
1589 if cursor >= lines.len() {
1590 break;
1591 }
1592
1593 let start = cursor.saturating_sub(context);
1594 while cursor < lines.len() && lines[cursor].prefix != " " {
1595 cursor += 1;
1596 }
1597 let mut end = (cursor + context).min(lines.len());
1598
1599 while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
1600 cursor += 1;
1601 }
1602 while cursor < lines.len() && lines[cursor].prefix != " " {
1603 end = (cursor + 1 + context).min(lines.len());
1604 cursor += 1;
1605 }
1606
1607 if let Some((_, previous_end)) = ranges.last_mut()
1608 && start <= *previous_end
1609 {
1610 *previous_end = end;
1611 continue;
1612 }
1613 ranges.push((start, end));
1614 }
1615
1616 let mut output = Vec::new();
1617 for (start, end) in ranges {
1618 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1619 output.push(LineDiff {
1620 prefix: "@".to_string(),
1621 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1622 old_line: None,
1623 new_line: None,
1624 });
1625 output.extend_from_slice(&lines[start..end]);
1633 }
1634 output
1635}
1636
1637fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
1644 let end = lines.len();
1645 let start = end.saturating_sub(context + 1);
1646 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1647 let mut output = Vec::with_capacity(end - start + 1);
1648 output.push(LineDiff {
1649 prefix: "@".to_string(),
1650 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1651 old_line: None,
1652 new_line: None,
1653 });
1654 output.extend_from_slice(&lines[start..end]);
1655 output
1656}
1657
1658pub fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
1673 let mut output = Vec::with_capacity(lines.len());
1674 let mut body_start = 0usize;
1675 for (index, line) in lines.iter().enumerate() {
1676 if line.prefix == "@" {
1677 if body_start < index {
1678 output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
1679 }
1680 output.push(line.clone());
1681 body_start = index + 1;
1682 }
1683 }
1684 if body_start < lines.len() {
1685 output.extend(trim_trailing_added_decorations(&lines[body_start..]));
1686 }
1687 output
1688}
1689
1690fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
1691 let mut trimmed = Vec::with_capacity(lines.len());
1692 let mut index = 0usize;
1693 while index < lines.len() {
1694 if lines[index].prefix == "+"
1695 && is_visual_decoration_line(&lines[index].content)
1696 && let Some(next_context) = next_context_line(lines, index + 1)
1697 && next_context.content == lines[index].content
1698 {
1699 let added_block_has_code = lines[index + 1..next_context.index]
1700 .iter()
1701 .any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
1702 if added_block_has_code {
1703 index += 1;
1704 continue;
1705 }
1706 }
1707 trimmed.push(lines[index].clone());
1708 index += 1;
1709 }
1710 trimmed
1711}
1712
1713struct IndexedLine<'a> {
1714 index: usize,
1715 content: &'a str,
1716}
1717
1718fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
1719 lines[start..]
1720 .iter()
1721 .enumerate()
1722 .find(|(_, line)| line.prefix == " ")
1723 .map(|(offset, line)| IndexedLine {
1724 index: start + offset,
1725 content: &line.content,
1726 })
1727}
1728
1729fn is_blank_or_visual_decoration(line: &str) -> bool {
1730 line.trim().is_empty() || is_visual_decoration_line(line)
1731}
1732
1733fn is_visual_decoration_line(line: &str) -> bool {
1734 let trimmed = line.trim_start();
1735 trimmed.starts_with("#[")
1736 || trimmed.starts_with("#![")
1737 || trimmed.starts_with('@')
1738 || trimmed.starts_with("///")
1739 || trimmed.starts_with("//!")
1740}
1741
1742fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
1743 let old_before = lines[..start]
1744 .iter()
1745 .filter(|line| line.prefix != "+")
1746 .count();
1747 let new_before = lines[..start]
1748 .iter()
1749 .filter(|line| line.prefix != "-")
1750 .count();
1751 let old_len = lines[start..end]
1752 .iter()
1753 .filter(|line| line.prefix != "+")
1754 .count();
1755 let new_len = lines[start..end]
1756 .iter()
1757 .filter(|line| line.prefix != "-")
1758 .count();
1759
1760 let old_start = if old_len == 0 {
1761 old_before
1762 } else {
1763 old_before + 1
1764 };
1765 let new_start = if new_len == 0 {
1766 new_before
1767 } else {
1768 new_before + 1
1769 };
1770 (old_start, old_len, new_start, new_len)
1771}
1772
1773fn get_worktree_diff(
1774 repo: &Repository,
1775 from_tree: Option<&Tree>,
1776 path: &str,
1777 kind: &DiffKind,
1778) -> Result<(Vec<LineDiff>, FileEolState)> {
1779 let worktree_path = repo.root().join(path);
1780
1781 match kind {
1782 DiffKind::Added => {
1783 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1784 let eol = eol_for_added(&new_blob);
1785 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1786 }
1787 DiffKind::Deleted => {
1788 if let Some(tree) = from_tree
1792 && let Some(blob) = find_blob_in_tree(repo, tree, path)?
1793 {
1794 let eol = eol_for_deleted(&blob);
1795 return Ok((number_lines(blob_lines(&blob, "-")?), eol));
1796 }
1797 Ok((vec![], FileEolState::default()))
1798 }
1799 DiffKind::Modified => {
1800 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1801
1802 if let Some(tree) = from_tree
1803 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
1804 {
1805 return modified_blob_hunks(&old_blob, &new_blob);
1806 }
1807
1808 let eol = eol_for_added(&new_blob);
1809 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1810 }
1811 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
1812 }
1813}
1814
1815fn worktree_modified_type_change(
1831 repo_root: &Path,
1832 path: &str,
1833 diff_kind: DiffKind,
1834) -> Option<(&'static str, DiffKind)> {
1835 if matches!(diff_kind, DiffKind::Modified)
1836 && worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
1837 {
1838 Some(("deleted", DiffKind::Deleted))
1839 } else {
1840 None
1841 }
1842}
1843
1844fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
1845 let metadata = std::fs::symlink_metadata(path)?;
1846 if metadata.file_type().is_symlink() {
1847 let target = std::fs::read_link(path)?;
1848 return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
1849 }
1850 Ok(Blob::new(std::fs::read(path)?))
1851}
1852
1853fn is_symlink_mode(mode: Option<FileMode>) -> bool {
1854 matches!(mode, Some(FileMode::Symlink))
1855}
1856
1857fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
1865 match kind {
1866 "added" => (false, is_symlink_mode(mode)),
1867 "deleted" => (is_symlink_mode(mode), false),
1868 _ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
1869 }
1870}
1871
1872fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
1882 (old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
1883}
1884
1885fn symlink_change_from_blobs(
1889 kind: &str,
1890 old_blob: Option<&Blob>,
1891 old_mode: Option<FileMode>,
1892 new_blob: Option<&Blob>,
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(|| old_blob.map(|blob| blob.content().to_vec()))
1898 .flatten();
1899 let new = new_is_link
1900 .then(|| new_blob.map(|blob| blob.content().to_vec()))
1901 .flatten();
1902 make_symlink_change(old, new)
1903}
1904
1905#[allow(clippy::too_many_arguments)]
1912fn symlink_change_for_paths(
1913 repo: &Repository,
1914 from_tree: Option<&Tree>,
1915 to_tree: Option<&Tree>,
1916 kind: &str,
1917 old_path: &str,
1918 new_path: &str,
1919 old_mode: Option<FileMode>,
1920 mode: Option<FileMode>,
1921) -> Option<SymlinkChange> {
1922 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1923 let old = old_is_link
1924 .then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
1925 .flatten()
1926 .map(|blob| blob.content().to_vec());
1927 let new = new_is_link
1928 .then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
1929 .flatten()
1930 .map(|blob| blob.content().to_vec());
1931 make_symlink_change(old, new)
1932}
1933fn detect_clear_renames(
1934 repo: &Repository,
1935 from_tree: Option<&Tree>,
1936 to_tree: Option<&Tree>,
1937 changes: Vec<FileChange>,
1938 include_lines: bool,
1939 unified: usize,
1940) -> Result<Vec<FileChange>> {
1941 detect_clear_renames_with_stats(
1942 repo,
1943 from_tree,
1944 to_tree,
1945 changes,
1946 include_lines,
1947 unified,
1948 &mut RenameDetectionStats::default(),
1949 )
1950}
1951
1952#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1953struct RenameDetectionStats {
1954 blob_reads: usize,
1955 lcs_comparisons: usize,
1956 total_possible_pairs: usize,
1957 qualifying_candidate_pairs: usize,
1958}
1959
1960struct PreparedRenameBlob {
1961 blob: Blob,
1962 content_hash: ContentHash,
1963 text: Option<RenameTextFingerprint>,
1964}
1965
1966struct RenameTextFingerprint {
1967 line_count: usize,
1968 line_hash_counts: BTreeMap<u64, usize>,
1969}
1970
1971#[allow(clippy::too_many_arguments)]
1972fn detect_clear_renames_with_stats(
1973 repo: &Repository,
1974 from_tree: Option<&Tree>,
1975 to_tree: Option<&Tree>,
1976 changes: Vec<FileChange>,
1977 include_lines: bool,
1978 unified: usize,
1979 stats: &mut RenameDetectionStats,
1980) -> Result<Vec<FileChange>> {
1981 let mut deleted = changes
1982 .iter()
1983 .filter(|change| change.kind == "deleted")
1984 .map(|change| change.path.as_str())
1985 .collect::<Vec<_>>();
1986 let mut added = changes
1987 .iter()
1988 .filter(|change| change.kind == "added")
1989 .map(|change| change.path.as_str())
1990 .collect::<Vec<_>>();
1991 deleted.sort_unstable();
1992 added.sort_unstable();
1993 if deleted.is_empty() || added.is_empty() {
1994 return Ok(changes);
1995 }
1996 stats.total_possible_pairs = deleted.len().saturating_mul(added.len());
1997
1998 let deleted_side_modes = changes
2008 .iter()
2009 .filter(|change| change.kind == "deleted")
2010 .map(|change| (change.path.as_str(), change.mode))
2011 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
2012 let added_side_modes = changes
2013 .iter()
2014 .filter(|change| change.kind == "added")
2015 .map(|change| (change.path.as_str(), change.mode))
2016 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
2017
2018 let mut added_blobs = BTreeMap::new();
2019 for new_path in &added {
2020 stats.blob_reads += 1;
2021 if let Some(blob) = new_blob_for_rename(repo, to_tree, new_path)? {
2022 added_blobs.insert(*new_path, prepare_rename_blob(blob));
2023 }
2024 }
2025
2026 let mut candidates = RenameCandidateIndex::new(deleted.len(), added.len());
2027 for (old_index, old_path) in deleted.iter().enumerate() {
2028 stats.blob_reads += 1;
2029 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2030 continue;
2031 };
2032 let old_blob = prepare_rename_blob(old_blob);
2033 for (new_index, new_path) in added.iter().enumerate() {
2034 if old_path == new_path {
2039 continue;
2040 }
2041 if !rename_mode_compatible(
2048 deleted_side_modes.get(old_path).copied().flatten(),
2049 added_side_modes.get(new_path).copied().flatten(),
2050 ) {
2051 continue;
2052 }
2053 let Some(new_blob) = added_blobs.get(new_path) else {
2054 continue;
2055 };
2056 let score = rename_similarity(&old_blob, new_blob, stats);
2057 if score >= RENAME_SIMILARITY_THRESHOLD {
2058 candidates.push(old_index, new_index, score);
2059 }
2060 }
2061 }
2062 stats.qualifying_candidate_pairs = candidates.candidate_count();
2063
2064 let renames = candidates
2065 .assign()
2066 .into_iter()
2067 .map(|assignment| {
2068 (
2069 deleted[assignment.source_index].to_string(),
2070 added[assignment.target_index].to_string(),
2071 assignment.score,
2072 )
2073 })
2074 .collect::<Vec<_>>();
2075 if renames.is_empty() {
2076 return Ok(changes);
2077 }
2078
2079 let rename_by_new = renames
2080 .iter()
2081 .map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
2082 .collect::<std::collections::BTreeMap<_, _>>();
2083 let removed_old = renames
2084 .iter()
2085 .map(|(old_path, _, _)| old_path.as_str())
2086 .collect::<BTreeSet<_>>();
2087 let deleted_modes = changes
2093 .iter()
2094 .filter(|change| change.kind == "deleted")
2095 .map(|change| (change.path.clone(), change.mode))
2096 .collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
2097
2098 let mut output = Vec::with_capacity(changes.len() - renames.len());
2099 for mut change in changes {
2100 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2101 continue;
2102 }
2103 if change.kind == "added"
2104 && let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
2105 {
2106 let (lines, eol) = if include_lines {
2107 match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
2108 Ok(Some((lines, eol))) => (Some(lines), eol),
2109 Ok(None) => (None, FileEolState::default()),
2110 Err(error) if is_binary_diff_error(&error) => {
2111 change.binary = true;
2112 (None, FileEolState::default())
2113 }
2114 Err(error) => return Err(error),
2115 }
2116 } else {
2117 (None, FileEolState::default())
2118 };
2119 change.kind = "renamed".to_string();
2120 change.old_path = Some(old_path.to_string());
2121 change.similarity_score = Some(score);
2122 change.lines = lines;
2123 change.eol = eol;
2124 change.old_mode = deleted_modes.get(old_path).copied().flatten();
2128 change.symlink = symlink_change_for_paths(
2135 repo,
2136 from_tree,
2137 to_tree,
2138 "renamed",
2139 old_path,
2140 &change.path,
2141 change.old_mode,
2142 change.mode,
2143 );
2144 if change.symlink.is_some() {
2145 change.binary = false;
2146 }
2147 change.line_counts = None;
2154 }
2155 output.push(change);
2156 }
2157 Ok(output)
2158}
2159
2160fn rename_lines(
2161 repo: &Repository,
2162 from_tree: Option<&Tree>,
2163 to_tree: Option<&Tree>,
2164 old_path: &str,
2165 new_path: &str,
2166 unified: usize,
2167) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
2168 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2169 return Ok(None);
2170 };
2171 let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
2172 return Ok(None);
2173 };
2174 ensure_text_diffable(&old_blob)?;
2175 ensure_text_diffable(&new_blob)?;
2176 let eol = eol_for_modified(&old_blob, &new_blob);
2177 let diff = diff_blobs(&old_blob, &new_blob);
2178 let lines = diff
2179 .iter()
2180 .map(|line| LineDiff::new(line.prefix(), line.content()))
2181 .collect();
2182 Ok(Some((
2183 unified_hunks(number_lines(lines), unified, &eol),
2184 eol,
2185 )))
2186}
2187
2188fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
2189 let Some(tree) = tree else {
2190 return Ok(None);
2191 };
2192 find_blob_in_tree(repo, tree, path)
2193}
2194
2195fn new_blob_for_rename(
2196 repo: &Repository,
2197 to_tree: Option<&Tree>,
2198 path: &str,
2199) -> Result<Option<Blob>> {
2200 if let Some(tree) = to_tree {
2201 return find_blob_in_tree(repo, tree, path);
2202 }
2203
2204 let worktree_path = repo.root().join(path);
2212 match std::fs::symlink_metadata(&worktree_path) {
2213 Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
2214 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2215 Err(error) => Err(error.into()),
2216 }
2217}
2218
2219fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
2229 let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
2230 is_symlink(old) == is_symlink(new)
2231}
2232
2233fn prepare_rename_blob(blob: Blob) -> PreparedRenameBlob {
2234 let content_hash = blob.hash();
2235 let text = blob.content_str().and_then(|text| {
2236 if text.chars().any(is_terminal_hostile_control) {
2237 return None;
2238 }
2239 let mut line_count = 0;
2240 let mut line_hash_counts = BTreeMap::new();
2241 for line in text.lines() {
2242 line_count += 1;
2243 *line_hash_counts.entry(cheap_line_hash(line)).or_insert(0) += 1;
2244 }
2245 Some(RenameTextFingerprint {
2246 line_count,
2247 line_hash_counts,
2248 })
2249 });
2250 PreparedRenameBlob {
2251 blob,
2252 content_hash,
2253 text,
2254 }
2255}
2256
2257fn cheap_line_hash(line: &str) -> u64 {
2258 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
2259 const FNV_PRIME: u64 = 0x100000001b3;
2260 line.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
2261 (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
2262 })
2263}
2264
2265fn can_reach_rename_threshold(
2266 old_text: &RenameTextFingerprint,
2267 new_text: &RenameTextFingerprint,
2268) -> bool {
2269 let total_lines = old_text.line_count + new_text.line_count;
2270 if old_text.line_count == 0 || new_text.line_count == 0 {
2271 return false;
2272 }
2273
2274 let length_upper_bound = old_text.line_count.min(new_text.line_count);
2275 if (length_upper_bound as u128) * 8 < (total_lines as u128) * 3 {
2276 return false;
2277 }
2278
2279 let shared_hash_upper_bound = old_text
2283 .line_hash_counts
2284 .iter()
2285 .filter_map(|(hash, old_count)| {
2286 new_text
2287 .line_hash_counts
2288 .get(hash)
2289 .map(|new_count| old_count.min(new_count))
2290 })
2291 .sum::<usize>();
2292 (shared_hash_upper_bound as u128) * 8 >= (total_lines as u128) * 3
2293}
2294
2295fn rename_similarity(
2296 old_blob: &PreparedRenameBlob,
2297 new_blob: &PreparedRenameBlob,
2298 stats: &mut RenameDetectionStats,
2299) -> f64 {
2300 if old_blob.content_hash == new_blob.content_hash
2301 && old_blob.blob.content() == new_blob.blob.content()
2302 {
2303 return 1.0;
2304 }
2305 let (Some(old_fingerprint), Some(new_fingerprint)) = (&old_blob.text, &new_blob.text) else {
2306 return 0.0;
2307 };
2308 if !can_reach_rename_threshold(old_fingerprint, new_fingerprint) {
2309 return 0.0;
2310 }
2311 let old_text = old_blob
2312 .blob
2313 .content_str()
2314 .expect("text fingerprint requires UTF-8 content");
2315 let new_text = new_blob
2316 .blob
2317 .content_str()
2318 .expect("text fingerprint requires UTF-8 content");
2319 let old_lines = old_text.lines().collect::<Vec<_>>();
2320 let new_lines = new_text.lines().collect::<Vec<_>>();
2321 stats.lcs_comparisons += 1;
2322 let shared = lcs_len(&old_lines, &new_lines);
2323 (shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
2324}
2325
2326fn lcs_len(left: &[&str], right: &[&str]) -> usize {
2327 let mut previous = vec![0usize; right.len() + 1];
2328 let mut current = vec![0usize; right.len() + 1];
2329 for left_line in left {
2330 for (index, right_line) in right.iter().enumerate() {
2331 current[index + 1] = if left_line == right_line {
2332 previous[index] + 1
2333 } else {
2334 previous[index + 1].max(current[index])
2335 };
2336 }
2337 std::mem::swap(&mut previous, &mut current);
2338 current.fill(0);
2339 }
2340 previous[right.len()]
2341}
2342
2343fn get_state_diff(
2355 repo: &Repository,
2356 from_tree: Option<&Tree>,
2357 to_tree: &Tree,
2358 path: &str,
2359 kind: &DiffKind,
2360) -> Result<(Vec<LineDiff>, FileEolState)> {
2361 match kind {
2362 DiffKind::Added => {
2363 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2364 return Ok((Vec::new(), FileEolState::default()));
2365 };
2366 let eol = eol_for_added(&new_blob);
2367 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2368 }
2369 DiffKind::Deleted => {
2370 let Some(tree) = from_tree else {
2371 return Ok((Vec::new(), FileEolState::default()));
2372 };
2373 let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
2374 return Ok((Vec::new(), FileEolState::default()));
2375 };
2376 let eol = eol_for_deleted(&old_blob);
2377 Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
2378 }
2379 DiffKind::Modified => {
2380 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2381 return Ok((Vec::new(), FileEolState::default()));
2382 };
2383 if let Some(tree) = from_tree
2384 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
2385 {
2386 return modified_blob_hunks(&old_blob, &new_blob);
2387 }
2388 let eol = eol_for_added(&new_blob);
2390 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2391 }
2392 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
2393 }
2394}
2395
2396fn eol_for_added(new_blob: &Blob) -> FileEolState {
2400 let (new_eol, new_count) = blob_eol_meta(new_blob);
2401 FileEolState {
2402 old_has_final_newline: true,
2403 new_has_final_newline: new_eol,
2404 old_line_count: 0,
2405 new_line_count: new_count,
2406 }
2407}
2408
2409fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
2410 let (old_eol, old_count) = blob_eol_meta(old_blob);
2411 FileEolState {
2412 old_has_final_newline: old_eol,
2413 new_has_final_newline: true,
2414 old_line_count: old_count,
2415 new_line_count: 0,
2416 }
2417}
2418
2419fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
2420 let (old_eol, old_count) = blob_eol_meta(old_blob);
2421 let (new_eol, new_count) = blob_eol_meta(new_blob);
2422 FileEolState {
2423 old_has_final_newline: old_eol,
2424 new_has_final_newline: new_eol,
2425 old_line_count: old_count,
2426 new_line_count: new_count,
2427 }
2428}
2429
2430fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
2435 let content = blob.content();
2436 if content.is_empty() {
2437 return (true, 0);
2438 }
2439 let has_eol = content.ends_with(b"\n");
2440 let line_count = blob
2441 .content_str()
2442 .map(|text| text.lines().count())
2443 .unwrap_or(0);
2444 (has_eol, line_count)
2445}
2446
2447fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
2448 let text = text_diff_content(blob)?;
2449 Ok(text
2450 .lines()
2451 .map(|line| LineDiff::new(prefix, line))
2452 .collect())
2453}
2454
2455fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
2469 if old.content() == new.content() {
2470 return Ok((Vec::new(), FileEolState::default()));
2471 }
2472 ensure_text_diffable(old)?;
2473 ensure_text_diffable(new)?;
2474 let eol = eol_for_modified(old, new);
2475 let diff = diff_blobs(old, new);
2476 let lines = diff
2477 .iter()
2478 .map(|l| LineDiff::new(l.prefix(), l.content()))
2479 .collect();
2480 Ok((number_lines(lines), eol))
2481}
2482
2483fn ensure_text_diffable(blob: &Blob) -> Result<()> {
2484 text_diff_content(blob).map(|_| ())
2485}
2486
2487fn text_diff_content(blob: &Blob) -> Result<&str> {
2488 let Some(text) = blob.content_str() else {
2489 return Err(anyhow!(BINARY_DIFF_ERROR));
2490 };
2491 if text.chars().any(is_terminal_hostile_control) {
2492 return Err(anyhow!(BINARY_DIFF_ERROR));
2493 }
2494 Ok(text)
2495}
2496
2497fn is_binary_diff_error(error: &anyhow::Error) -> bool {
2498 error.to_string() == BINARY_DIFF_ERROR
2499}
2500
2501fn is_terminal_hostile_control(ch: char) -> bool {
2502 ch.is_control() && ch != '\n' && ch != '\t'
2503}
2504
2505fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
2506 let mut old_line = 1usize;
2507 let mut new_line = 1usize;
2508
2509 lines
2510 .into_iter()
2511 .map(|line| {
2512 let old = if line.prefix != "+" {
2513 let current = Some(old_line);
2514 old_line += 1;
2515 current
2516 } else {
2517 None
2518 };
2519 let new = if line.prefix != "-" {
2520 let current = Some(new_line);
2521 new_line += 1;
2522 current
2523 } else {
2524 None
2525 };
2526 LineDiff::with_lines(line.prefix, line.content, old, new)
2527 })
2528 .collect()
2529}
2530
2531fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
2532 match find_entry_in_tree(repo, tree, path)? {
2533 Some(entry) => match entry.content_hash() {
2534 Some(hash) if entry.is_blob() || entry.is_symlink() => {
2535 Ok(Some(repo.require_blob(&hash)?))
2536 }
2537 _ => Ok(None),
2538 },
2539 None => Ok(None),
2540 }
2541}
2542
2543fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
2551 let parts: Vec<&str> = path.split('/').collect();
2552 find_entry_recursive(repo, tree, &parts)
2553}
2554
2555fn find_entry_recursive(
2556 repo: &Repository,
2557 tree: &Tree,
2558 parts: &[&str],
2559) -> Result<Option<TreeEntry>> {
2560 if parts.is_empty() {
2561 return Ok(None);
2562 }
2563
2564 let name = parts[0];
2565 let entry = match tree.get(name) {
2566 Some(e) => e,
2567 None => return Ok(None),
2568 };
2569
2570 if parts.len() == 1 {
2571 if entry.is_blob() || entry.entry_type() == EntryType::Symlink || entry.is_gitlink() {
2572 return Ok(Some(entry.clone()));
2573 }
2574 } else if entry.is_tree()
2575 && let Some(hash) = entry.tree_hash()
2576 && let Some(subtree) = repo.store().get_tree(&hash)?
2577 {
2578 return find_entry_recursive(repo, &subtree, &parts[1..]);
2579 }
2580
2581 Ok(None)
2582}
2583
2584fn worktree_file_mode(path: &Path) -> Option<FileMode> {
2589 let metadata = std::fs::symlink_metadata(path).ok()?;
2590 if metadata.file_type().is_symlink() {
2591 return Some(FileMode::Symlink);
2592 }
2593 #[cfg(unix)]
2594 {
2595 use std::os::unix::fs::PermissionsExt;
2596 if metadata.permissions().mode() & 0o111 != 0 {
2597 return Some(FileMode::Executable);
2598 }
2599 }
2600 Some(FileMode::Normal)
2601}
2602
2603fn change_file_modes(
2616 repo: &Repository,
2617 from_tree: Option<&Tree>,
2618 to_tree: Option<&Tree>,
2619 path: &str,
2620 kind: &str,
2621) -> (Option<FileMode>, Option<FileMode>) {
2622 let old_side = || {
2623 from_tree
2624 .and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
2625 .map(|entry| entry.mode())
2626 };
2627 let new_side = || match to_tree {
2628 Some(tree) => find_entry_in_tree(repo, tree, path)
2629 .ok()
2630 .flatten()
2631 .map(|entry| entry.mode()),
2632 None => worktree_file_mode(&repo.root().join(path)),
2633 };
2634 match kind {
2635 "added" => (None, new_side()),
2636 "deleted" => (None, old_side()),
2637 "modified" => (old_side(), new_side()),
2638 _ => (None, None),
2639 }
2640}
2641
2642#[cfg(test)]
2643mod tests {
2644 use objects::{
2645 object::{Blob, FileMode, Tree, TreeEntry},
2646 store::ObjectStore,
2647 };
2648 use repo::Repository;
2649 use tempfile::TempDir;
2650
2651 use super::{
2652 DiffStats, FileChange, FileEolState, LineCounts, LineDiff, RENAME_SIMILARITY_THRESHOLD,
2653 RenameDetectionStats, change_line_counts, detect_clear_renames_with_stats, lcs_len,
2654 prepare_rename_blob, rename_similarity, unified_hunks,
2655 };
2656
2657 type RenameSummary = Vec<(String, String, Option<String>, Option<f64>)>;
2658
2659 fn rename_fixture(
2660 shared_line_counts: &[usize],
2661 ) -> (TempDir, Repository, Tree, Tree, Vec<FileChange>) {
2662 let temp = TempDir::new().expect("create rename fixture");
2663 let repo = Repository::init_default(temp.path()).expect("initialize rename fixture");
2664 let mut old_entries = Vec::with_capacity(shared_line_counts.len());
2665 let mut new_entries = Vec::with_capacity(shared_line_counts.len());
2666 let mut changes = Vec::with_capacity(shared_line_counts.len() * 2);
2667
2668 for (file_index, shared_lines) in shared_line_counts.iter().copied().enumerate() {
2669 let old_content = (0..32)
2670 .map(|line_index| format!("file {file_index} original line {line_index}\n"))
2671 .collect::<String>();
2672 let new_content = (0..32)
2673 .map(|line_index| {
2674 if line_index < shared_lines {
2675 format!("file {file_index} original line {line_index}\n")
2676 } else {
2677 format!("file {file_index} replacement line {line_index}\n")
2678 }
2679 })
2680 .collect::<String>();
2681 let old_hash = repo
2682 .store()
2683 .put_blob(&Blob::from(old_content))
2684 .expect("store old rename blob");
2685 let new_hash = repo
2686 .store()
2687 .put_blob(&Blob::from(new_content))
2688 .expect("store new rename blob");
2689 let old_path = format!("old_{file_index:04}.txt");
2690 let new_path = format!("new_{file_index:04}.txt");
2691 old_entries
2692 .push(TreeEntry::file(&old_path, old_hash, false).expect("build old tree entry"));
2693 new_entries
2694 .push(TreeEntry::file(&new_path, new_hash, false).expect("build new tree entry"));
2695 changes.push(FileChange {
2696 path: old_path,
2697 kind: "deleted".to_string(),
2698 mode: Some(FileMode::Normal),
2699 ..Default::default()
2700 });
2701 changes.push(FileChange {
2702 path: new_path,
2703 kind: "added".to_string(),
2704 mode: Some(FileMode::Normal),
2705 ..Default::default()
2706 });
2707 }
2708
2709 (
2710 temp,
2711 repo,
2712 Tree::from_entries(old_entries),
2713 Tree::from_entries(new_entries),
2714 changes,
2715 )
2716 }
2717
2718 fn run_rename_fixture(shared_line_counts: &[usize]) -> (RenameSummary, RenameDetectionStats) {
2719 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(shared_line_counts);
2720 let mut stats = RenameDetectionStats::default();
2721 let output = detect_clear_renames_with_stats(
2722 &repo,
2723 Some(&old_tree),
2724 Some(&new_tree),
2725 changes,
2726 false,
2727 0,
2728 &mut stats,
2729 )
2730 .expect("detect fixture renames");
2731 let summary = output
2732 .into_iter()
2733 .map(|change| {
2734 (
2735 change.path,
2736 change.kind,
2737 change.old_path,
2738 change.similarity_score,
2739 )
2740 })
2741 .collect();
2742 (summary, stats)
2743 }
2744
2745 #[test]
2746 fn rename_fixture_characterizes_exact_threshold_and_rejected_pairs() {
2747 let (summary, _) = run_rename_fixture(&[32, 31, 24, 23]);
2748
2749 assert_eq!(
2750 summary,
2751 vec![
2752 (
2753 "new_0000.txt".to_string(),
2754 "renamed".to_string(),
2755 Some("old_0000.txt".to_string()),
2756 Some(1.0),
2757 ),
2758 (
2759 "new_0001.txt".to_string(),
2760 "renamed".to_string(),
2761 Some("old_0001.txt".to_string()),
2762 Some(31.0 / 32.0),
2763 ),
2764 (
2765 "new_0002.txt".to_string(),
2766 "renamed".to_string(),
2767 Some("old_0002.txt".to_string()),
2768 Some(0.75),
2769 ),
2770 (
2771 "old_0003.txt".to_string(),
2772 "deleted".to_string(),
2773 None,
2774 None,
2775 ),
2776 ("new_0003.txt".to_string(), "added".to_string(), None, None,),
2777 ]
2778 );
2779 }
2780
2781 #[test]
2782 fn many_rename_detection_reads_each_blob_once_and_limits_lcs_to_candidates() {
2783 const FILE_COUNT: usize = 32;
2784 let (summary, stats) = run_rename_fixture(&[31; FILE_COUNT]);
2785
2786 assert_eq!(summary.len(), FILE_COUNT);
2787 assert!(summary.iter().all(|(_, kind, _, _)| kind == "renamed"));
2788 assert_eq!(
2789 stats.blob_reads,
2790 FILE_COUNT * 2,
2791 "each old and added blob should be read once per diff"
2792 );
2793 assert_eq!(
2794 stats.lcs_comparisons, FILE_COUNT,
2795 "only the one plausible modified target per deleted file should reach LCS"
2796 );
2797 assert_eq!(stats.total_possible_pairs, FILE_COUNT * FILE_COUNT);
2798 assert_eq!(
2799 stats.qualifying_candidate_pairs, FILE_COUNT,
2800 "only threshold-qualified pairs should enter deterministic assignment"
2801 );
2802 assert!(stats.qualifying_candidate_pairs < stats.total_possible_pairs);
2803 }
2804
2805 #[test]
2806 fn rename_prefilter_preserves_all_qualifying_short_line_pairs() {
2807 let mut contents = vec![String::new()];
2808 for line_count in 1..=5 {
2809 for bits in 0..(1usize << line_count) {
2810 let content = (0..line_count)
2811 .map(|line| {
2812 if bits & (1 << line) == 0 {
2813 "alpha"
2814 } else {
2815 "beta"
2816 }
2817 })
2818 .collect::<Vec<_>>()
2819 .join("\n");
2820 contents.push(content);
2821 }
2822 }
2823
2824 for old_content in &contents {
2825 for new_content in &contents {
2826 let old_lines = old_content.lines().collect::<Vec<_>>();
2827 let new_lines = new_content.lines().collect::<Vec<_>>();
2828 let expected = if old_content == new_content {
2829 1.0
2830 } else if old_lines.is_empty() || new_lines.is_empty() {
2831 0.0
2832 } else {
2833 (lcs_len(&old_lines, &new_lines) * 2) as f64
2834 / (old_lines.len() + new_lines.len()) as f64
2835 };
2836 if expected < RENAME_SIMILARITY_THRESHOLD {
2837 continue;
2838 }
2839
2840 let old_blob = prepare_rename_blob(Blob::from(old_content.clone()));
2841 let new_blob = prepare_rename_blob(Blob::from(new_content.clone()));
2842 let actual =
2843 rename_similarity(&old_blob, &new_blob, &mut RenameDetectionStats::default());
2844 assert_eq!(
2845 actual, expected,
2846 "qualifying pair changed score: old={old_content:?}, new={new_content:?}"
2847 );
2848 }
2849 }
2850 }
2851
2852 #[test]
2853 #[ignore = "focused release-mode wall-time measurement"]
2854 fn benchmark_many_modified_renames() {
2855 use std::time::Instant;
2856
2857 const FILE_COUNT: usize = 128;
2858 const SAMPLES: usize = 7;
2859 let shared_line_counts = vec![31; FILE_COUNT];
2860 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(&shared_line_counts);
2861 let mut samples = Vec::with_capacity(SAMPLES);
2862 let mut final_stats = RenameDetectionStats::default();
2863
2864 for _ in 0..SAMPLES {
2865 let mut stats = RenameDetectionStats::default();
2866 let started = Instant::now();
2867 let output = detect_clear_renames_with_stats(
2868 &repo,
2869 Some(&old_tree),
2870 Some(&new_tree),
2871 changes.clone(),
2872 false,
2873 0,
2874 &mut stats,
2875 )
2876 .expect("benchmark rename detection");
2877 assert_eq!(output.len(), FILE_COUNT);
2878 samples.push(started.elapsed());
2879 final_stats = stats;
2880 }
2881 samples.sort();
2882 eprintln!(
2883 "rename_diff files={FILE_COUNT} samples={SAMPLES} median_ms={:.3} blob_reads={} lcs_comparisons={}",
2884 samples[SAMPLES / 2].as_secs_f64() * 1_000.0,
2885 final_stats.blob_reads,
2886 final_stats.lcs_comparisons,
2887 );
2888 }
2889
2890 fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
2891 FileChange {
2892 path: "notes.txt".to_string(),
2893 kind: kind.to_string(),
2894 line_counts: Some(counts),
2895 ..Default::default()
2896 }
2897 }
2898
2899 #[test]
2906 fn diff_stats_reads_line_counts_when_hunks_dropped() {
2907 let changes = vec![stat_change(
2908 "modified",
2909 LineCounts {
2910 added: 1,
2911 modified: 0,
2912 deleted: 0,
2913 },
2914 )];
2915
2916 let stats = DiffStats::from_changes(&changes, None);
2917
2918 assert_eq!(stats.files_changed, 1);
2919 assert_eq!(stats.additions, 1);
2920 assert_eq!(stats.modifications, 0);
2921 assert_eq!(stats.deletions, 0);
2922 assert_eq!(stats.renames, 0);
2923 }
2924
2925 #[test]
2930 fn diff_stats_treats_zero_line_counts_as_authoritative() {
2931 let changes = vec![stat_change(
2932 "modified",
2933 LineCounts {
2934 added: 0,
2935 modified: 0,
2936 deleted: 0,
2937 },
2938 )];
2939
2940 let stats = DiffStats::from_changes(&changes, None);
2941
2942 assert_eq!(stats.modifications, 0);
2943 assert_eq!(stats.additions, 0);
2944 assert_eq!(stats.deletions, 0);
2945 }
2946
2947 #[test]
2950 fn change_line_counts_pairs_modified_lines() {
2951 let lines = vec![
2952 LineDiff::with_lines("-", "alpha", Some(1), None),
2953 LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
2954 LineDiff::with_lines("+", "fresh", None, Some(2)),
2955 ];
2956 let counts = change_line_counts(Some(&lines));
2957 assert_eq!(counts.modified, 1);
2958 assert_eq!(counts.added, 1);
2959 assert_eq!(counts.deleted, 0);
2960 }
2961
2962 #[test]
2968 fn unified_hunks_keeps_added_decoration_in_canonical_body() {
2969 let lines = vec![
2970 LineDiff::with_lines("+", "#[test]", None, Some(1)),
2971 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
2972 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
2973 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
2974 ];
2975
2976 let hunk = unified_hunks(lines, 3, &FileEolState::default());
2977
2978 let header = hunk
2979 .iter()
2980 .find(|line| line.prefix == "@")
2981 .expect("hunk should carry an `@@` header");
2982 assert_eq!(
2984 header.content, "@ -1,2 +1,4 @@",
2985 "header counts must match the untrimmed body: {hunk:?}"
2986 );
2987 assert!(
2988 hunk.iter()
2989 .any(|line| line.prefix == "+" && line.content == "#[test]"),
2990 "added decoration line must survive in the canonical body: {hunk:?}"
2991 );
2992 assert!(
2993 hunk.iter()
2994 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
2995 "added function body should remain: {hunk:?}"
2996 );
2997 }
2998
2999 #[test]
3003 fn display_trim_drops_added_decoration_but_keeps_header() {
3004 use super::trim_added_decorations_for_display;
3005
3006 let lines = vec![
3007 LineDiff::with_lines("+", "#[test]", None, Some(1)),
3008 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
3009 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
3010 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
3011 ];
3012 let hunk = unified_hunks(lines, 3, &FileEolState::default());
3013
3014 let display = trim_added_decorations_for_display(&hunk);
3015
3016 assert!(
3017 display
3018 .iter()
3019 .filter(|line| line.content == "#[test]")
3020 .all(|line| line.prefix == " "),
3021 "display trim should let existing context own the decoration: {display:?}"
3022 );
3023 assert!(
3024 display
3025 .iter()
3026 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
3027 "added function body should remain after display trim: {display:?}"
3028 );
3029 assert_eq!(
3030 display
3031 .iter()
3032 .find(|line| line.prefix == "@")
3033 .map(|l| l.content.as_str()),
3034 Some("@ -1,2 +1,4 @@"),
3035 "display trim must not rewrite the `@@` header: {display:?}"
3036 );
3037 }
3038
3039 #[test]
3042 fn minimal_resolve_failure_maps_to_recovery_state_not_found() {
3043 use objects::{RecoveryDetails, error::HeddleError};
3044 use repo::{
3045 ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
3046 };
3047 use tempfile::TempDir;
3048
3049 let temp = TempDir::new().unwrap();
3050 let repo = repo::Repository::init_default(temp.path()).unwrap();
3051 std::fs::write(temp.path().join("a.txt"), "a").unwrap();
3052 repo.snapshot(Some("seed".into()), None).unwrap();
3053
3054 let err = resolve_state_for_command(&repo, "hs-zzzzzzzzzzzz", ResolvePolicy::minimal())
3055 .unwrap_err();
3056 let mapped = match err {
3057 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
3058 HeddleError::recovery(RecoveryDetails::state_not_found(spec))
3059 }
3060 other => panic!("expected not-found failure, got {other:?}"),
3061 };
3062 assert!(matches!(mapped, HeddleError::Recovery(_)));
3063 assert!(
3064 mapped.to_string().contains("State not found"),
3065 "unexpected message: {mapped}"
3066 );
3067 }
3068}