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
1555pub fn compute_projected_tree_diff(
1559 repo: &Repository,
1560 from_tree: &Tree,
1561 to_tree: &Tree,
1562 from_label: impl Into<String>,
1563 to_label: impl Into<String>,
1564 unified: usize,
1565) -> Result<DiffReport> {
1566 let from_hash = repo.store().put_tree(from_tree)?;
1567 let to_hash = repo.store().put_tree(to_tree)?;
1568 let changes = repo.diff_trees(&from_hash, &to_hash)?;
1569 let file_changes: Vec<FileChange> = changes
1570 .iter()
1571 .map(|change| {
1572 build_state_change(
1573 repo,
1574 Some(from_tree),
1575 to_tree,
1576 &change.path,
1577 &change.kind.to_string(),
1578 change.kind,
1579 unified,
1580 )
1581 })
1582 .collect();
1583 let file_changes = sort_changes_by_path(file_changes);
1584 let file_changes = expand_type_changes(
1585 repo,
1586 Some(from_tree),
1587 Some(to_tree),
1588 file_changes,
1589 true,
1590 unified,
1591 )?;
1592 let file_changes = detect_clear_renames(
1593 repo,
1594 Some(from_tree),
1595 Some(to_tree),
1596 file_changes,
1597 true,
1598 unified,
1599 )?;
1600 let mut output = DiffReport::new(
1601 Some(from_label.into()),
1602 Some(to_label.into()),
1603 file_changes,
1604 None,
1605 None,
1606 None,
1607 );
1608 populate_patch_text(&mut output);
1609 Ok(output)
1610}
1611
1612fn strip_line_hunks(changes: Vec<FileChange>) -> Vec<FileChange> {
1613 changes
1614 .into_iter()
1615 .map(|mut change| {
1616 change.lines = None;
1617 change
1618 })
1619 .collect()
1620}
1621
1622fn unified_hunks(lines: Vec<LineDiff>, context: usize, eol: &FileEolState) -> Vec<LineDiff> {
1623 if lines.is_empty() {
1624 return lines;
1625 }
1626 if !lines.iter().any(|line| line.prefix != " ") {
1627 if eol.old_has_final_newline == eol.new_has_final_newline {
1635 return lines;
1636 }
1637 return eol_only_tail_hunk(lines, context);
1638 }
1639
1640 let mut ranges = Vec::<(usize, usize)>::new();
1641 let mut cursor = 0usize;
1642 while cursor < lines.len() {
1643 while cursor < lines.len() && lines[cursor].prefix == " " {
1644 cursor += 1;
1645 }
1646 if cursor >= lines.len() {
1647 break;
1648 }
1649
1650 let start = cursor.saturating_sub(context);
1651 while cursor < lines.len() && lines[cursor].prefix != " " {
1652 cursor += 1;
1653 }
1654 let mut end = (cursor + context).min(lines.len());
1655
1656 while cursor < lines.len() && lines[cursor].prefix == " " && cursor < end {
1657 cursor += 1;
1658 }
1659 while cursor < lines.len() && lines[cursor].prefix != " " {
1660 end = (cursor + 1 + context).min(lines.len());
1661 cursor += 1;
1662 }
1663
1664 if let Some((_, previous_end)) = ranges.last_mut()
1665 && start <= *previous_end
1666 {
1667 *previous_end = end;
1668 continue;
1669 }
1670 ranges.push((start, end));
1671 }
1672
1673 let mut output = Vec::new();
1674 for (start, end) in ranges {
1675 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1676 output.push(LineDiff {
1677 prefix: "@".to_string(),
1678 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1679 old_line: None,
1680 new_line: None,
1681 });
1682 output.extend_from_slice(&lines[start..end]);
1690 }
1691 output
1692}
1693
1694fn eol_only_tail_hunk(lines: Vec<LineDiff>, context: usize) -> Vec<LineDiff> {
1701 let end = lines.len();
1702 let start = end.saturating_sub(context + 1);
1703 let (old_start, old_len, new_start, new_len) = hunk_span(&lines, start, end);
1704 let mut output = Vec::with_capacity(end - start + 1);
1705 output.push(LineDiff {
1706 prefix: "@".to_string(),
1707 content: format!("@ -{},{} +{},{} @@", old_start, old_len, new_start, new_len),
1708 old_line: None,
1709 new_line: None,
1710 });
1711 output.extend_from_slice(&lines[start..end]);
1712 output
1713}
1714
1715pub fn trim_added_decorations_for_display(lines: &[LineDiff]) -> Vec<LineDiff> {
1730 let mut output = Vec::with_capacity(lines.len());
1731 let mut body_start = 0usize;
1732 for (index, line) in lines.iter().enumerate() {
1733 if line.prefix == "@" {
1734 if body_start < index {
1735 output.extend(trim_trailing_added_decorations(&lines[body_start..index]));
1736 }
1737 output.push(line.clone());
1738 body_start = index + 1;
1739 }
1740 }
1741 if body_start < lines.len() {
1742 output.extend(trim_trailing_added_decorations(&lines[body_start..]));
1743 }
1744 output
1745}
1746
1747fn trim_trailing_added_decorations(lines: &[LineDiff]) -> Vec<LineDiff> {
1748 let mut trimmed = Vec::with_capacity(lines.len());
1749 let mut index = 0usize;
1750 while index < lines.len() {
1751 if lines[index].prefix == "+"
1752 && is_visual_decoration_line(&lines[index].content)
1753 && let Some(next_context) = next_context_line(lines, index + 1)
1754 && next_context.content == lines[index].content
1755 {
1756 let added_block_has_code = lines[index + 1..next_context.index]
1757 .iter()
1758 .any(|line| line.prefix == "+" && !is_blank_or_visual_decoration(&line.content));
1759 if added_block_has_code {
1760 index += 1;
1761 continue;
1762 }
1763 }
1764 trimmed.push(lines[index].clone());
1765 index += 1;
1766 }
1767 trimmed
1768}
1769
1770struct IndexedLine<'a> {
1771 index: usize,
1772 content: &'a str,
1773}
1774
1775fn next_context_line(lines: &[LineDiff], start: usize) -> Option<IndexedLine<'_>> {
1776 lines[start..]
1777 .iter()
1778 .enumerate()
1779 .find(|(_, line)| line.prefix == " ")
1780 .map(|(offset, line)| IndexedLine {
1781 index: start + offset,
1782 content: &line.content,
1783 })
1784}
1785
1786fn is_blank_or_visual_decoration(line: &str) -> bool {
1787 line.trim().is_empty() || is_visual_decoration_line(line)
1788}
1789
1790fn is_visual_decoration_line(line: &str) -> bool {
1791 let trimmed = line.trim_start();
1792 trimmed.starts_with("#[")
1793 || trimmed.starts_with("#![")
1794 || trimmed.starts_with('@')
1795 || trimmed.starts_with("///")
1796 || trimmed.starts_with("//!")
1797}
1798
1799fn hunk_span(lines: &[LineDiff], start: usize, end: usize) -> (usize, usize, usize, usize) {
1800 let old_before = lines[..start]
1801 .iter()
1802 .filter(|line| line.prefix != "+")
1803 .count();
1804 let new_before = lines[..start]
1805 .iter()
1806 .filter(|line| line.prefix != "-")
1807 .count();
1808 let old_len = lines[start..end]
1809 .iter()
1810 .filter(|line| line.prefix != "+")
1811 .count();
1812 let new_len = lines[start..end]
1813 .iter()
1814 .filter(|line| line.prefix != "-")
1815 .count();
1816
1817 let old_start = if old_len == 0 {
1818 old_before
1819 } else {
1820 old_before + 1
1821 };
1822 let new_start = if new_len == 0 {
1823 new_before
1824 } else {
1825 new_before + 1
1826 };
1827 (old_start, old_len, new_start, new_len)
1828}
1829
1830fn get_worktree_diff(
1831 repo: &Repository,
1832 from_tree: Option<&Tree>,
1833 path: &str,
1834 kind: &DiffKind,
1835) -> Result<(Vec<LineDiff>, FileEolState)> {
1836 let worktree_path = repo.root().join(path);
1837
1838 match kind {
1839 DiffKind::Added => {
1840 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1841 let eol = eol_for_added(&new_blob);
1842 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1843 }
1844 DiffKind::Deleted => {
1845 if let Some(tree) = from_tree
1849 && let Some(blob) = find_blob_in_tree(repo, tree, path)?
1850 {
1851 let eol = eol_for_deleted(&blob);
1852 return Ok((number_lines(blob_lines(&blob, "-")?), eol));
1853 }
1854 Ok((vec![], FileEolState::default()))
1855 }
1856 DiffKind::Modified => {
1857 let new_blob = read_worktree_blob_for_diff(&worktree_path)?;
1858
1859 if let Some(tree) = from_tree
1860 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
1861 {
1862 return modified_blob_hunks(&old_blob, &new_blob);
1863 }
1864
1865 let eol = eol_for_added(&new_blob);
1866 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
1867 }
1868 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
1869 }
1870}
1871
1872fn worktree_modified_type_change(
1888 repo_root: &Path,
1889 path: &str,
1890 diff_kind: DiffKind,
1891) -> Option<(&'static str, DiffKind)> {
1892 if matches!(diff_kind, DiffKind::Modified)
1893 && worktree_side_kind(&repo_root.join(path)) == SideKind::Dir
1894 {
1895 Some(("deleted", DiffKind::Deleted))
1896 } else {
1897 None
1898 }
1899}
1900
1901fn read_worktree_blob_for_diff(path: &std::path::Path) -> Result<Blob> {
1902 let metadata = std::fs::symlink_metadata(path)?;
1903 if metadata.file_type().is_symlink() {
1904 let target = std::fs::read_link(path)?;
1905 return Ok(Blob::new(objects::util::symlink_target_bytes(&target)));
1906 }
1907 Ok(Blob::new(std::fs::read(path)?))
1908}
1909
1910fn is_symlink_mode(mode: Option<FileMode>) -> bool {
1911 matches!(mode, Some(FileMode::Symlink))
1912}
1913
1914fn symlink_sides(kind: &str, old_mode: Option<FileMode>, mode: Option<FileMode>) -> (bool, bool) {
1922 match kind {
1923 "added" => (false, is_symlink_mode(mode)),
1924 "deleted" => (is_symlink_mode(mode), false),
1925 _ => (is_symlink_mode(old_mode), is_symlink_mode(mode)),
1926 }
1927}
1928
1929fn make_symlink_change(old: Option<Vec<u8>>, new: Option<Vec<u8>>) -> Option<SymlinkChange> {
1939 (old.is_some() || new.is_some()).then_some(SymlinkChange { old, new })
1940}
1941
1942fn symlink_change_from_blobs(
1946 kind: &str,
1947 old_blob: Option<&Blob>,
1948 old_mode: Option<FileMode>,
1949 new_blob: Option<&Blob>,
1950 mode: Option<FileMode>,
1951) -> Option<SymlinkChange> {
1952 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1953 let old = old_is_link
1954 .then(|| old_blob.map(|blob| blob.content().to_vec()))
1955 .flatten();
1956 let new = new_is_link
1957 .then(|| new_blob.map(|blob| blob.content().to_vec()))
1958 .flatten();
1959 make_symlink_change(old, new)
1960}
1961
1962#[allow(clippy::too_many_arguments)]
1969fn symlink_change_for_paths(
1970 repo: &Repository,
1971 from_tree: Option<&Tree>,
1972 to_tree: Option<&Tree>,
1973 kind: &str,
1974 old_path: &str,
1975 new_path: &str,
1976 old_mode: Option<FileMode>,
1977 mode: Option<FileMode>,
1978) -> Option<SymlinkChange> {
1979 let (old_is_link, new_is_link) = symlink_sides(kind, old_mode, mode);
1980 let old = old_is_link
1981 .then(|| blob_from_tree(repo, from_tree, old_path).ok().flatten())
1982 .flatten()
1983 .map(|blob| blob.content().to_vec());
1984 let new = new_is_link
1985 .then(|| new_blob_for_rename(repo, to_tree, new_path).ok().flatten())
1986 .flatten()
1987 .map(|blob| blob.content().to_vec());
1988 make_symlink_change(old, new)
1989}
1990fn detect_clear_renames(
1991 repo: &Repository,
1992 from_tree: Option<&Tree>,
1993 to_tree: Option<&Tree>,
1994 changes: Vec<FileChange>,
1995 include_lines: bool,
1996 unified: usize,
1997) -> Result<Vec<FileChange>> {
1998 detect_clear_renames_with_stats(
1999 repo,
2000 from_tree,
2001 to_tree,
2002 changes,
2003 include_lines,
2004 unified,
2005 &mut RenameDetectionStats::default(),
2006 )
2007}
2008
2009#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2010struct RenameDetectionStats {
2011 blob_reads: usize,
2012 lcs_comparisons: usize,
2013 total_possible_pairs: usize,
2014 qualifying_candidate_pairs: usize,
2015}
2016
2017struct PreparedRenameBlob {
2018 blob: Blob,
2019 content_hash: ContentHash,
2020 text: Option<RenameTextFingerprint>,
2021}
2022
2023struct RenameTextFingerprint {
2024 line_count: usize,
2025 line_hash_counts: BTreeMap<u64, usize>,
2026}
2027
2028#[allow(clippy::too_many_arguments)]
2029fn detect_clear_renames_with_stats(
2030 repo: &Repository,
2031 from_tree: Option<&Tree>,
2032 to_tree: Option<&Tree>,
2033 changes: Vec<FileChange>,
2034 include_lines: bool,
2035 unified: usize,
2036 stats: &mut RenameDetectionStats,
2037) -> Result<Vec<FileChange>> {
2038 let mut deleted = changes
2039 .iter()
2040 .filter(|change| change.kind == "deleted")
2041 .map(|change| change.path.as_str())
2042 .collect::<Vec<_>>();
2043 let mut added = changes
2044 .iter()
2045 .filter(|change| change.kind == "added")
2046 .map(|change| change.path.as_str())
2047 .collect::<Vec<_>>();
2048 deleted.sort_unstable();
2049 added.sort_unstable();
2050 if deleted.is_empty() || added.is_empty() {
2051 return Ok(changes);
2052 }
2053 stats.total_possible_pairs = deleted.len().saturating_mul(added.len());
2054
2055 let deleted_side_modes = changes
2065 .iter()
2066 .filter(|change| change.kind == "deleted")
2067 .map(|change| (change.path.as_str(), change.mode))
2068 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
2069 let added_side_modes = changes
2070 .iter()
2071 .filter(|change| change.kind == "added")
2072 .map(|change| (change.path.as_str(), change.mode))
2073 .collect::<std::collections::BTreeMap<&str, Option<FileMode>>>();
2074
2075 let mut added_blobs = BTreeMap::new();
2076 for new_path in &added {
2077 stats.blob_reads += 1;
2078 if let Some(blob) = new_blob_for_rename(repo, to_tree, new_path)? {
2079 added_blobs.insert(*new_path, prepare_rename_blob(blob));
2080 }
2081 }
2082
2083 let mut candidates = RenameCandidateIndex::new(deleted.len(), added.len());
2084 for (old_index, old_path) in deleted.iter().enumerate() {
2085 stats.blob_reads += 1;
2086 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2087 continue;
2088 };
2089 let old_blob = prepare_rename_blob(old_blob);
2090 for (new_index, new_path) in added.iter().enumerate() {
2091 if old_path == new_path {
2096 continue;
2097 }
2098 if !rename_mode_compatible(
2105 deleted_side_modes.get(old_path).copied().flatten(),
2106 added_side_modes.get(new_path).copied().flatten(),
2107 ) {
2108 continue;
2109 }
2110 let Some(new_blob) = added_blobs.get(new_path) else {
2111 continue;
2112 };
2113 let score = rename_similarity(&old_blob, new_blob, stats);
2114 if score >= RENAME_SIMILARITY_THRESHOLD {
2115 candidates.push(old_index, new_index, score);
2116 }
2117 }
2118 }
2119 stats.qualifying_candidate_pairs = candidates.candidate_count();
2120
2121 let renames = candidates
2122 .assign()
2123 .into_iter()
2124 .map(|assignment| {
2125 (
2126 deleted[assignment.source_index].to_string(),
2127 added[assignment.target_index].to_string(),
2128 assignment.score,
2129 )
2130 })
2131 .collect::<Vec<_>>();
2132 if renames.is_empty() {
2133 return Ok(changes);
2134 }
2135
2136 let rename_by_new = renames
2137 .iter()
2138 .map(|(old_path, new_path, score)| (new_path.as_str(), (old_path.as_str(), *score)))
2139 .collect::<std::collections::BTreeMap<_, _>>();
2140 let removed_old = renames
2141 .iter()
2142 .map(|(old_path, _, _)| old_path.as_str())
2143 .collect::<BTreeSet<_>>();
2144 let deleted_modes = changes
2150 .iter()
2151 .filter(|change| change.kind == "deleted")
2152 .map(|change| (change.path.clone(), change.mode))
2153 .collect::<std::collections::BTreeMap<String, Option<FileMode>>>();
2154
2155 let mut output = Vec::with_capacity(changes.len() - renames.len());
2156 for mut change in changes {
2157 if change.kind == "deleted" && removed_old.contains(change.path.as_str()) {
2158 continue;
2159 }
2160 if change.kind == "added"
2161 && let Some((old_path, score)) = rename_by_new.get(change.path.as_str()).copied()
2162 {
2163 let (lines, eol) = if include_lines {
2164 match rename_lines(repo, from_tree, to_tree, old_path, &change.path, unified) {
2165 Ok(Some((lines, eol))) => (Some(lines), eol),
2166 Ok(None) => (None, FileEolState::default()),
2167 Err(error) if is_binary_diff_error(&error) => {
2168 change.binary = true;
2169 (None, FileEolState::default())
2170 }
2171 Err(error) => return Err(error),
2172 }
2173 } else {
2174 (None, FileEolState::default())
2175 };
2176 change.kind = "renamed".to_string();
2177 change.old_path = Some(old_path.to_string());
2178 change.similarity_score = Some(score);
2179 change.lines = lines;
2180 change.eol = eol;
2181 change.old_mode = deleted_modes.get(old_path).copied().flatten();
2185 change.symlink = symlink_change_for_paths(
2192 repo,
2193 from_tree,
2194 to_tree,
2195 "renamed",
2196 old_path,
2197 &change.path,
2198 change.old_mode,
2199 change.mode,
2200 );
2201 if change.symlink.is_some() {
2202 change.binary = false;
2203 }
2204 change.line_counts = None;
2211 }
2212 output.push(change);
2213 }
2214 Ok(output)
2215}
2216
2217fn rename_lines(
2218 repo: &Repository,
2219 from_tree: Option<&Tree>,
2220 to_tree: Option<&Tree>,
2221 old_path: &str,
2222 new_path: &str,
2223 unified: usize,
2224) -> Result<Option<(Vec<LineDiff>, FileEolState)>> {
2225 let Some(old_blob) = blob_from_tree(repo, from_tree, old_path)? else {
2226 return Ok(None);
2227 };
2228 let Some(new_blob) = new_blob_for_rename(repo, to_tree, new_path)? else {
2229 return Ok(None);
2230 };
2231 ensure_text_diffable(&old_blob)?;
2232 ensure_text_diffable(&new_blob)?;
2233 let eol = eol_for_modified(&old_blob, &new_blob);
2234 let diff = diff_blobs(&old_blob, &new_blob);
2235 let lines = diff
2236 .iter()
2237 .map(|line| LineDiff::new(line.prefix(), line.content()))
2238 .collect();
2239 Ok(Some((
2240 unified_hunks(number_lines(lines), unified, &eol),
2241 eol,
2242 )))
2243}
2244
2245fn blob_from_tree(repo: &Repository, tree: Option<&Tree>, path: &str) -> Result<Option<Blob>> {
2246 let Some(tree) = tree else {
2247 return Ok(None);
2248 };
2249 find_blob_in_tree(repo, tree, path)
2250}
2251
2252fn new_blob_for_rename(
2253 repo: &Repository,
2254 to_tree: Option<&Tree>,
2255 path: &str,
2256) -> Result<Option<Blob>> {
2257 if let Some(tree) = to_tree {
2258 return find_blob_in_tree(repo, tree, path);
2259 }
2260
2261 let worktree_path = repo.root().join(path);
2269 match std::fs::symlink_metadata(&worktree_path) {
2270 Ok(_) => Ok(Some(read_worktree_blob_for_diff(&worktree_path)?)),
2271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2272 Err(error) => Err(error.into()),
2273 }
2274}
2275
2276fn rename_mode_compatible(old: Option<FileMode>, new: Option<FileMode>) -> bool {
2286 let is_symlink = |mode: Option<FileMode>| matches!(mode, Some(FileMode::Symlink));
2287 is_symlink(old) == is_symlink(new)
2288}
2289
2290fn prepare_rename_blob(blob: Blob) -> PreparedRenameBlob {
2291 let content_hash = blob.hash();
2292 let text = blob.content_str().and_then(|text| {
2293 if text.chars().any(is_terminal_hostile_control) {
2294 return None;
2295 }
2296 let mut line_count = 0;
2297 let mut line_hash_counts = BTreeMap::new();
2298 for line in text.lines() {
2299 line_count += 1;
2300 *line_hash_counts.entry(cheap_line_hash(line)).or_insert(0) += 1;
2301 }
2302 Some(RenameTextFingerprint {
2303 line_count,
2304 line_hash_counts,
2305 })
2306 });
2307 PreparedRenameBlob {
2308 blob,
2309 content_hash,
2310 text,
2311 }
2312}
2313
2314fn cheap_line_hash(line: &str) -> u64 {
2315 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
2316 const FNV_PRIME: u64 = 0x100000001b3;
2317 line.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
2318 (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
2319 })
2320}
2321
2322fn can_reach_rename_threshold(
2323 old_text: &RenameTextFingerprint,
2324 new_text: &RenameTextFingerprint,
2325) -> bool {
2326 let total_lines = old_text.line_count + new_text.line_count;
2327 if old_text.line_count == 0 || new_text.line_count == 0 {
2328 return false;
2329 }
2330
2331 let length_upper_bound = old_text.line_count.min(new_text.line_count);
2332 if (length_upper_bound as u128) * 8 < (total_lines as u128) * 3 {
2333 return false;
2334 }
2335
2336 let shared_hash_upper_bound = old_text
2340 .line_hash_counts
2341 .iter()
2342 .filter_map(|(hash, old_count)| {
2343 new_text
2344 .line_hash_counts
2345 .get(hash)
2346 .map(|new_count| old_count.min(new_count))
2347 })
2348 .sum::<usize>();
2349 (shared_hash_upper_bound as u128) * 8 >= (total_lines as u128) * 3
2350}
2351
2352fn rename_similarity(
2353 old_blob: &PreparedRenameBlob,
2354 new_blob: &PreparedRenameBlob,
2355 stats: &mut RenameDetectionStats,
2356) -> f64 {
2357 if old_blob.content_hash == new_blob.content_hash
2358 && old_blob.blob.content() == new_blob.blob.content()
2359 {
2360 return 1.0;
2361 }
2362 let (Some(old_fingerprint), Some(new_fingerprint)) = (&old_blob.text, &new_blob.text) else {
2363 return 0.0;
2364 };
2365 if !can_reach_rename_threshold(old_fingerprint, new_fingerprint) {
2366 return 0.0;
2367 }
2368 let old_text = old_blob
2369 .blob
2370 .content_str()
2371 .expect("text fingerprint requires UTF-8 content");
2372 let new_text = new_blob
2373 .blob
2374 .content_str()
2375 .expect("text fingerprint requires UTF-8 content");
2376 let old_lines = old_text.lines().collect::<Vec<_>>();
2377 let new_lines = new_text.lines().collect::<Vec<_>>();
2378 stats.lcs_comparisons += 1;
2379 let shared = lcs_len(&old_lines, &new_lines);
2380 (shared * 2) as f64 / (old_lines.len() + new_lines.len()) as f64
2381}
2382
2383fn lcs_len(left: &[&str], right: &[&str]) -> usize {
2384 let mut previous = vec![0usize; right.len() + 1];
2385 let mut current = vec![0usize; right.len() + 1];
2386 for left_line in left {
2387 for (index, right_line) in right.iter().enumerate() {
2388 current[index + 1] = if left_line == right_line {
2389 previous[index] + 1
2390 } else {
2391 previous[index + 1].max(current[index])
2392 };
2393 }
2394 std::mem::swap(&mut previous, &mut current);
2395 current.fill(0);
2396 }
2397 previous[right.len()]
2398}
2399
2400fn get_state_diff(
2412 repo: &Repository,
2413 from_tree: Option<&Tree>,
2414 to_tree: &Tree,
2415 path: &str,
2416 kind: &DiffKind,
2417) -> Result<(Vec<LineDiff>, FileEolState)> {
2418 match kind {
2419 DiffKind::Added => {
2420 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2421 return Ok((Vec::new(), FileEolState::default()));
2422 };
2423 let eol = eol_for_added(&new_blob);
2424 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2425 }
2426 DiffKind::Deleted => {
2427 let Some(tree) = from_tree else {
2428 return Ok((Vec::new(), FileEolState::default()));
2429 };
2430 let Some(old_blob) = find_blob_in_tree(repo, tree, path)? else {
2431 return Ok((Vec::new(), FileEolState::default()));
2432 };
2433 let eol = eol_for_deleted(&old_blob);
2434 Ok((number_lines(blob_lines(&old_blob, "-")?), eol))
2435 }
2436 DiffKind::Modified => {
2437 let Some(new_blob) = find_blob_in_tree(repo, to_tree, path)? else {
2438 return Ok((Vec::new(), FileEolState::default()));
2439 };
2440 if let Some(tree) = from_tree
2441 && let Some(old_blob) = find_blob_in_tree(repo, tree, path)?
2442 {
2443 return modified_blob_hunks(&old_blob, &new_blob);
2444 }
2445 let eol = eol_for_added(&new_blob);
2447 Ok((number_lines(blob_lines(&new_blob, "+")?), eol))
2448 }
2449 DiffKind::Unchanged => Ok((Vec::new(), FileEolState::default())),
2450 }
2451}
2452
2453fn eol_for_added(new_blob: &Blob) -> FileEolState {
2457 let (new_eol, new_count) = blob_eol_meta(new_blob);
2458 FileEolState {
2459 old_has_final_newline: true,
2460 new_has_final_newline: new_eol,
2461 old_line_count: 0,
2462 new_line_count: new_count,
2463 }
2464}
2465
2466fn eol_for_deleted(old_blob: &Blob) -> FileEolState {
2467 let (old_eol, old_count) = blob_eol_meta(old_blob);
2468 FileEolState {
2469 old_has_final_newline: old_eol,
2470 new_has_final_newline: true,
2471 old_line_count: old_count,
2472 new_line_count: 0,
2473 }
2474}
2475
2476fn eol_for_modified(old_blob: &Blob, new_blob: &Blob) -> FileEolState {
2477 let (old_eol, old_count) = blob_eol_meta(old_blob);
2478 let (new_eol, new_count) = blob_eol_meta(new_blob);
2479 FileEolState {
2480 old_has_final_newline: old_eol,
2481 new_has_final_newline: new_eol,
2482 old_line_count: old_count,
2483 new_line_count: new_count,
2484 }
2485}
2486
2487fn blob_eol_meta(blob: &Blob) -> (bool, usize) {
2492 let content = blob.content();
2493 if content.is_empty() {
2494 return (true, 0);
2495 }
2496 let has_eol = content.ends_with(b"\n");
2497 let line_count = blob
2498 .content_str()
2499 .map(|text| text.lines().count())
2500 .unwrap_or(0);
2501 (has_eol, line_count)
2502}
2503
2504fn blob_lines(blob: &Blob, prefix: &str) -> Result<Vec<LineDiff>> {
2505 let text = text_diff_content(blob)?;
2506 Ok(text
2507 .lines()
2508 .map(|line| LineDiff::new(prefix, line))
2509 .collect())
2510}
2511
2512fn modified_blob_hunks(old: &Blob, new: &Blob) -> Result<(Vec<LineDiff>, FileEolState)> {
2526 if old.content() == new.content() {
2527 return Ok((Vec::new(), FileEolState::default()));
2528 }
2529 ensure_text_diffable(old)?;
2530 ensure_text_diffable(new)?;
2531 let eol = eol_for_modified(old, new);
2532 let diff = diff_blobs(old, new);
2533 let lines = diff
2534 .iter()
2535 .map(|l| LineDiff::new(l.prefix(), l.content()))
2536 .collect();
2537 Ok((number_lines(lines), eol))
2538}
2539
2540fn ensure_text_diffable(blob: &Blob) -> Result<()> {
2541 text_diff_content(blob).map(|_| ())
2542}
2543
2544fn text_diff_content(blob: &Blob) -> Result<&str> {
2545 let Some(text) = blob.content_str() else {
2546 return Err(anyhow!(BINARY_DIFF_ERROR));
2547 };
2548 if text.chars().any(is_terminal_hostile_control) {
2549 return Err(anyhow!(BINARY_DIFF_ERROR));
2550 }
2551 Ok(text)
2552}
2553
2554fn is_binary_diff_error(error: &anyhow::Error) -> bool {
2555 error.to_string() == BINARY_DIFF_ERROR
2556}
2557
2558fn is_terminal_hostile_control(ch: char) -> bool {
2559 ch.is_control() && ch != '\n' && ch != '\t'
2560}
2561
2562fn number_lines(lines: Vec<LineDiff>) -> Vec<LineDiff> {
2563 let mut old_line = 1usize;
2564 let mut new_line = 1usize;
2565
2566 lines
2567 .into_iter()
2568 .map(|line| {
2569 let old = if line.prefix != "+" {
2570 let current = Some(old_line);
2571 old_line += 1;
2572 current
2573 } else {
2574 None
2575 };
2576 let new = if line.prefix != "-" {
2577 let current = Some(new_line);
2578 new_line += 1;
2579 current
2580 } else {
2581 None
2582 };
2583 LineDiff::with_lines(line.prefix, line.content, old, new)
2584 })
2585 .collect()
2586}
2587
2588fn find_blob_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<Blob>> {
2589 match find_entry_in_tree(repo, tree, path)? {
2590 Some(entry) => match entry.content_hash() {
2591 Some(hash) if entry.is_blob() || entry.is_symlink() => {
2592 Ok(Some(repo.require_blob(&hash)?))
2593 }
2594 _ => Ok(None),
2595 },
2596 None => Ok(None),
2597 }
2598}
2599
2600fn find_entry_in_tree(repo: &Repository, tree: &Tree, path: &str) -> Result<Option<TreeEntry>> {
2608 let parts: Vec<&str> = path.split('/').collect();
2609 find_entry_recursive(repo, tree, &parts)
2610}
2611
2612fn find_entry_recursive(
2613 repo: &Repository,
2614 tree: &Tree,
2615 parts: &[&str],
2616) -> Result<Option<TreeEntry>> {
2617 if parts.is_empty() {
2618 return Ok(None);
2619 }
2620
2621 let name = parts[0];
2622 let entry = match tree.get(name) {
2623 Some(e) => e,
2624 None => return Ok(None),
2625 };
2626
2627 if parts.len() == 1 {
2628 if entry.is_blob() || entry.entry_type() == EntryType::Symlink || entry.is_gitlink() {
2629 return Ok(Some(entry.clone()));
2630 }
2631 } else if entry.is_tree()
2632 && let Some(hash) = entry.tree_hash()
2633 && let Some(subtree) = repo.store().get_tree(&hash)?
2634 {
2635 return find_entry_recursive(repo, &subtree, &parts[1..]);
2636 }
2637
2638 Ok(None)
2639}
2640
2641fn worktree_file_mode(path: &Path) -> Option<FileMode> {
2646 let metadata = std::fs::symlink_metadata(path).ok()?;
2647 if metadata.file_type().is_symlink() {
2648 return Some(FileMode::Symlink);
2649 }
2650 #[cfg(unix)]
2651 {
2652 use std::os::unix::fs::PermissionsExt;
2653 if metadata.permissions().mode() & 0o111 != 0 {
2654 return Some(FileMode::Executable);
2655 }
2656 }
2657 Some(FileMode::Normal)
2658}
2659
2660fn change_file_modes(
2673 repo: &Repository,
2674 from_tree: Option<&Tree>,
2675 to_tree: Option<&Tree>,
2676 path: &str,
2677 kind: &str,
2678) -> (Option<FileMode>, Option<FileMode>) {
2679 let old_side = || {
2680 from_tree
2681 .and_then(|tree| find_entry_in_tree(repo, tree, path).ok().flatten())
2682 .map(|entry| entry.mode())
2683 };
2684 let new_side = || match to_tree {
2685 Some(tree) => find_entry_in_tree(repo, tree, path)
2686 .ok()
2687 .flatten()
2688 .map(|entry| entry.mode()),
2689 None => worktree_file_mode(&repo.root().join(path)),
2690 };
2691 match kind {
2692 "added" => (None, new_side()),
2693 "deleted" => (None, old_side()),
2694 "modified" => (old_side(), new_side()),
2695 _ => (None, None),
2696 }
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701 use objects::{
2702 object::{Blob, FileMode, Tree, TreeEntry},
2703 store::ObjectStore,
2704 };
2705 use repo::Repository;
2706 use tempfile::TempDir;
2707
2708 use super::{
2709 DiffStats, FileChange, FileEolState, LineCounts, LineDiff, RENAME_SIMILARITY_THRESHOLD,
2710 RenameDetectionStats, change_line_counts, detect_clear_renames_with_stats, lcs_len,
2711 prepare_rename_blob, rename_similarity, unified_hunks,
2712 };
2713
2714 type RenameSummary = Vec<(String, String, Option<String>, Option<f64>)>;
2715
2716 fn rename_fixture(
2717 shared_line_counts: &[usize],
2718 ) -> (TempDir, Repository, Tree, Tree, Vec<FileChange>) {
2719 let temp = TempDir::new().expect("create rename fixture");
2720 let repo = Repository::init_default(temp.path()).expect("initialize rename fixture");
2721 let mut old_entries = Vec::with_capacity(shared_line_counts.len());
2722 let mut new_entries = Vec::with_capacity(shared_line_counts.len());
2723 let mut changes = Vec::with_capacity(shared_line_counts.len() * 2);
2724
2725 for (file_index, shared_lines) in shared_line_counts.iter().copied().enumerate() {
2726 let old_content = (0..32)
2727 .map(|line_index| format!("file {file_index} original line {line_index}\n"))
2728 .collect::<String>();
2729 let new_content = (0..32)
2730 .map(|line_index| {
2731 if line_index < shared_lines {
2732 format!("file {file_index} original line {line_index}\n")
2733 } else {
2734 format!("file {file_index} replacement line {line_index}\n")
2735 }
2736 })
2737 .collect::<String>();
2738 let old_hash = repo
2739 .store()
2740 .put_blob(&Blob::from(old_content))
2741 .expect("store old rename blob");
2742 let new_hash = repo
2743 .store()
2744 .put_blob(&Blob::from(new_content))
2745 .expect("store new rename blob");
2746 let old_path = format!("old_{file_index:04}.txt");
2747 let new_path = format!("new_{file_index:04}.txt");
2748 old_entries
2749 .push(TreeEntry::file(&old_path, old_hash, false).expect("build old tree entry"));
2750 new_entries
2751 .push(TreeEntry::file(&new_path, new_hash, false).expect("build new tree entry"));
2752 changes.push(FileChange {
2753 path: old_path,
2754 kind: "deleted".to_string(),
2755 mode: Some(FileMode::Normal),
2756 ..Default::default()
2757 });
2758 changes.push(FileChange {
2759 path: new_path,
2760 kind: "added".to_string(),
2761 mode: Some(FileMode::Normal),
2762 ..Default::default()
2763 });
2764 }
2765
2766 (
2767 temp,
2768 repo,
2769 Tree::from_entries(old_entries),
2770 Tree::from_entries(new_entries),
2771 changes,
2772 )
2773 }
2774
2775 fn run_rename_fixture(shared_line_counts: &[usize]) -> (RenameSummary, RenameDetectionStats) {
2776 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(shared_line_counts);
2777 let mut stats = RenameDetectionStats::default();
2778 let output = detect_clear_renames_with_stats(
2779 &repo,
2780 Some(&old_tree),
2781 Some(&new_tree),
2782 changes,
2783 false,
2784 0,
2785 &mut stats,
2786 )
2787 .expect("detect fixture renames");
2788 let summary = output
2789 .into_iter()
2790 .map(|change| {
2791 (
2792 change.path,
2793 change.kind,
2794 change.old_path,
2795 change.similarity_score,
2796 )
2797 })
2798 .collect();
2799 (summary, stats)
2800 }
2801
2802 #[test]
2803 fn rename_fixture_characterizes_exact_threshold_and_rejected_pairs() {
2804 let (summary, _) = run_rename_fixture(&[32, 31, 24, 23]);
2805
2806 assert_eq!(
2807 summary,
2808 vec![
2809 (
2810 "new_0000.txt".to_string(),
2811 "renamed".to_string(),
2812 Some("old_0000.txt".to_string()),
2813 Some(1.0),
2814 ),
2815 (
2816 "new_0001.txt".to_string(),
2817 "renamed".to_string(),
2818 Some("old_0001.txt".to_string()),
2819 Some(31.0 / 32.0),
2820 ),
2821 (
2822 "new_0002.txt".to_string(),
2823 "renamed".to_string(),
2824 Some("old_0002.txt".to_string()),
2825 Some(0.75),
2826 ),
2827 (
2828 "old_0003.txt".to_string(),
2829 "deleted".to_string(),
2830 None,
2831 None,
2832 ),
2833 ("new_0003.txt".to_string(), "added".to_string(), None, None,),
2834 ]
2835 );
2836 }
2837
2838 #[test]
2839 fn many_rename_detection_reads_each_blob_once_and_limits_lcs_to_candidates() {
2840 const FILE_COUNT: usize = 32;
2841 let (summary, stats) = run_rename_fixture(&[31; FILE_COUNT]);
2842
2843 assert_eq!(summary.len(), FILE_COUNT);
2844 assert!(summary.iter().all(|(_, kind, _, _)| kind == "renamed"));
2845 assert_eq!(
2846 stats.blob_reads,
2847 FILE_COUNT * 2,
2848 "each old and added blob should be read once per diff"
2849 );
2850 assert_eq!(
2851 stats.lcs_comparisons, FILE_COUNT,
2852 "only the one plausible modified target per deleted file should reach LCS"
2853 );
2854 assert_eq!(stats.total_possible_pairs, FILE_COUNT * FILE_COUNT);
2855 assert_eq!(
2856 stats.qualifying_candidate_pairs, FILE_COUNT,
2857 "only threshold-qualified pairs should enter deterministic assignment"
2858 );
2859 assert!(stats.qualifying_candidate_pairs < stats.total_possible_pairs);
2860 }
2861
2862 #[test]
2863 fn rename_prefilter_preserves_all_qualifying_short_line_pairs() {
2864 let mut contents = vec![String::new()];
2865 for line_count in 1..=5 {
2866 for bits in 0..(1usize << line_count) {
2867 let content = (0..line_count)
2868 .map(|line| {
2869 if bits & (1 << line) == 0 {
2870 "alpha"
2871 } else {
2872 "beta"
2873 }
2874 })
2875 .collect::<Vec<_>>()
2876 .join("\n");
2877 contents.push(content);
2878 }
2879 }
2880
2881 for old_content in &contents {
2882 for new_content in &contents {
2883 let old_lines = old_content.lines().collect::<Vec<_>>();
2884 let new_lines = new_content.lines().collect::<Vec<_>>();
2885 let expected = if old_content == new_content {
2886 1.0
2887 } else if old_lines.is_empty() || new_lines.is_empty() {
2888 0.0
2889 } else {
2890 (lcs_len(&old_lines, &new_lines) * 2) as f64
2891 / (old_lines.len() + new_lines.len()) as f64
2892 };
2893 if expected < RENAME_SIMILARITY_THRESHOLD {
2894 continue;
2895 }
2896
2897 let old_blob = prepare_rename_blob(Blob::from(old_content.clone()));
2898 let new_blob = prepare_rename_blob(Blob::from(new_content.clone()));
2899 let actual =
2900 rename_similarity(&old_blob, &new_blob, &mut RenameDetectionStats::default());
2901 assert_eq!(
2902 actual, expected,
2903 "qualifying pair changed score: old={old_content:?}, new={new_content:?}"
2904 );
2905 }
2906 }
2907 }
2908
2909 #[test]
2910 #[ignore = "focused release-mode wall-time measurement"]
2911 fn benchmark_many_modified_renames() {
2912 use std::time::Instant;
2913
2914 const FILE_COUNT: usize = 128;
2915 const SAMPLES: usize = 7;
2916 let shared_line_counts = vec![31; FILE_COUNT];
2917 let (_temp, repo, old_tree, new_tree, changes) = rename_fixture(&shared_line_counts);
2918 let mut samples = Vec::with_capacity(SAMPLES);
2919 let mut final_stats = RenameDetectionStats::default();
2920
2921 for _ in 0..SAMPLES {
2922 let mut stats = RenameDetectionStats::default();
2923 let started = Instant::now();
2924 let output = detect_clear_renames_with_stats(
2925 &repo,
2926 Some(&old_tree),
2927 Some(&new_tree),
2928 changes.clone(),
2929 false,
2930 0,
2931 &mut stats,
2932 )
2933 .expect("benchmark rename detection");
2934 assert_eq!(output.len(), FILE_COUNT);
2935 samples.push(started.elapsed());
2936 final_stats = stats;
2937 }
2938 samples.sort();
2939 eprintln!(
2940 "rename_diff files={FILE_COUNT} samples={SAMPLES} median_ms={:.3} blob_reads={} lcs_comparisons={}",
2941 samples[SAMPLES / 2].as_secs_f64() * 1_000.0,
2942 final_stats.blob_reads,
2943 final_stats.lcs_comparisons,
2944 );
2945 }
2946
2947 fn stat_change(kind: &str, counts: LineCounts) -> FileChange {
2948 FileChange {
2949 path: "notes.txt".to_string(),
2950 kind: kind.to_string(),
2951 line_counts: Some(counts),
2952 ..Default::default()
2953 }
2954 }
2955
2956 #[test]
2963 fn diff_stats_reads_line_counts_when_hunks_dropped() {
2964 let changes = vec![stat_change(
2965 "modified",
2966 LineCounts {
2967 added: 1,
2968 modified: 0,
2969 deleted: 0,
2970 },
2971 )];
2972
2973 let stats = DiffStats::from_changes(&changes, None);
2974
2975 assert_eq!(stats.files_changed, 1);
2976 assert_eq!(stats.additions, 1);
2977 assert_eq!(stats.modifications, 0);
2978 assert_eq!(stats.deletions, 0);
2979 assert_eq!(stats.renames, 0);
2980 }
2981
2982 #[test]
2987 fn diff_stats_treats_zero_line_counts_as_authoritative() {
2988 let changes = vec![stat_change(
2989 "modified",
2990 LineCounts {
2991 added: 0,
2992 modified: 0,
2993 deleted: 0,
2994 },
2995 )];
2996
2997 let stats = DiffStats::from_changes(&changes, None);
2998
2999 assert_eq!(stats.modifications, 0);
3000 assert_eq!(stats.additions, 0);
3001 assert_eq!(stats.deletions, 0);
3002 }
3003
3004 #[test]
3007 fn change_line_counts_pairs_modified_lines() {
3008 let lines = vec![
3009 LineDiff::with_lines("-", "alpha", Some(1), None),
3010 LineDiff::with_lines("+", "alpha-changed", None, Some(1)),
3011 LineDiff::with_lines("+", "fresh", None, Some(2)),
3012 ];
3013 let counts = change_line_counts(Some(&lines));
3014 assert_eq!(counts.modified, 1);
3015 assert_eq!(counts.added, 1);
3016 assert_eq!(counts.deleted, 0);
3017 }
3018
3019 #[test]
3025 fn unified_hunks_keeps_added_decoration_in_canonical_body() {
3026 let lines = vec![
3027 LineDiff::with_lines("+", "#[test]", None, Some(1)),
3028 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
3029 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
3030 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
3031 ];
3032
3033 let hunk = unified_hunks(lines, 3, &FileEolState::default());
3034
3035 let header = hunk
3036 .iter()
3037 .find(|line| line.prefix == "@")
3038 .expect("hunk should carry an `@@` header");
3039 assert_eq!(
3041 header.content, "@ -1,2 +1,4 @@",
3042 "header counts must match the untrimmed body: {hunk:?}"
3043 );
3044 assert!(
3045 hunk.iter()
3046 .any(|line| line.prefix == "+" && line.content == "#[test]"),
3047 "added decoration line must survive in the canonical body: {hunk:?}"
3048 );
3049 assert!(
3050 hunk.iter()
3051 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
3052 "added function body should remain: {hunk:?}"
3053 );
3054 }
3055
3056 #[test]
3060 fn display_trim_drops_added_decoration_but_keeps_header() {
3061 use super::trim_added_decorations_for_display;
3062
3063 let lines = vec![
3064 LineDiff::with_lines("+", "#[test]", None, Some(1)),
3065 LineDiff::with_lines("+", "fn added() {}", None, Some(2)),
3066 LineDiff::with_lines(" ", "#[test]", Some(1), Some(3)),
3067 LineDiff::with_lines(" ", "fn existing() {}", Some(2), Some(4)),
3068 ];
3069 let hunk = unified_hunks(lines, 3, &FileEolState::default());
3070
3071 let display = trim_added_decorations_for_display(&hunk);
3072
3073 assert!(
3074 display
3075 .iter()
3076 .filter(|line| line.content == "#[test]")
3077 .all(|line| line.prefix == " "),
3078 "display trim should let existing context own the decoration: {display:?}"
3079 );
3080 assert!(
3081 display
3082 .iter()
3083 .any(|line| line.prefix == "+" && line.content == "fn added() {}"),
3084 "added function body should remain after display trim: {display:?}"
3085 );
3086 assert_eq!(
3087 display
3088 .iter()
3089 .find(|line| line.prefix == "@")
3090 .map(|l| l.content.as_str()),
3091 Some("@ -1,2 +1,4 @@"),
3092 "display trim must not rewrite the `@@` header: {display:?}"
3093 );
3094 }
3095
3096 #[test]
3099 fn minimal_resolve_failure_maps_to_recovery_state_not_found() {
3100 use objects::{RecoveryDetails, error::HeddleError};
3101 use repo::{
3102 ResolvePolicy, StateResolveError, StateResolveFailure, resolve_state_for_command,
3103 };
3104 use tempfile::TempDir;
3105
3106 let temp = TempDir::new().unwrap();
3107 let repo = repo::Repository::init_default(temp.path()).unwrap();
3108 std::fs::write(temp.path().join("a.txt"), "a").unwrap();
3109 repo.snapshot_with_attribution(
3110 Some("seed".into()),
3111 None,
3112 objects::object::Attribution::human(objects::object::Principal::new(
3113 "Test",
3114 "test@example.com",
3115 )),
3116 )
3117 .unwrap();
3118
3119 let err = resolve_state_for_command(&repo, "hs-zzzzzzzzzzzz", ResolvePolicy::minimal())
3120 .unwrap_err();
3121 let mapped = match err {
3122 StateResolveError::Failure(StateResolveFailure::NotFound { spec }) => {
3123 HeddleError::recovery(RecoveryDetails::state_not_found(spec))
3124 }
3125 other => panic!("expected not-found failure, got {other:?}"),
3126 };
3127 assert!(matches!(mapped, HeddleError::Recovery(_)));
3128 assert!(
3129 mapped.to_string().contains("State not found"),
3130 "unexpected message: {mapped}"
3131 );
3132 }
3133}