1use std::fs;
23use std::os::unix::fs::MetadataExt;
24use std::path::Path;
25
26use crate::error::{Error, Result};
27use crate::index::{Index, IndexEntry};
28use crate::objects::{parse_tree, ObjectId, ObjectKind, TreeEntry};
29use crate::odb::Odb;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DiffStatus {
34 Added,
36 Deleted,
38 Modified,
40 Renamed,
42 Copied,
44 TypeChanged,
46 Unmerged,
48}
49
50impl DiffStatus {
51 #[must_use]
53 pub fn letter(&self) -> char {
54 match self {
55 Self::Added => 'A',
56 Self::Deleted => 'D',
57 Self::Modified => 'M',
58 Self::Renamed => 'R',
59 Self::Copied => 'C',
60 Self::TypeChanged => 'T',
61 Self::Unmerged => 'U',
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct DiffEntry {
69 pub status: DiffStatus,
71 pub old_path: Option<String>,
73 pub new_path: Option<String>,
75 pub old_mode: String,
77 pub new_mode: String,
79 pub old_oid: ObjectId,
81 pub new_oid: ObjectId,
83 pub score: Option<u32>,
85}
86
87impl DiffEntry {
88 #[must_use]
90 pub fn path(&self) -> &str {
91 self.new_path
92 .as_deref()
93 .or(self.old_path.as_deref())
94 .unwrap_or("")
95 }
96}
97
98pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
100
101#[must_use]
103pub fn zero_oid() -> ObjectId {
104 ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
105 panic!("internal error: failed to create zero OID");
107 })
108}
109
110pub fn diff_trees(
125 odb: &Odb,
126 old_tree_oid: Option<&ObjectId>,
127 new_tree_oid: Option<&ObjectId>,
128 prefix: &str,
129) -> Result<Vec<DiffEntry>> {
130 let old_entries = match old_tree_oid {
131 Some(oid) => read_tree(odb, oid)?,
132 None => Vec::new(),
133 };
134 let new_entries = match new_tree_oid {
135 Some(oid) => read_tree(odb, oid)?,
136 None => Vec::new(),
137 };
138
139 let mut result = Vec::new();
140 diff_tree_entries(odb, &old_entries, &new_entries, prefix, &mut result)?;
141 Ok(result)
142}
143
144fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
146 let obj = odb.read(oid)?;
147 if obj.kind != ObjectKind::Tree {
148 return Err(Error::CorruptObject(format!(
149 "expected tree, got {}",
150 obj.kind.as_str()
151 )));
152 }
153 parse_tree(&obj.data)
154}
155
156fn diff_tree_entries(
158 odb: &Odb,
159 old: &[TreeEntry],
160 new: &[TreeEntry],
161 prefix: &str,
162 result: &mut Vec<DiffEntry>,
163) -> Result<()> {
164 let mut oi = 0;
165 let mut ni = 0;
166
167 while oi < old.len() || ni < new.len() {
168 match (old.get(oi), new.get(ni)) {
169 (Some(o), Some(n)) => {
170 let cmp = crate::objects::tree_entry_cmp(
171 &o.name,
172 is_tree_mode(o.mode),
173 &n.name,
174 is_tree_mode(n.mode),
175 );
176 match cmp {
177 std::cmp::Ordering::Less => {
178 emit_deleted(odb, o, prefix, result)?;
180 oi += 1;
181 }
182 std::cmp::Ordering::Greater => {
183 emit_added(odb, n, prefix, result)?;
185 ni += 1;
186 }
187 std::cmp::Ordering::Equal => {
188 if o.oid != n.oid || o.mode != n.mode {
190 let name_str = String::from_utf8_lossy(&o.name);
191 let path = format_path(prefix, &name_str);
192 if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
193 let nested = diff_trees(odb, Some(&o.oid), Some(&n.oid), &path)?;
195 result.extend(nested);
196 } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
197 emit_deleted(odb, o, prefix, result)?;
199 emit_added(odb, n, prefix, result)?;
200 } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
201 emit_deleted(odb, o, prefix, result)?;
203 emit_added(odb, n, prefix, result)?;
204 } else {
205 result.push(DiffEntry {
207 status: if o.mode != n.mode && o.oid == n.oid {
208 DiffStatus::TypeChanged
209 } else {
210 DiffStatus::Modified
211 },
212 old_path: Some(path.clone()),
213 new_path: Some(path),
214 old_mode: format_mode(o.mode),
215 new_mode: format_mode(n.mode),
216 old_oid: o.oid,
217 new_oid: n.oid,
218 score: None,
219 });
220 }
221 }
222 oi += 1;
223 ni += 1;
224 }
225 }
226 }
227 (Some(o), None) => {
228 emit_deleted(odb, o, prefix, result)?;
229 oi += 1;
230 }
231 (None, Some(n)) => {
232 emit_added(odb, n, prefix, result)?;
233 ni += 1;
234 }
235 (None, None) => break,
236 }
237 }
238
239 Ok(())
240}
241
242fn emit_deleted(
243 odb: &Odb,
244 entry: &TreeEntry,
245 prefix: &str,
246 result: &mut Vec<DiffEntry>,
247) -> Result<()> {
248 let name_str = String::from_utf8_lossy(&entry.name);
249 let path = format_path(prefix, &name_str);
250 if is_tree_mode(entry.mode) {
251 let nested = diff_trees(odb, Some(&entry.oid), None, &path)?;
253 result.extend(nested);
254 } else {
255 result.push(DiffEntry {
256 status: DiffStatus::Deleted,
257 old_path: Some(path.clone()),
258 new_path: None,
259 old_mode: format_mode(entry.mode),
260 new_mode: "000000".to_owned(),
261 old_oid: entry.oid,
262 new_oid: zero_oid(),
263 score: None,
264 });
265 }
266 Ok(())
267}
268
269fn emit_added(
270 odb: &Odb,
271 entry: &TreeEntry,
272 prefix: &str,
273 result: &mut Vec<DiffEntry>,
274) -> Result<()> {
275 let name_str = String::from_utf8_lossy(&entry.name);
276 let path = format_path(prefix, &name_str);
277 if is_tree_mode(entry.mode) {
278 let nested = diff_trees(odb, None, Some(&entry.oid), &path)?;
280 result.extend(nested);
281 } else {
282 result.push(DiffEntry {
283 status: DiffStatus::Added,
284 old_path: None,
285 new_path: Some(path),
286 old_mode: "000000".to_owned(),
287 new_mode: format_mode(entry.mode),
288 old_oid: zero_oid(),
289 new_oid: entry.oid,
290 score: None,
291 });
292 }
293 Ok(())
294}
295
296pub fn diff_index_to_tree(
313 odb: &Odb,
314 index: &Index,
315 tree_oid: Option<&ObjectId>,
316) -> Result<Vec<DiffEntry>> {
317 let tree_entries = match tree_oid {
319 Some(oid) => flatten_tree(odb, oid, "")?,
320 None => Vec::new(),
321 };
322
323 let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
325 std::collections::BTreeMap::new();
326 for entry in &tree_entries {
327 tree_map.insert(&entry.path, entry);
328 }
329
330 let mut result = Vec::new();
331
332 for ie in &index.entries {
334 if ie.stage() != 0 {
336 continue;
337 }
338 let path = String::from_utf8_lossy(&ie.path).to_string();
339 match tree_map.remove(path.as_str()) {
340 Some(te) => {
341 if te.oid != ie.oid || te.mode != ie.mode {
343 result.push(DiffEntry {
344 status: DiffStatus::Modified,
345 old_path: Some(path.clone()),
346 new_path: Some(path),
347 old_mode: format_mode(te.mode),
348 new_mode: format_mode(ie.mode),
349 old_oid: te.oid,
350 new_oid: ie.oid,
351 score: None,
352 });
353 }
354 }
355 None => {
356 result.push(DiffEntry {
358 status: DiffStatus::Added,
359 old_path: None,
360 new_path: Some(path),
361 old_mode: "000000".to_owned(),
362 new_mode: format_mode(ie.mode),
363 old_oid: zero_oid(),
364 new_oid: ie.oid,
365 score: None,
366 });
367 }
368 }
369 }
370
371 for (path, te) in tree_map {
373 result.push(DiffEntry {
374 status: DiffStatus::Deleted,
375 old_path: Some(path.to_owned()),
376 new_path: None,
377 old_mode: format_mode(te.mode),
378 new_mode: "000000".to_owned(),
379 old_oid: te.oid,
380 new_oid: zero_oid(),
381 score: None,
382 });
383 }
384
385 result.sort_by(|a, b| a.path().cmp(b.path()));
386 Ok(result)
387}
388
389pub fn diff_index_to_worktree(
405 odb: &Odb,
406 index: &Index,
407 work_tree: &Path,
408) -> Result<Vec<DiffEntry>> {
409 let mut result = Vec::new();
410
411 for ie in &index.entries {
412 if ie.stage() != 0 {
413 continue;
414 }
415 let path_str_ref = std::str::from_utf8(&ie.path).unwrap_or("");
418 let file_path = work_tree.join(path_str_ref);
419 match fs::symlink_metadata(&file_path) {
420 Ok(meta) => {
421 if stat_matches(ie, &meta) {
423 continue; }
425
426 let worktree_oid = hash_worktree_file(odb, &file_path, &meta)?;
428 let worktree_mode = mode_from_metadata(&meta);
429
430 if worktree_oid != ie.oid || worktree_mode != ie.mode {
431 let path_owned = path_str_ref.to_owned();
432 result.push(DiffEntry {
433 status: DiffStatus::Modified,
434 old_path: Some(path_owned.clone()),
435 new_path: Some(path_owned),
436 old_mode: format_mode(ie.mode),
437 new_mode: format_mode(worktree_mode),
438 old_oid: ie.oid,
439 new_oid: worktree_oid,
440 score: None,
441 });
442 }
443 }
444 Err(e) if e.kind() == std::io::ErrorKind::NotFound
445 || e.raw_os_error() == Some(20) => {
446 result.push(DiffEntry {
448 status: DiffStatus::Deleted,
449 old_path: Some(path_str_ref.to_owned()),
450 new_path: None,
451 old_mode: format_mode(ie.mode),
452 new_mode: "000000".to_owned(),
453 old_oid: ie.oid,
454 new_oid: zero_oid(),
455 score: None,
456 });
457 }
458 Err(e) => return Err(Error::Io(e)),
459 }
460 }
461
462 Ok(result)
463}
464
465pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
467 if meta.len() as u32 != ie.size {
469 return false;
470 }
471 if meta.mtime() as u32 != ie.mtime_sec {
473 return false;
474 }
475 if meta.mtime_nsec() as u32 != ie.mtime_nsec {
476 return false;
477 }
478 if meta.ctime() as u32 != ie.ctime_sec {
480 return false;
481 }
482 if meta.ctime_nsec() as u32 != ie.ctime_nsec {
483 return false;
484 }
485 if meta.ino() as u32 != ie.ino {
487 return false;
488 }
489 if meta.dev() as u32 != ie.dev {
490 return false;
491 }
492 true
493}
494
495fn hash_worktree_file(_odb: &Odb, path: &Path, meta: &fs::Metadata) -> Result<ObjectId> {
497 let data = if meta.file_type().is_symlink() {
498 let target = fs::read_link(path)?;
500 target.to_string_lossy().into_owned().into_bytes()
501 } else {
502 fs::read(path)?
503 };
504
505 Ok(Odb::hash_object_data(ObjectKind::Blob, &data))
506}
507
508fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
510 if meta.file_type().is_symlink() {
511 0o120000
512 } else if meta.mode() & 0o111 != 0 {
513 0o100755
514 } else {
515 0o100644
516 }
517}
518
519pub fn diff_tree_to_worktree(
536 odb: &Odb,
537 tree_oid: Option<&ObjectId>,
538 work_tree: &Path,
539 index: &Index,
540) -> Result<Vec<DiffEntry>> {
541 let tree_flat = match tree_oid {
543 Some(oid) => flatten_tree(odb, oid, "")?,
544 None => Vec::new(),
545 };
546 let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
547 tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
548
549 let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
551 std::collections::BTreeMap::new();
552 let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
553 for ie in &index.entries {
554 if ie.stage() != 0 {
555 continue;
556 }
557 let path = String::from_utf8_lossy(&ie.path).to_string();
558 index_entries.insert(&ie.path, ie);
559 index_paths.insert(path);
560 }
561
562 let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
564 all_paths.extend(tree_map.keys().cloned());
565 all_paths.extend(index_paths.iter().cloned());
566
567 let mut result = Vec::new();
568
569 for path in &all_paths {
570 let tree_entry = tree_map.get(path.as_str());
571 let file_path = work_tree.join(path);
572
573 let wt_meta = match fs::symlink_metadata(&file_path) {
574 Ok(m) => Some(m),
575 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
576 Err(e) => return Err(Error::Io(e)),
577 };
578
579 match (tree_entry, wt_meta) {
580 (Some(te), Some(ref meta)) => {
581 if let Some(ie) = index_entries.get(path.as_bytes()) {
584 if ie.oid == te.oid && ie.mode == te.mode && stat_matches(ie, meta) {
585 continue;
586 }
587 }
588
589 let wt_oid = hash_worktree_file(odb, &file_path, meta)?;
591 let wt_mode = mode_from_metadata(meta);
592 if wt_oid != te.oid || wt_mode != te.mode {
593 result.push(DiffEntry {
594 status: DiffStatus::Modified,
595 old_path: Some(path.clone()),
596 new_path: Some(path.clone()),
597 old_mode: format_mode(te.mode),
598 new_mode: format_mode(wt_mode),
599 old_oid: te.oid,
600 new_oid: wt_oid,
601 score: None,
602 });
603 }
604 }
605 (Some(te), None) => {
606 result.push(DiffEntry {
608 status: DiffStatus::Deleted,
609 old_path: Some(path.clone()),
610 new_path: None,
611 old_mode: format_mode(te.mode),
612 new_mode: "000000".to_owned(),
613 old_oid: te.oid,
614 new_oid: zero_oid(),
615 score: None,
616 });
617 }
618 (None, Some(ref meta)) => {
619 let wt_oid = hash_worktree_file(odb, &file_path, meta)?;
621 let wt_mode = mode_from_metadata(meta);
622 result.push(DiffEntry {
623 status: DiffStatus::Added,
624 old_path: None,
625 new_path: Some(path.clone()),
626 old_mode: "000000".to_owned(),
627 new_mode: format_mode(wt_mode),
628 old_oid: zero_oid(),
629 new_oid: wt_oid,
630 score: None,
631 });
632 }
633 (None, None) => {
634 }
636 }
637 }
638
639 result.sort_by(|a, b| a.path().cmp(b.path()));
640 Ok(result)
641}
642
643pub fn detect_renames(odb: &Odb, entries: Vec<DiffEntry>, threshold: u32) -> Vec<DiffEntry> {
653 let mut deleted: Vec<DiffEntry> = Vec::new();
655 let mut added: Vec<DiffEntry> = Vec::new();
656 let mut others: Vec<DiffEntry> = Vec::new();
657
658 for entry in entries {
659 match entry.status {
660 DiffStatus::Deleted => deleted.push(entry),
661 DiffStatus::Added => added.push(entry),
662 _ => others.push(entry),
663 }
664 }
665
666 if deleted.is_empty() || added.is_empty() {
667 let mut result = others;
669 result.extend(deleted);
670 result.extend(added);
671 result.sort_by(|a, b| a.path().cmp(b.path()));
672 return result;
673 }
674
675 let deleted_contents: Vec<Option<Vec<u8>>> = deleted
677 .iter()
678 .map(|d| odb.read(&d.old_oid).ok().map(|obj| obj.data))
679 .collect();
680
681 let added_contents: Vec<Option<Vec<u8>>> = added
683 .iter()
684 .map(|a| odb.read(&a.new_oid).ok().map(|obj| obj.data))
685 .collect();
686
687 let mut scores: Vec<(u32, usize, usize)> = Vec::new();
690
691 for (di, del) in deleted.iter().enumerate() {
692 for (ai, add) in added.iter().enumerate() {
693 if del.old_oid == add.new_oid {
695 scores.push((100, di, ai));
696 continue;
697 }
698
699 let score = match (&deleted_contents[di], &added_contents[ai]) {
700 (Some(old_data), Some(new_data)) => {
701 compute_similarity(old_data, new_data)
702 }
703 _ => 0,
704 };
705
706 if score >= threshold {
707 scores.push((score, di, ai));
708 }
709 }
710 }
711
712 scores.sort_by(|a, b| {
715 let a_same = same_basename(&deleted[a.1], &added[a.2]);
716 let b_same = same_basename(&deleted[b.1], &added[b.2]);
717 b_same.cmp(&a_same).then_with(|| b.0.cmp(&a.0))
718 });
719
720 let mut used_deleted = vec![false; deleted.len()];
721 let mut used_added = vec![false; added.len()];
722 let mut renames: Vec<DiffEntry> = Vec::new();
723
724 for (score, di, ai) in &scores {
725 if used_deleted[*di] || used_added[*ai] {
726 continue;
727 }
728 used_deleted[*di] = true;
729 used_added[*ai] = true;
730
731 let del = &deleted[*di];
732 let add = &added[*ai];
733
734 renames.push(DiffEntry {
735 status: DiffStatus::Renamed,
736 old_path: del.old_path.clone(),
737 new_path: add.new_path.clone(),
738 old_mode: del.old_mode.clone(),
739 new_mode: add.new_mode.clone(),
740 old_oid: del.old_oid,
741 new_oid: add.new_oid,
742 score: Some(*score),
743 });
744 }
745
746 let mut result = others;
748 result.extend(renames);
749 for (i, entry) in deleted.into_iter().enumerate() {
750 if !used_deleted[i] {
751 result.push(entry);
752 }
753 }
754 for (i, entry) in added.into_iter().enumerate() {
755 if !used_added[i] {
756 result.push(entry);
757 }
758 }
759
760 result.sort_by(|a, b| a.path().cmp(b.path()));
761 result
762}
763
764
765pub fn detect_copies(
776 odb: &Odb,
777 entries: Vec<DiffEntry>,
778 threshold: u32,
779 find_copies_harder: bool,
780 source_tree_entries: &[(String, String, ObjectId)],
781) -> Vec<DiffEntry> {
782 let entries = detect_renames(odb, entries, threshold);
784
785 let mut added: Vec<DiffEntry> = Vec::new();
787 let mut others: Vec<DiffEntry> = Vec::new();
788
789 for entry in entries {
790 match entry.status {
791 DiffStatus::Added => added.push(entry),
792 _ => others.push(entry),
793 }
794 }
795
796 if added.is_empty() {
797 return others;
798 }
799
800 let mut sources: Vec<(String, ObjectId)> = Vec::new();
802
803 for entry in &others {
805 if entry.status == DiffStatus::Modified {
806 if let Some(ref old_path) = entry.old_path {
807 sources.push((old_path.clone(), entry.old_oid));
808 }
809 }
810 }
811
812 if find_copies_harder {
814 for (path, _mode, oid) in source_tree_entries {
815 if !sources.iter().any(|(p, _)| p == path) {
816 sources.push((path.clone(), *oid));
817 }
818 }
819 }
820
821 if sources.is_empty() {
822 let mut result = others;
823 result.extend(added);
824 result.sort_by(|a, b| a.path().cmp(b.path()));
825 return result;
826 }
827
828 let source_contents: Vec<Option<Vec<u8>>> = sources
830 .iter()
831 .map(|(_, oid)| odb.read(oid).ok().map(|obj| obj.data))
832 .collect();
833
834 let added_contents: Vec<Option<Vec<u8>>> = added
836 .iter()
837 .map(|a| odb.read(&a.new_oid).ok().map(|obj| obj.data))
838 .collect();
839
840 let mut scores: Vec<(u32, usize, usize)> = Vec::new();
842 for (si, (_, src_oid)) in sources.iter().enumerate() {
843 for (ai, add) in added.iter().enumerate() {
844 if *src_oid == add.new_oid {
845 scores.push((100, si, ai));
846 continue;
847 }
848 let score = match (&source_contents[si], &added_contents[ai]) {
849 (Some(old_data), Some(new_data)) => compute_similarity(old_data, new_data),
850 _ => 0,
851 };
852 if score >= threshold {
853 scores.push((score, si, ai));
854 }
855 }
856 }
857
858 scores.sort_by(|a, b| b.0.cmp(&a.0));
860
861 let mut used_added = vec![false; added.len()];
862 let mut copies: Vec<DiffEntry> = Vec::new();
863
864 for (score, si, ai) in &scores {
865 if used_added[*ai] {
866 continue;
867 }
868 used_added[*ai] = true;
869
870 let (ref src_path, _) = sources[*si];
871 let add = &added[*ai];
872
873 let src_mode = source_tree_entries
874 .iter()
875 .find(|(p, _, _)| p == src_path)
876 .map(|(_, m, _)| m.clone())
877 .unwrap_or_else(|| add.old_mode.clone());
878
879 copies.push(DiffEntry {
880 status: DiffStatus::Copied,
881 old_path: Some(src_path.clone()),
882 new_path: add.new_path.clone(),
883 old_mode: src_mode,
884 new_mode: add.new_mode.clone(),
885 old_oid: sources[*si].1,
886 new_oid: add.new_oid,
887 score: Some(*score),
888 });
889 }
890
891 let mut result = others;
892 result.extend(copies);
893 for (i, entry) in added.into_iter().enumerate() {
894 if !used_added[i] {
895 result.push(entry);
896 }
897 }
898
899 result.sort_by(|a, b| a.path().cmp(b.path()));
900 result
901}
902
903pub fn format_rename_path(old: &str, new: &str) -> String {
911 let ob = old.as_bytes();
912 let nb = new.as_bytes();
913
914 let pfx = {
916 let mut last_sep = 0usize;
917 let min_len = ob.len().min(nb.len());
918 for i in 0..min_len {
919 if ob[i] != nb[i] {
920 break;
921 }
922 if ob[i] == b'/' {
923 last_sep = i + 1;
924 }
925 }
926 last_sep
927 };
928
929 let mut sfx = {
931 let mut last_sep = 0usize;
932 let min_len = ob.len().min(nb.len());
933 for i in 0..min_len {
934 let oi = ob.len() - 1 - i;
935 let ni = nb.len() - 1 - i;
936 if ob[oi] != nb[ni] {
937 break;
938 }
939 if ob[oi] == b'/' {
940 last_sep = i + 1;
941 }
942 }
943 last_sep
944 };
945
946 let mut sfx_at_old = ob.len() - sfx;
948 let mut sfx_at_new = nb.len() - sfx;
949
950 while pfx > sfx_at_old && pfx > sfx_at_new && sfx > 0 {
953 let suffix_bytes = &ob[sfx_at_old..];
955 let mut new_sfx = 0;
956 for i in 1..suffix_bytes.len() {
958 if suffix_bytes[i] == b'/' {
959 new_sfx = sfx - i;
960 break;
961 }
962 }
963 if new_sfx == 0 || new_sfx >= sfx {
964 sfx = 0;
965 sfx_at_old = ob.len();
966 sfx_at_new = nb.len();
967 break;
968 }
969 sfx = new_sfx;
970 sfx_at_old = ob.len() - sfx;
971 sfx_at_new = nb.len() - sfx;
972 }
973
974 let prefix = &old[..pfx];
981 let suffix = &old[sfx_at_old..];
982 let old_mid = if pfx <= sfx_at_old {
983 &old[pfx..sfx_at_old]
984 } else {
985 ""
986 };
987 let new_mid = if pfx <= sfx_at_new {
988 &new[pfx..sfx_at_new]
989 } else {
990 ""
991 };
992
993 if prefix.is_empty() && suffix.is_empty() {
994 return format!("{old} => {new}");
995 }
996
997 format!("{prefix}{{{old_mid} => {new_mid}}}{suffix}")
998}
999
1000fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
1002 let old = del.old_path.as_deref().unwrap_or("");
1003 let new = add.new_path.as_deref().unwrap_or("");
1004 let old_base = old.rsplit('/').next().unwrap_or(old);
1005 let new_base = new.rsplit('/').next().unwrap_or(new);
1006 old_base == new_base && !old_base.is_empty()
1007}
1008
1009fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
1014 let src_size = old.len();
1015 let dst_size = new.len();
1016
1017 if src_size == 0 && dst_size == 0 {
1018 return 100;
1019 }
1020 let total = src_size + dst_size;
1021 if total == 0 {
1022 return 100;
1023 }
1024
1025 use similar::{ChangeTag, TextDiff};
1027 let old_str = String::from_utf8_lossy(old);
1028 let new_str = String::from_utf8_lossy(new);
1029 let diff = TextDiff::from_lines(&old_str as &str, &new_str as &str);
1030
1031 let mut shared_bytes = 0usize;
1032 for change in diff.iter_all_changes() {
1033 if change.tag() == ChangeTag::Equal {
1034 shared_bytes += change.value().len();
1036 }
1037 }
1038
1039 let max_size = src_size.max(dst_size);
1042 let score = ((shared_bytes * 100) / max_size).min(100) as u32;
1043 score
1044}
1045
1046pub fn format_raw(entry: &DiffEntry) -> String {
1052 let path = match entry.status {
1053 DiffStatus::Renamed | DiffStatus::Copied => {
1054 format!(
1055 "{}\t{}",
1056 entry.old_path.as_deref().unwrap_or(""),
1057 entry.new_path.as_deref().unwrap_or("")
1058 )
1059 }
1060 _ => entry.path().to_owned(),
1061 };
1062
1063 let status_str = match (entry.status, entry.score) {
1064 (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
1065 (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
1066 _ => entry.status.letter().to_string(),
1067 };
1068
1069 format!(
1070 ":{} {} {} {} {}\t{}",
1071 entry.old_mode,
1072 entry.new_mode,
1073 entry.old_oid,
1074 entry.new_oid,
1075 status_str,
1076 path
1077 )
1078}
1079
1080pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
1082 let old_hex = format!("{}", entry.old_oid);
1083 let new_hex = format!("{}", entry.new_oid);
1084 let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
1085 let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
1086
1087 let path = entry.path();
1088
1089 format!(
1090 ":{} {} {}... {}... {}\t{}",
1091 entry.old_mode,
1092 entry.new_mode,
1093 old_abbrev,
1094 new_abbrev,
1095 entry.status.letter(),
1096 path
1097 )
1098}
1099
1100pub fn unified_diff(
1114 old_content: &str,
1115 new_content: &str,
1116 old_path: &str,
1117 new_path: &str,
1118 context_lines: usize,
1119) -> String {
1120 use similar::TextDiff;
1121
1122 let diff = TextDiff::from_lines(old_content, new_content);
1123
1124 let mut output = String::new();
1125 if old_path == "/dev/null" {
1126 output.push_str("--- /dev/null\n");
1127 } else {
1128 output.push_str(&format!("--- a/{old_path}\n"));
1129 }
1130 if new_path == "/dev/null" {
1131 output.push_str("+++ /dev/null\n");
1132 } else {
1133 output.push_str(&format!("+++ b/{new_path}\n"));
1134 }
1135
1136 let old_lines: Vec<&str> = old_content.lines().collect();
1137
1138 for hunk in diff
1139 .unified_diff()
1140 .context_radius(context_lines)
1141 .iter_hunks()
1142 {
1143 let hunk_str = format!("{hunk}");
1144 if let Some(first_newline) = hunk_str.find('\n') {
1148 let header_line = &hunk_str[..first_newline];
1149 let rest = &hunk_str[first_newline..];
1150
1151 if let Some(func_ctx) = extract_function_context(header_line, &old_lines) {
1153 output.push_str(header_line);
1154 output.push(' ');
1155 output.push_str(&func_ctx);
1156 output.push_str(rest);
1157 } else {
1158 output.push_str(&hunk_str);
1159 }
1160 } else {
1161 output.push_str(&hunk_str);
1162 }
1163 }
1164
1165 output
1166}
1167
1168fn extract_function_context(header: &str, old_lines: &[&str]) -> Option<String> {
1174 let at_pos = header.find("-")?;
1176 let rest = &header[at_pos + 1..];
1177 let comma_or_space = rest.find(|c: char| c == ',' || c == ' ')?;
1178 let start_str = &rest[..comma_or_space];
1179 let start_line: usize = start_str.parse().ok()?;
1180
1181 if start_line <= 1 {
1182 return None;
1183 }
1184
1185 let search_end = (start_line - 1).min(old_lines.len());
1190 for i in (0..search_end).rev() {
1191 let line = old_lines[i];
1192 if !line.is_empty() {
1193 let first = line.as_bytes()[0];
1194 if first != b' ' && first != b'\t' {
1198 let truncated = if line.len() > 40 {
1200 &line[..40]
1201 } else {
1202 line
1203 };
1204 return Some(truncated.to_owned());
1205 }
1206 }
1207 }
1208 None
1209}
1210
1211pub fn format_stat_line(
1215 path: &str,
1216 insertions: usize,
1217 deletions: usize,
1218 max_path_len: usize,
1219) -> String {
1220 let total = insertions + deletions;
1221 let plus = "+".repeat(insertions.min(50));
1222 let minus = "-".repeat(deletions.min(50));
1223 format!(
1224 " {:<width$} | {:>4} {}{}",
1225 path,
1226 total,
1227 plus,
1228 minus,
1229 width = max_path_len
1230 )
1231}
1232
1233pub fn count_changes(old_content: &str, new_content: &str) -> (usize, usize) {
1237 use similar::{ChangeTag, TextDiff};
1238
1239 let diff = TextDiff::from_lines(old_content, new_content);
1240 let mut ins = 0;
1241 let mut del = 0;
1242
1243 for change in diff.iter_all_changes() {
1244 match change.tag() {
1245 ChangeTag::Insert => ins += 1,
1246 ChangeTag::Delete => del += 1,
1247 ChangeTag::Equal => {}
1248 }
1249 }
1250
1251 (ins, del)
1252}
1253
1254struct FlatEntry {
1258 path: String,
1259 mode: u32,
1260 oid: ObjectId,
1261}
1262
1263fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
1264 let entries = read_tree(odb, tree_oid)?;
1265 let mut result = Vec::new();
1266
1267 for entry in entries {
1268 let name_str = String::from_utf8_lossy(&entry.name);
1269 let path = format_path(prefix, &name_str);
1270 if is_tree_mode(entry.mode) {
1271 let nested = flatten_tree(odb, &entry.oid, &path)?;
1272 result.extend(nested);
1273 } else {
1274 result.push(FlatEntry {
1275 path,
1276 mode: entry.mode,
1277 oid: entry.oid,
1278 });
1279 }
1280 }
1281
1282 Ok(result)
1283}
1284
1285fn is_tree_mode(mode: u32) -> bool {
1287 mode == 0o040000
1288}
1289
1290fn format_path(prefix: &str, name: &str) -> String {
1292 if prefix.is_empty() {
1293 name.to_owned()
1294 } else {
1295 format!("{prefix}/{name}")
1296 }
1297}
1298
1299fn format_mode(mode: u32) -> String {
1301 format!("{mode:06o}")
1302}