1use std::borrow::Cow;
14use std::path::Path;
15
16use crate::hash::Hash;
17use crate::index::{Index, IndexError};
18use crate::object::{EntryMode, Object, TreeEntry};
19use crate::store::{MAX_TREE_DEPTH, ObjectSource, ObjectStore, StoreError};
20use crate::worktree::{self, WorktreeError};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum DiffKind {
25 Added,
27 Removed,
29 Modified,
31 ModeChanged,
33 Renamed,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiffEntry {
44 pub path: String,
45 pub kind: DiffKind,
46 pub old_hash: Option<Hash>,
47 pub new_hash: Option<Hash>,
48 pub old_mode: Option<EntryMode>,
51 pub new_mode: Option<EntryMode>,
52 pub old_path: Option<String>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct DiffResult {
60 pub entries: Vec<DiffEntry>,
61}
62
63impl DiffResult {
64 #[must_use]
65 pub fn is_empty(&self) -> bool {
66 self.entries.is_empty()
67 }
68
69 #[must_use]
70 pub fn len(&self) -> usize {
71 self.entries.len()
72 }
73}
74
75pub fn detect_content_renames<S: ObjectSource + ?Sized>(
83 store: &S,
84 entries: &mut Vec<DiffEntry>,
85) -> Result<(), StoreError> {
86 let mut memo = std::collections::HashMap::new();
87 for e in entries.iter() {
88 let hash = match e.kind {
89 DiffKind::Removed => e.old_hash,
90 DiffKind::Added => e.new_hash,
91 _ => None,
92 };
93 if let Some(h) = hash
94 && let std::collections::hash_map::Entry::Vacant(slot) = memo.entry(h)
95 {
96 slot.insert(worktree::content_fingerprint(store, &h)?);
97 }
98 }
99 let mut candidate = entries.clone();
100 pair_renames(&mut candidate, |h| memo[&h]);
101 for entry in &candidate {
102 if entry.kind == DiffKind::Renamed
103 && let (Some(a), Some(b)) = (entry.old_hash, entry.new_hash)
104 && !worktree::content_eq(store, &a, &b)?
105 {
106 return Err(StoreError::Io(std::io::Error::other(
107 "content fingerprint collision",
108 )));
109 }
110 }
111 *entries = candidate;
112 Ok(())
113}
114
115fn pair_renames<K: Eq + std::hash::Hash>(entries: &mut Vec<DiffEntry>, key: impl Fn(Hash) -> K) {
116 use std::collections::HashMap;
117
118 let mut removed: HashMap<K, Vec<usize>> = HashMap::new();
119 let mut added: HashMap<K, Vec<usize>> = HashMap::new();
120 for (i, e) in entries.iter().enumerate() {
121 match e.kind {
122 DiffKind::Removed => {
123 if let Some(h) = e.old_hash {
124 removed.entry(key(h)).or_default().push(i);
125 }
126 }
127 DiffKind::Added => {
128 if let Some(h) = e.new_hash {
129 added.entry(key(h)).or_default().push(i);
130 }
131 }
132 _ => {}
133 }
134 }
135
136 let mut consumed = vec![false; entries.len()];
137 let mut renames: Vec<DiffEntry> = Vec::new();
138 for (h, rem_idx) in &removed {
139 let Some(add_idx) = added.get(h) else {
140 continue;
141 };
142 let mut r = rem_idx.clone();
143 let mut a = add_idx.clone();
144 r.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
145 a.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
146 for (&ri, &ai) in r.iter().zip(a.iter()) {
147 renames.push(DiffEntry {
148 path: entries[ai].path.clone(),
149 kind: DiffKind::Renamed,
150 old_hash: entries[ri].old_hash,
151 new_hash: entries[ai].new_hash,
152 old_mode: entries[ri].old_mode,
153 new_mode: entries[ai].new_mode,
154 old_path: Some(entries[ri].path.clone()),
155 });
156 consumed[ri] = true;
157 consumed[ai] = true;
158 }
159 }
160
161 if renames.is_empty() {
162 return;
163 }
164 let mut out: Vec<DiffEntry> = Vec::with_capacity(entries.len());
165 for (i, e) in entries.drain(..).enumerate() {
166 if !consumed[i] {
167 out.push(e);
168 }
169 }
170 out.extend(renames);
171 out.sort_by(|a, b| a.path.cmp(&b.path));
172 *entries = out;
173}
174
175pub fn diff_trees<S: ObjectSource + ?Sized>(
184 store: &S,
185 old_hash: Option<Hash>,
186 new_hash: Option<Hash>,
187) -> Result<DiffResult, StoreError> {
188 diff_trees_inner(store, old_hash, new_hash, false)
189}
190
191fn diff_trees_inner<S: ObjectSource + ?Sized>(
192 store: &S,
193 old_hash: Option<Hash>,
194 new_hash: Option<Hash>,
195 ignore_regular_executable_mode: bool,
196) -> Result<DiffResult, StoreError> {
197 match (old_hash, new_hash) {
199 (None, None) => return Ok(DiffResult::default()),
200 (Some(a), Some(b)) if a == b => return Ok(DiffResult::default()),
201 _ => {}
202 }
203
204 let old_entries = load_entries(store, old_hash)?;
205 let new_entries = load_entries(store, new_hash)?;
206
207 let mut out: Vec<DiffEntry> = Vec::new();
208 diff_entries_recursive(
209 store,
210 &old_entries,
211 &new_entries,
212 "",
213 &mut out,
214 ignore_regular_executable_mode,
215 0,
216 )?;
217 out.sort_by(|a, b| a.path.cmp(&b.path));
222 Ok(DiffResult { entries: out })
223}
224
225fn diff_entries_recursive<S: ObjectSource + ?Sized>(
227 store: &S,
228 old_entries: &[TreeEntry],
229 new_entries: &[TreeEntry],
230 prefix: &str,
231 out: &mut Vec<DiffEntry>,
232 ignore_regular_executable_mode: bool,
233 depth: usize,
234) -> Result<(), StoreError> {
235 if depth > MAX_TREE_DEPTH {
236 return Err(StoreError::TreeTooDeep);
237 }
238 let mut i = 0usize;
239 let mut j = 0usize;
240
241 while i < old_entries.len() && j < new_entries.len() {
242 let o = &old_entries[i];
243 let n = &new_entries[j];
244 match o.name.as_slice().cmp(n.name.as_slice()) {
245 std::cmp::Ordering::Less => {
246 add_removed_entries(store, o, prefix, out, depth)?;
247 i += 1;
248 }
249 std::cmp::Ordering::Greater => {
250 add_added_entries(store, n, prefix, out, depth)?;
251 j += 1;
252 }
253 std::cmp::Ordering::Equal => {
254 if o.mode == EntryMode::Tree && n.mode == EntryMode::Tree {
255 if o.object_hash != n.object_hash {
256 let sub_prefix = join_path(prefix, &o.name);
257 let old_sub = load_tree(store, o.object_hash)?;
258 let new_sub = load_tree(store, n.object_hash)?;
259 diff_entries_recursive(
260 store,
261 &old_sub,
262 &new_sub,
263 &sub_prefix,
264 out,
265 ignore_regular_executable_mode,
266 depth + 1,
267 )?;
268 }
269 } else if o.mode == EntryMode::Tree || n.mode == EntryMode::Tree {
271 add_removed_entries(store, o, prefix, out, depth)?;
277 add_added_entries(store, n, prefix, out, depth)?;
278 } else if worktree::content_eq(store, &o.object_hash, &n.object_hash)? {
279 if o.mode == n.mode {
280 i += 1;
281 j += 1;
282 continue;
283 }
284 if !ignore_regular_executable_mode || !regular_executable_pair(o.mode, n.mode) {
285 out.push(DiffEntry {
286 path: join_path(prefix, &o.name),
287 kind: DiffKind::ModeChanged,
288 old_hash: Some(o.object_hash),
289 new_hash: Some(n.object_hash),
290 old_mode: Some(o.mode),
291 new_mode: Some(n.mode),
292 old_path: None,
293 });
294 }
295 } else if o.object_hash != n.object_hash || o.mode != n.mode {
296 out.push(DiffEntry {
297 path: join_path(prefix, &o.name),
298 kind: DiffKind::Modified,
299 old_hash: Some(o.object_hash),
300 new_hash: Some(n.object_hash),
301 old_mode: Some(o.mode),
302 new_mode: Some(n.mode),
303 old_path: None,
304 });
305 }
306 i += 1;
307 j += 1;
308 }
309 }
310 }
311
312 while i < old_entries.len() {
313 add_removed_entries(store, &old_entries[i], prefix, out, depth)?;
314 i += 1;
315 }
316 while j < new_entries.len() {
317 add_added_entries(store, &new_entries[j], prefix, out, depth)?;
318 j += 1;
319 }
320 Ok(())
321}
322
323fn regular_executable_pair(a: EntryMode, b: EntryMode) -> bool {
324 matches!(
325 (a, b),
326 (EntryMode::Blob, EntryMode::Executable) | (EntryMode::Executable, EntryMode::Blob)
327 )
328}
329
330fn add_removed_entries<S: ObjectSource + ?Sized>(
331 store: &S,
332 entry: &TreeEntry,
333 prefix: &str,
334 out: &mut Vec<DiffEntry>,
335 depth: usize,
336) -> Result<(), StoreError> {
337 if depth > MAX_TREE_DEPTH {
338 return Err(StoreError::TreeTooDeep);
339 }
340 if entry.mode == EntryMode::Tree {
341 let sub_prefix = join_path(prefix, &entry.name);
342 let sub = load_tree(store, entry.object_hash)?;
343 for sub_entry in &sub {
344 add_removed_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
345 }
346 } else {
347 out.push(DiffEntry {
348 path: join_path(prefix, &entry.name),
349 kind: DiffKind::Removed,
350 old_hash: Some(entry.object_hash),
351 new_hash: None,
352 old_mode: Some(entry.mode),
353 new_mode: None,
354 old_path: None,
355 });
356 }
357 Ok(())
358}
359
360fn add_added_entries<S: ObjectSource + ?Sized>(
361 store: &S,
362 entry: &TreeEntry,
363 prefix: &str,
364 out: &mut Vec<DiffEntry>,
365 depth: usize,
366) -> Result<(), StoreError> {
367 if depth > MAX_TREE_DEPTH {
368 return Err(StoreError::TreeTooDeep);
369 }
370 if entry.mode == EntryMode::Tree {
371 let sub_prefix = join_path(prefix, &entry.name);
372 let sub = load_tree(store, entry.object_hash)?;
373 for sub_entry in &sub {
374 add_added_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
375 }
376 } else {
377 out.push(DiffEntry {
378 path: join_path(prefix, &entry.name),
379 kind: DiffKind::Added,
380 old_hash: None,
381 new_hash: Some(entry.object_hash),
382 old_mode: None,
383 new_mode: Some(entry.mode),
384 old_path: None,
385 });
386 }
387 Ok(())
388}
389
390fn load_entries<S: ObjectSource + ?Sized>(
391 store: &S,
392 hash: Option<Hash>,
393) -> Result<Vec<TreeEntry>, StoreError> {
394 match hash {
395 Some(h) => load_tree(store, h),
396 None => Ok(Vec::new()),
397 }
398}
399
400fn load_tree<S: ObjectSource + ?Sized>(store: &S, h: Hash) -> Result<Vec<TreeEntry>, StoreError> {
401 match store.read_object(&h)? {
402 Object::Tree(t) => Ok(t.entries),
403 other => Err(StoreError::Decode(
404 crate::object::MkitError::InvalidObjectType(other.object_type() as u8),
405 )),
406 }
407}
408
409fn join_path(prefix: &str, name: &[u8]) -> String {
417 let name_str = String::from_utf8_lossy(name);
418 if prefix.is_empty() {
419 name_str.into_owned()
420 } else {
421 let mut s = String::with_capacity(prefix.len() + 1 + name_str.len());
422 s.push_str(prefix);
423 s.push('/');
424 s.push_str(&name_str);
425 s
426 }
427}
428
429const PATCH_CONTEXT: usize = 3;
435
436pub const DEFAULT_CONTEXT_LINES: usize = PATCH_CONTEXT;
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
450pub enum WhitespaceMode {
451 #[default]
453 Exact,
454 IgnoreSpaceChange,
458 IgnoreAllSpace,
461}
462
463fn ws_key(text: &[u8], mode: WhitespaceMode) -> Cow<'_, [u8]> {
466 match mode {
467 WhitespaceMode::Exact => Cow::Borrowed(text),
468 WhitespaceMode::IgnoreAllSpace => Cow::Owned(strip_all_ws(text)),
469 WhitespaceMode::IgnoreSpaceChange => Cow::Owned(collapse_ws(text)),
470 }
471}
472
473fn is_ws_byte(b: u8) -> bool {
476 b.is_ascii_whitespace() || b == 0x0B
477}
478
479fn strip_all_ws(line: &[u8]) -> Vec<u8> {
481 line.iter().copied().filter(|&b| !is_ws_byte(b)).collect()
482}
483
484fn collapse_ws(line: &[u8]) -> Vec<u8> {
489 let mut end = line.len();
490 while end > 0 && is_ws_byte(line[end - 1]) {
491 end -= 1;
492 }
493 let line = &line[..end];
494 let mut out = Vec::with_capacity(line.len());
495 let mut i = 0;
496 while i < line.len() {
497 if is_ws_byte(line[i]) {
498 out.push(b' ');
499 while i < line.len() && is_ws_byte(line[i]) {
500 i += 1;
501 }
502 } else {
503 out.push(line[i]);
504 i += 1;
505 }
506 }
507 out
508}
509
510#[must_use]
537pub fn text_patch(old_bytes: &[u8], new_bytes: &[u8], old_path: &str, new_path: &str) -> String {
538 match unified_hunks(old_bytes, new_bytes) {
539 None => format!("Binary files a/{old_path} and b/{new_path} differ\n"),
540 Some(hunks) if hunks.is_empty() => String::new(),
541 Some(hunks) => {
542 format!(
543 "--- a/{old_path}\n+++ b/{new_path}\n{}",
544 String::from_utf8_lossy(&hunks)
545 )
546 }
547 }
548}
549
550#[must_use]
561pub fn unified_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<u8>> {
562 unified_hunks_opts(old_bytes, new_bytes, PATCH_CONTEXT, WhitespaceMode::Exact)
563}
564
565#[must_use]
573pub fn unified_hunks_opts(
574 old_bytes: &[u8],
575 new_bytes: &[u8],
576 context: usize,
577 mode: WhitespaceMode,
578) -> Option<Vec<u8>> {
579 if is_binary(old_bytes) || is_binary(new_bytes) {
580 return None;
581 }
582 let old_lines = split_lines(old_bytes);
583 let new_lines = split_lines(new_bytes);
584 let ops = edit_script(&old_lines, &new_lines, mode);
585 let hunks = group_hunks(&ops, context);
586 let mut out = Vec::new();
587 for hunk in &hunks {
588 render_hunk(&mut out, hunk, &old_lines, &new_lines);
589 }
590 Some(out)
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596pub enum HunkLineKind {
597 Context,
599 Added,
601 Removed,
603}
604
605#[derive(Debug, Clone, PartialEq, Eq)]
608pub struct HunkLine {
609 pub kind: HunkLineKind,
611 pub text: Vec<u8>,
613 pub has_newline: bool,
615}
616
617#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct PatchHunk {
622 pub old_start: usize,
624 pub old_len: usize,
626 pub new_start: usize,
628 pub new_len: usize,
630 pub lines: Vec<HunkLine>,
632}
633
634#[must_use]
642pub fn enumerate_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<PatchHunk>> {
643 if is_binary(old_bytes) || is_binary(new_bytes) {
644 return None;
645 }
646 let old_lines = split_lines(old_bytes);
647 let new_lines = split_lines(new_bytes);
648 let ops = edit_script(&old_lines, &new_lines, WhitespaceMode::Exact);
649 let hunks = group_hunks(&ops, PATCH_CONTEXT);
650 Some(
651 hunks
652 .iter()
653 .map(|h| to_patch_hunk(h, &old_lines, &new_lines))
654 .collect(),
655 )
656}
657
658fn to_patch_hunk(hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> PatchHunk {
659 let lines = hunk
660 .ops
661 .iter()
662 .map(|op| {
663 let (kind, dl) = match *op {
664 DiffOp::Equal(oi, _) => (HunkLineKind::Context, &old[oi]),
665 DiffOp::Delete(oi) => (HunkLineKind::Removed, &old[oi]),
666 DiffOp::Insert(ni) => (HunkLineKind::Added, &new[ni]),
667 };
668 HunkLine {
669 kind,
670 text: dl.text.to_vec(),
671 has_newline: dl.has_newline,
672 }
673 })
674 .collect();
675 PatchHunk {
676 old_start: hunk.old_start,
677 old_len: hunk.old_len,
678 new_start: hunk.new_start,
679 new_len: hunk.new_len,
680 lines,
681 }
682}
683
684#[must_use]
695pub fn apply_hunks_subset(base_bytes: &[u8], hunks: &[PatchHunk], selected: &[usize]) -> Vec<u8> {
696 let old_lines = split_lines(base_bytes);
697 let sel: std::collections::HashSet<usize> = selected.iter().copied().collect();
698 let mut out: Vec<u8> = Vec::with_capacity(base_bytes.len());
699 let mut cursor = 0usize; for (i, h) in hunks.iter().enumerate() {
701 let region_start = if h.old_len == 0 {
705 h.old_start
706 } else {
707 h.old_start - 1
708 }
709 .min(old_lines.len());
710 let region_end = (region_start + h.old_len).min(old_lines.len());
711 for dl in &old_lines[cursor..region_start] {
713 emit_raw_line(&mut out, dl.text, dl.has_newline);
714 }
715 if sel.contains(&i) {
716 for l in &h.lines {
718 if l.kind != HunkLineKind::Removed {
719 emit_raw_line(&mut out, &l.text, l.has_newline);
720 }
721 }
722 } else {
723 for dl in &old_lines[region_start..region_end] {
725 emit_raw_line(&mut out, dl.text, dl.has_newline);
726 }
727 }
728 cursor = region_end;
729 }
730 for dl in &old_lines[cursor..] {
731 emit_raw_line(&mut out, dl.text, dl.has_newline);
732 }
733 out
734}
735
736fn emit_raw_line(out: &mut Vec<u8>, text: &[u8], has_newline: bool) {
737 out.extend_from_slice(text);
738 if has_newline {
739 out.push(b'\n');
740 }
741}
742
743struct MergeRegion {
747 base_start: usize,
748 base_len: usize,
749 new: Vec<(Vec<u8>, bool)>,
750}
751
752#[must_use]
762pub fn merge_blob_3way(base: &[u8], ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
763 if ours == theirs {
764 return Some(ours.to_vec());
765 }
766 if is_binary(base) || is_binary(ours) || is_binary(theirs) {
767 return None;
768 }
769 let base_lines = split_lines(base);
770 let ours_regions = changed_regions(&base_lines, &split_lines(ours));
771 let theirs_regions = changed_regions(&base_lines, &split_lines(theirs));
772 if regions_overlap(&ours_regions, &theirs_regions) {
773 return None;
774 }
775 Some(apply_merge_regions(
776 &base_lines,
777 &ours_regions,
778 &theirs_regions,
779 ))
780}
781
782fn changed_regions(base: &[DiffLine<'_>], side: &[DiffLine<'_>]) -> Vec<MergeRegion> {
785 let mut regions: Vec<MergeRegion> = Vec::new();
786 let mut cur: Option<MergeRegion> = None;
787 let mut base_idx = 0usize; for op in edit_script(base, side, WhitespaceMode::Exact) {
789 match op {
790 DiffOp::Equal(bi, _) => {
791 if let Some(r) = cur.take() {
792 regions.push(r);
793 }
794 base_idx = bi + 1;
795 }
796 DiffOp::Delete(bi) => {
797 cur.get_or_insert(MergeRegion {
798 base_start: base_idx,
799 base_len: 0,
800 new: Vec::new(),
801 })
802 .base_len += 1;
803 base_idx = bi + 1;
804 }
805 DiffOp::Insert(si) => {
806 let line = &side[si];
807 cur.get_or_insert(MergeRegion {
808 base_start: base_idx,
809 base_len: 0,
810 new: Vec::new(),
811 })
812 .new
813 .push((line.text.to_vec(), line.has_newline));
814 }
815 }
816 }
817 if let Some(r) = cur.take() {
818 regions.push(r);
819 }
820 regions
821}
822
823fn regions_overlap(ours: &[MergeRegion], theirs: &[MergeRegion]) -> bool {
829 ours.iter().any(|a| {
830 theirs.iter().any(|b| {
831 let (a_s, a_e) = (a.base_start, a.base_start + a.base_len);
832 let (b_s, b_e) = (b.base_start, b.base_start + b.base_len);
833 match (a.base_len == 0, b.base_len == 0) {
834 (true, true) => a_s == b_s,
835 (true, false) => b_s < a_s && a_s < b_e,
836 (false, true) => a_s < b_s && b_s < a_e,
837 (false, false) => a_s < b_e && b_s < a_e,
838 }
839 })
840 })
841}
842
843fn apply_merge_regions(
847 base: &[DiffLine<'_>],
848 ours: &[MergeRegion],
849 theirs: &[MergeRegion],
850) -> Vec<u8> {
851 let mut all: Vec<&MergeRegion> = ours.iter().chain(theirs.iter()).collect();
852 all.sort_by_key(|r| (r.base_start, r.base_len));
853 let mut out: Vec<u8> = Vec::new();
854 let mut cursor = 0usize;
855 for r in all {
856 for line in &base[cursor..r.base_start] {
857 emit_raw_line(&mut out, line.text, line.has_newline);
858 }
859 for (text, nl) in &r.new {
860 emit_raw_line(&mut out, text, *nl);
861 }
862 cursor = r.base_start + r.base_len;
863 }
864 for line in &base[cursor..] {
865 emit_raw_line(&mut out, line.text, line.has_newline);
866 }
867 out
868}
869
870#[must_use]
881pub fn diff_line_counts(old_bytes: &[u8], new_bytes: &[u8]) -> Option<(usize, usize)> {
882 if is_binary(old_bytes) || is_binary(new_bytes) {
883 return None;
884 }
885 let old_lines = split_lines(old_bytes);
886 let new_lines = split_lines(new_bytes);
887 let mut added = 0;
888 let mut deleted = 0;
889 for op in edit_script(&old_lines, &new_lines, WhitespaceMode::Exact) {
890 match op {
891 DiffOp::Insert(_) => added += 1,
892 DiffOp::Delete(_) => deleted += 1,
893 DiffOp::Equal(_, _) => {}
894 }
895 }
896 Some((added, deleted))
897}
898
899pub const BINARY_SNIFF_LEN: usize = 8000;
905
906#[must_use]
912pub fn is_binary(bytes: &[u8]) -> bool {
913 bytes.iter().take(BINARY_SNIFF_LEN).any(|&b| b == 0)
914}
915
916struct DiffLine<'a> {
919 text: &'a [u8],
920 has_newline: bool,
922}
923
924fn split_lines(text: &[u8]) -> Vec<DiffLine<'_>> {
927 let mut lines = Vec::new();
928 let mut rest = text;
929 while !rest.is_empty() {
930 if let Some(idx) = rest.iter().position(|&b| b == b'\n') {
931 lines.push(DiffLine {
932 text: &rest[..idx],
933 has_newline: true,
934 });
935 rest = &rest[idx + 1..];
936 } else {
937 lines.push(DiffLine {
938 text: rest,
939 has_newline: false,
940 });
941 rest = b"";
942 }
943 }
944 lines
945}
946
947#[derive(Debug, Clone, Copy, PartialEq, Eq)]
949enum DiffOp {
950 Equal(usize, usize),
952 Delete(usize),
954 Insert(usize),
956}
957
958fn edit_script(old: &[DiffLine<'_>], new: &[DiffLine<'_>], mode: WhitespaceMode) -> Vec<DiffOp> {
964 let (mut old_changed, mut new_changed) = myers_changed(old, new, mode);
965 compact_changes(old, &mut old_changed, mode);
966 compact_changes(new, &mut new_changed, mode);
967 script_from_flags(old, new, &old_changed, &new_changed)
968}
969
970fn myers_changed(
1001 old: &[DiffLine<'_>],
1002 new: &[DiffLine<'_>],
1003 mode: WhitespaceMode,
1004) -> (Vec<bool>, Vec<bool>) {
1005 let n = old.len();
1006 let m = new.len();
1007
1008 let max_prefix = n.min(m);
1009 let mut prefix = 0;
1010 while prefix < max_prefix && lines_equal(&old[prefix], &new[prefix], mode) {
1011 prefix += 1;
1012 }
1013
1014 let (mid_old_changed, mid_new_changed) =
1015 myers_changed_core(&old[prefix..], &new[prefix..], mode);
1016
1017 let mut old_changed = Vec::with_capacity(n);
1023 old_changed.resize(prefix, false);
1024 old_changed.extend(mid_old_changed);
1025 let mut new_changed = Vec::with_capacity(m);
1026 new_changed.resize(prefix, false);
1027 new_changed.extend(mid_new_changed);
1028 (old_changed, new_changed)
1029}
1030
1031#[allow(
1037 clippy::cast_sign_loss,
1038 clippy::cast_possible_wrap,
1039 clippy::many_single_char_names
1040)]
1041fn myers_changed_core(
1042 old: &[DiffLine<'_>],
1043 new: &[DiffLine<'_>],
1044 mode: WhitespaceMode,
1045) -> (Vec<bool>, Vec<bool>) {
1046 let n = old.len();
1047 let m = new.len();
1048 let mut old_changed = vec![false; n];
1049 let mut new_changed = vec![false; m];
1050 if n == 0 {
1051 new_changed.fill(true);
1052 return (old_changed, new_changed);
1053 }
1054 if m == 0 {
1055 old_changed.fill(true);
1056 return (old_changed, new_changed);
1057 }
1058
1059 let max = n + m;
1060 let offset = max as isize; let mut v = vec![0isize; 2 * max + 1];
1062 let mut trace: Vec<Vec<isize>> = Vec::new();
1063
1064 let idx = |k: isize| (k + offset) as usize;
1065 let mut found = max as isize;
1066 'outer: for d in 0..=max as isize {
1067 trace.push(v.clone());
1068 let mut k = -d;
1069 while k <= d {
1070 let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) {
1072 v[idx(k + 1)] } else {
1074 v[idx(k - 1)] + 1 };
1076 let mut y = x - k;
1077 while (x as usize) < n
1078 && (y as usize) < m
1079 && lines_equal(&old[x as usize], &new[y as usize], mode)
1080 {
1081 x += 1;
1082 y += 1;
1083 }
1084 v[idx(k)] = x;
1085 if x as usize >= n && y as usize >= m {
1086 found = d;
1087 break 'outer;
1088 }
1089 k += 2;
1090 }
1091 }
1092
1093 let mut x = n as isize;
1095 let mut y = m as isize;
1096 for d in (0..=found).rev() {
1097 let vd = &trace[d as usize];
1098 let k = x - y;
1099 let down = k == -d || (k != d && vd[idx(k - 1)] < vd[idx(k + 1)]);
1100 let prev_k = if down { k + 1 } else { k - 1 };
1101 let prev_x = vd[idx(prev_k)];
1102 let prev_y = prev_x - prev_k;
1103 while x > prev_x && y > prev_y {
1105 x -= 1;
1106 y -= 1;
1107 }
1108 if d > 0 {
1109 if down {
1110 new_changed[(y - 1) as usize] = true; y -= 1;
1112 } else {
1113 old_changed[(x - 1) as usize] = true; x -= 1;
1115 }
1116 }
1117 }
1118 (old_changed, new_changed)
1119}
1120
1121fn compact_changes(lines: &[DiffLine<'_>], changed: &mut [bool], mode: WhitespaceMode) {
1126 let n = lines.len();
1127 let mut i = 0;
1128 while i < n {
1129 if !changed[i] {
1130 i += 1;
1131 continue;
1132 }
1133 let start = i;
1135 let mut end = i;
1136 while end < n && changed[end] {
1137 end += 1;
1138 }
1139 let (mut s, mut e) = (start, end);
1142 while e < n && lines_equal(&lines[s], &lines[e], mode) {
1143 changed[s] = false;
1144 changed[e] = true;
1145 s += 1;
1146 e += 1;
1147 }
1148 i = e;
1149 }
1150}
1151
1152fn script_from_flags(
1156 old: &[DiffLine<'_>],
1157 new: &[DiffLine<'_>],
1158 old_changed: &[bool],
1159 new_changed: &[bool],
1160) -> Vec<DiffOp> {
1161 let (n, m) = (old.len(), new.len());
1162 let mut ops = Vec::new();
1163 let (mut i, mut j) = (0usize, 0usize);
1164 while i < n || j < m {
1165 if i < n && j < m && !old_changed[i] && !new_changed[j] {
1166 ops.push(DiffOp::Equal(i, j));
1167 i += 1;
1168 j += 1;
1169 } else {
1170 while i < n && old_changed[i] {
1171 ops.push(DiffOp::Delete(i));
1172 i += 1;
1173 }
1174 while j < m && new_changed[j] {
1175 ops.push(DiffOp::Insert(j));
1176 j += 1;
1177 }
1178 }
1179 }
1180 ops
1181}
1182
1183fn lines_equal(a: &DiffLine<'_>, b: &DiffLine<'_>, mode: WhitespaceMode) -> bool {
1188 a.has_newline == b.has_newline && ws_key(a.text, mode) == ws_key(b.text, mode)
1189}
1190
1191struct Hunk {
1194 old_start: usize,
1195 old_len: usize,
1196 new_start: usize,
1197 new_len: usize,
1198 ops: Vec<DiffOp>,
1199}
1200
1201fn group_hunks(ops: &[DiffOp], context: usize) -> Vec<Hunk> {
1204 let change_positions: Vec<usize> = ops
1206 .iter()
1207 .enumerate()
1208 .filter(|(_, op)| !matches!(op, DiffOp::Equal(_, _)))
1209 .map(|(idx, _)| idx)
1210 .collect();
1211 if change_positions.is_empty() {
1212 return Vec::new();
1213 }
1214
1215 let mut ranges: Vec<(usize, usize)> = Vec::new();
1218 for &pos in &change_positions {
1219 let start = pos.saturating_sub(context);
1220 let end = (pos + context + 1).min(ops.len());
1221 match ranges.last_mut() {
1222 Some(last) if start <= last.1 => last.1 = last.1.max(end),
1223 _ => ranges.push((start, end)),
1224 }
1225 }
1226
1227 ranges
1228 .into_iter()
1229 .map(|(start, end)| build_hunk(&ops[start..end]))
1230 .collect()
1231}
1232
1233fn build_hunk(slice: &[DiffOp]) -> Hunk {
1234 let mut old_start = None;
1235 let mut new_start = None;
1236 let mut old_len = 0usize;
1237 let mut new_len = 0usize;
1238 for op in slice {
1239 match *op {
1240 DiffOp::Equal(oi, ni) => {
1241 old_start.get_or_insert(oi);
1242 new_start.get_or_insert(ni);
1243 old_len += 1;
1244 new_len += 1;
1245 }
1246 DiffOp::Delete(oi) => {
1247 old_start.get_or_insert(oi);
1248 old_len += 1;
1249 }
1250 DiffOp::Insert(ni) => {
1251 new_start.get_or_insert(ni);
1252 new_len += 1;
1253 }
1254 }
1255 }
1256 Hunk {
1257 old_start: old_start.map_or(0, |s| s + 1),
1259 old_len,
1260 new_start: new_start.map_or(0, |s| s + 1),
1261 new_len,
1262 ops: slice.to_vec(),
1263 }
1264}
1265
1266fn hunk_range(start: usize, len: usize) -> String {
1269 if len == 1 {
1270 start.to_string()
1271 } else {
1272 format!("{start},{len}")
1273 }
1274}
1275
1276fn render_hunk(out: &mut Vec<u8>, hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) {
1277 let header = format!(
1278 "@@ -{} +{} @@\n",
1279 hunk_range(hunk.old_start, hunk.old_len),
1280 hunk_range(hunk.new_start, hunk.new_len)
1281 );
1282 out.extend_from_slice(header.as_bytes());
1283 for op in &hunk.ops {
1284 match *op {
1285 DiffOp::Equal(oi, _) => emit_line(out, b' ', &old[oi]),
1286 DiffOp::Delete(oi) => emit_line(out, b'-', &old[oi]),
1287 DiffOp::Insert(ni) => emit_line(out, b'+', &new[ni]),
1288 }
1289 }
1290}
1291
1292fn emit_line(out: &mut Vec<u8>, prefix: u8, line: &DiffLine<'_>) {
1293 out.push(prefix);
1294 out.extend_from_slice(line.text);
1295 out.push(b'\n');
1296 if !line.has_newline {
1297 out.extend_from_slice(b"\\ No newline at end of file\n");
1298 }
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1310pub enum StatusStaging {
1311 Unstaged,
1313 Staged,
1315 PartiallyStaged,
1318}
1319
1320#[derive(Debug, Clone, PartialEq, Eq)]
1323pub struct StatusEntry {
1324 pub diff: DiffEntry,
1326 pub staging: StatusStaging,
1328}
1329
1330#[derive(Debug, thiserror::Error)]
1332pub enum DiffError {
1333 #[error(transparent)]
1335 Store(#[from] StoreError),
1336 #[error(transparent)]
1338 Worktree(#[from] WorktreeError),
1339 #[error(transparent)]
1341 Index(#[from] IndexError),
1342}
1343
1344#[allow(clippy::too_many_lines)]
1375pub fn status_diff(
1376 store: &ObjectStore,
1377 head_tree: Option<&Hash>,
1378 worktree_root: &Path,
1379 index: Option<&Index>,
1380) -> Result<Vec<StatusEntry>, DiffError> {
1381 status_diff_observed(store, head_tree, worktree_root, index).map(|(entries, _)| entries)
1382}
1383
1384#[allow(clippy::type_complexity)]
1393pub fn status_diff_observed(
1394 store: &ObjectStore,
1395 head_tree: Option<&Hash>,
1396 worktree_root: &Path,
1397 index: Option<&Index>,
1398) -> Result<(Vec<StatusEntry>, Vec<worktree::StatObservation>), DiffError> {
1399 let head_seed;
1404 let tracked = if let Some(i) = index {
1405 Some(i)
1406 } else if let Some(ht) = head_tree {
1407 head_seed = crate::index::from_tree(store, *ht)?;
1408 Some(&head_seed)
1409 } else {
1410 None
1411 };
1412 let snapshot = crate::store::EphemeralSink::new(store);
1418 let mut observations = Vec::new();
1419 let work_tree_hash = worktree::build_tree_filtered_observed_with_source(
1420 &snapshot,
1421 &snapshot,
1422 worktree_root,
1423 tracked,
1424 &mut observations,
1425 )?;
1426
1427 let Some(idx) = index else {
1428 let diff = diff_worktree_trees(&snapshot, head_tree.copied(), Some(work_tree_hash))?;
1430 return Ok((
1431 diff.entries
1432 .into_iter()
1433 .map(|d| StatusEntry {
1434 diff: d,
1435 staging: StatusStaging::Unstaged,
1436 })
1437 .collect(),
1438 observations,
1439 ));
1440 };
1441
1442 let index_tree = worktree::build_tree_from_index_with(store, &snapshot, idx, false)?;
1446
1447 let staged = diff_trees(&snapshot, head_tree.copied(), Some(index_tree))?;
1448 let unstaged = diff_worktree_trees(&snapshot, Some(index_tree), Some(work_tree_hash))?;
1449
1450 let mut out: Vec<StatusEntry> =
1457 Vec::with_capacity(staged.entries.len() + unstaged.entries.len());
1458 for d in staged.entries {
1459 out.push(StatusEntry {
1460 diff: d,
1461 staging: StatusStaging::Staged,
1462 });
1463 }
1464 for d in unstaged.entries {
1465 out.push(StatusEntry {
1466 diff: d,
1467 staging: StatusStaging::Unstaged,
1468 });
1469 }
1470 out.sort_by(|a, b| {
1471 a.diff.path.cmp(&b.diff.path).then_with(|| {
1473 #[allow(clippy::match_same_arms)]
1474 match (a.staging, b.staging) {
1475 (StatusStaging::Staged, StatusStaging::Staged) => std::cmp::Ordering::Equal,
1476 (StatusStaging::Staged, _) => std::cmp::Ordering::Less,
1477 (_, StatusStaging::Staged) => std::cmp::Ordering::Greater,
1478 _ => std::cmp::Ordering::Equal,
1479 }
1480 })
1481 });
1482 Ok((out, observations))
1483}
1484
1485#[cfg(unix)]
1486fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1487 store: &S,
1488 old_hash: Option<Hash>,
1489 new_hash: Option<Hash>,
1490) -> Result<DiffResult, StoreError> {
1491 diff_trees(store, old_hash, new_hash)
1492}
1493
1494#[cfg(not(unix))]
1495fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1496 store: &S,
1497 old_hash: Option<Hash>,
1498 new_hash: Option<Hash>,
1499) -> Result<DiffResult, StoreError> {
1500 diff_trees_inner(store, old_hash, new_hash, true)
1501}
1502
1503#[cfg(test)]
1508#[allow(clippy::many_single_char_names)] mod tests {
1510 use super::*;
1511 use crate::object::{Blob, Tree};
1512 use crate::serialize;
1513 use tempfile::TempDir;
1514
1515 #[test]
1516 fn diff_line_counts_counts_text_and_flags_binary() {
1517 assert_eq!(
1519 diff_line_counts(b"a\nb\nc\n", b"a\nB\nc\nd\ne\n"),
1520 Some((3, 1))
1521 );
1522 assert_eq!(diff_line_counts(b"a\nb\n", b"a\x00b\n"), None);
1524 assert_eq!(diff_line_counts(b"x\x00y", b"z"), None);
1525 assert!(diff_line_counts(b"\xff\xfe\n", b"\xff\xfe\nmore\n").is_some());
1527 }
1528
1529 fn h(b: &[u8]) -> Hash {
1532 crate::hash::hash(b)
1533 }
1534 fn removed(path: &str, content: &[u8]) -> DiffEntry {
1535 DiffEntry {
1536 path: path.into(),
1537 kind: DiffKind::Removed,
1538 old_hash: Some(h(content)),
1539 new_hash: None,
1540 old_mode: Some(EntryMode::Blob),
1541 new_mode: None,
1542 old_path: None,
1543 }
1544 }
1545 fn added(path: &str, content: &[u8]) -> DiffEntry {
1546 DiffEntry {
1547 path: path.into(),
1548 kind: DiffKind::Added,
1549 old_hash: None,
1550 new_hash: Some(h(content)),
1551 old_mode: None,
1552 new_mode: Some(EntryMode::Blob),
1553 old_path: None,
1554 }
1555 }
1556
1557 #[test]
1558 fn detect_pairs_identical_content_into_one_rename() {
1559 let mut es = vec![removed("old.txt", b"hello"), added("new.txt", b"hello")];
1560 pair_renames(&mut es, |h| h);
1561 assert_eq!(es.len(), 1);
1562 assert_eq!(es[0].kind, DiffKind::Renamed);
1563 assert_eq!(es[0].path, "new.txt");
1564 assert_eq!(es[0].old_path.as_deref(), Some("old.txt"));
1565 assert_eq!(es[0].old_hash, Some(h(b"hello")));
1566 assert_eq!(es[0].new_hash, Some(h(b"hello")));
1567 }
1568
1569 #[test]
1570 fn detect_leaves_unrelated_delete_add_alone() {
1571 let mut es = vec![removed("a", b"one"), added("b", b"two")];
1572 let before = es.clone();
1573 pair_renames(&mut es, |h| h);
1574 assert_eq!(es, before);
1575 }
1576
1577 #[test]
1578 fn detect_ignores_modified_in_place() {
1579 let mut es = vec![DiffEntry {
1580 path: "f".into(),
1581 kind: DiffKind::Modified,
1582 old_hash: Some(h(b"x")),
1583 new_hash: Some(h(b"y")),
1584 old_mode: Some(EntryMode::Blob),
1585 new_mode: Some(EntryMode::Blob),
1586 old_path: None,
1587 }];
1588 let before = es.clone();
1589 pair_renames(&mut es, |h| h);
1590 assert_eq!(es, before);
1591 }
1592
1593 #[test]
1594 fn detect_pairs_duplicates_deterministically_by_path() {
1595 let mut es = vec![
1599 added("new_b", b"dup"),
1600 removed("old_b", b"dup"),
1601 added("new_a", b"dup"),
1602 removed("old_a", b"dup"),
1603 ];
1604 pair_renames(&mut es, |h| h);
1605 assert_eq!(es.len(), 2);
1606 assert_eq!(
1607 (es[0].old_path.as_deref(), es[0].path.as_str()),
1608 (Some("old_a"), "new_a")
1609 );
1610 assert_eq!(
1611 (es[1].old_path.as_deref(), es[1].path.as_str()),
1612 (Some("old_b"), "new_b")
1613 );
1614 }
1615
1616 #[test]
1617 fn detect_leaves_unpaired_remainder() {
1618 let mut es = vec![
1621 removed("old_a", b"c"),
1622 removed("old_b", b"c"),
1623 added("new", b"c"),
1624 ];
1625 pair_renames(&mut es, |h| h);
1626 assert_eq!(es.len(), 2);
1627 let r = es.iter().find(|e| e.kind == DiffKind::Renamed).unwrap();
1628 assert_eq!(r.old_path.as_deref(), Some("old_a"));
1629 let d = es.iter().find(|e| e.kind == DiffKind::Removed).unwrap();
1630 assert_eq!(d.path, "old_b");
1631 }
1632
1633 #[test]
1634 fn detect_preserves_modes_on_rename_with_mode_change() {
1635 let r = removed("old", b"same");
1638 let mut a = added("new", b"same");
1639 a.new_mode = Some(EntryMode::Executable);
1640 let mut es = vec![r, a];
1641 pair_renames(&mut es, |h| h);
1642 assert_eq!(es.len(), 1);
1643 assert_eq!(es[0].kind, DiffKind::Renamed);
1644 assert_eq!(es[0].old_mode, Some(EntryMode::Blob));
1645 assert_eq!(es[0].new_mode, Some(EntryMode::Executable));
1646 }
1647
1648 fn fresh_store() -> (TempDir, ObjectStore) {
1649 let dir = TempDir::new().unwrap();
1650 let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
1651 (dir, store)
1652 }
1653
1654 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
1655 let obj = Object::Blob(Blob {
1656 data: data.to_vec(),
1657 });
1658 let bytes = serialize::serialize(&obj).unwrap();
1659 store.write(&bytes).unwrap()
1660 }
1661
1662 fn put_tree(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
1663 let obj = Object::Tree(Tree { entries });
1664 let bytes = serialize::serialize(&obj).unwrap();
1665 store.write(&bytes).unwrap()
1666 }
1667
1668 fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
1669 TreeEntry {
1670 name: name.to_vec(),
1671 mode,
1672 object_hash: h,
1673 }
1674 }
1675
1676 #[test]
1677 fn equal_content_different_chunk_layout_is_clean() {
1678 let (_d, s) = fresh_store();
1679 let inline = put_blob(&s, b"abcdef");
1680 let a = put_blob(&s, b"ab");
1681 let b = put_blob(&s, b"cdef");
1682 let manifest = Object::ChunkedBlob(crate::object::ChunkedBlob {
1683 total_size: 6,
1684 chunk_size: 0,
1685 chunks: vec![a, b],
1686 });
1687 let chunked = s.write(&serialize::serialize(&manifest).unwrap()).unwrap();
1688 assert_ne!(inline, chunked);
1689 let old = put_tree(&s, vec![entry(b"a", EntryMode::Blob, chunked)]);
1690 let new = put_tree(&s, vec![entry(b"a", EntryMode::Blob, inline)]);
1691 assert!(diff_trees(&s, Some(old), Some(new)).unwrap().is_empty());
1692 let moved = put_tree(&s, vec![entry(b"b", EntryMode::Blob, inline)]);
1693 let mut renamed = diff_trees(&s, Some(old), Some(moved)).unwrap();
1694 detect_content_renames(&s, &mut renamed.entries).unwrap();
1695 assert_eq!(renamed.entries.len(), 1);
1696 assert_eq!(renamed.entries[0].kind, DiffKind::Renamed);
1697 assert_eq!(renamed.entries[0].old_hash, Some(chunked));
1698 assert_eq!(renamed.entries[0].new_hash, Some(inline));
1699 let executable = put_tree(&s, vec![entry(b"a", EntryMode::Executable, inline)]);
1700 assert_eq!(
1701 diff_trees(&s, Some(old), Some(executable)).unwrap().entries[0].kind,
1702 DiffKind::ModeChanged
1703 );
1704 let merge = crate::ops::merge_trees(&s, None, Some(old), Some(new)).unwrap();
1705 assert!(!merge.has_conflicts());
1706 let work = fresh_workdir();
1707 std::fs::write(work.path().join("a"), b"abcdef").unwrap();
1708 let idx = crate::index::from_tree(&s, old).unwrap();
1709 let (status, observations) =
1710 status_diff_observed(&s, Some(&old), work.path(), Some(&idx)).unwrap();
1711 assert!(status.is_empty());
1712 assert_eq!(observations[0].object_hash, chunked);
1713 }
1714
1715 #[test]
1716 fn identical_trees_no_diff() {
1717 let (_d, s) = fresh_store();
1718 let blob = put_blob(&s, b"content");
1719 let tree = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob)]);
1720 let result = diff_trees(&s, Some(tree), Some(tree)).unwrap();
1721 assert_eq!(result.len(), 0);
1722 }
1723
1724 #[test]
1725 fn added_file_detected() {
1726 let (_d, s) = fresh_store();
1727 let blob_a = put_blob(&s, b"aaa");
1728 let blob_b = put_blob(&s, b"bbb");
1729 let old = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1730 let new = put_tree(
1731 &s,
1732 vec![
1733 entry(b"a.txt", EntryMode::Blob, blob_a),
1734 entry(b"b.txt", EntryMode::Blob, blob_b),
1735 ],
1736 );
1737 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1738 assert_eq!(r.entries.len(), 1);
1739 assert_eq!(r.entries[0].path, "b.txt");
1740 assert_eq!(r.entries[0].kind, DiffKind::Added);
1741 assert_eq!(r.entries[0].old_hash, None);
1742 assert_eq!(r.entries[0].new_hash, Some(blob_b));
1743 }
1744
1745 #[test]
1746 fn removed_file_detected() {
1747 let (_d, s) = fresh_store();
1748 let blob_a = put_blob(&s, b"aaa");
1749 let blob_b = put_blob(&s, b"bbb");
1750 let old = put_tree(
1751 &s,
1752 vec![
1753 entry(b"a.txt", EntryMode::Blob, blob_a),
1754 entry(b"b.txt", EntryMode::Blob, blob_b),
1755 ],
1756 );
1757 let new = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1758 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1759 assert_eq!(r.entries.len(), 1);
1760 assert_eq!(r.entries[0].path, "b.txt");
1761 assert_eq!(r.entries[0].kind, DiffKind::Removed);
1762 assert_eq!(r.entries[0].old_hash, Some(blob_b));
1763 assert_eq!(r.entries[0].new_hash, None);
1764 }
1765
1766 #[test]
1767 fn modified_file_detected() {
1768 let (_d, s) = fresh_store();
1769 let v1 = put_blob(&s, b"version 1");
1770 let v2 = put_blob(&s, b"version 2");
1771 let old = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v1)]);
1772 let new = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v2)]);
1773 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1774 assert_eq!(r.entries.len(), 1);
1775 assert_eq!(r.entries[0].path, "file.txt");
1776 assert_eq!(r.entries[0].kind, DiffKind::Modified);
1777 assert_eq!(r.entries[0].old_hash, Some(v1));
1778 assert_eq!(r.entries[0].new_hash, Some(v2));
1779 }
1780
1781 #[test]
1782 fn mode_change_detected() {
1783 let (_d, s) = fresh_store();
1784 let blob = put_blob(&s, b"content");
1785 let old = put_tree(&s, vec![entry(b"link", EntryMode::Blob, blob)]);
1786 let new = put_tree(&s, vec![entry(b"link", EntryMode::Symlink, blob)]);
1787 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1788 assert_eq!(r.entries.len(), 1);
1789 assert_eq!(r.entries[0].path, "link");
1790 assert_eq!(r.entries[0].kind, DiffKind::ModeChanged);
1791 assert_eq!(r.entries[0].old_hash, Some(blob));
1792 assert_eq!(r.entries[0].new_hash, Some(blob));
1793 }
1794
1795 #[test]
1796 fn nested_tree_diff() {
1797 let (_d, s) = fresh_store();
1798 let v1 = put_blob(&s, b"old content");
1799 let v2 = put_blob(&s, b"new content");
1800 let other = put_blob(&s, b"unchanged");
1801 let old_sub = put_tree(
1802 &s,
1803 vec![
1804 entry(b"file.txt", EntryMode::Blob, v1),
1805 entry(b"other.txt", EntryMode::Blob, other),
1806 ],
1807 );
1808 let new_sub = put_tree(
1809 &s,
1810 vec![
1811 entry(b"file.txt", EntryMode::Blob, v2),
1812 entry(b"other.txt", EntryMode::Blob, other),
1813 ],
1814 );
1815 let old_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, old_sub)]);
1816 let new_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, new_sub)]);
1817 let r = diff_trees(&s, Some(old_root), Some(new_root)).unwrap();
1818 assert_eq!(r.entries.len(), 1);
1819 assert_eq!(r.entries[0].path, "subdir/file.txt");
1820 assert_eq!(r.entries[0].kind, DiffKind::Modified);
1821 }
1822
1823 #[test]
1824 fn diff_against_empty_tree() {
1825 let (_d, s) = fresh_store();
1826 let blob_a = put_blob(&s, b"aaa");
1827 let blob_b = put_blob(&s, b"bbb");
1828 let new = put_tree(
1829 &s,
1830 vec![
1831 entry(b"a.txt", EntryMode::Blob, blob_a),
1832 entry(b"b.txt", EntryMode::Blob, blob_b),
1833 ],
1834 );
1835 let r = diff_trees(&s, None, Some(new)).unwrap();
1836 assert_eq!(r.entries.len(), 2);
1837 assert_eq!(r.entries[0].path, "a.txt");
1838 assert_eq!(r.entries[0].kind, DiffKind::Added);
1839 assert_eq!(r.entries[1].path, "b.txt");
1840 assert_eq!(r.entries[1].kind, DiffKind::Added);
1841 }
1842
1843 #[test]
1844 fn empty_tree_against_non_empty() {
1845 let (_d, s) = fresh_store();
1846 let blob_a = put_blob(&s, b"aaa");
1847 let blob_b = put_blob(&s, b"bbb");
1848 let old = put_tree(
1849 &s,
1850 vec![
1851 entry(b"a.txt", EntryMode::Blob, blob_a),
1852 entry(b"b.txt", EntryMode::Blob, blob_b),
1853 ],
1854 );
1855 let r = diff_trees(&s, Some(old), None).unwrap();
1856 assert_eq!(r.entries.len(), 2);
1857 assert_eq!(r.entries[0].kind, DiffKind::Removed);
1858 assert_eq!(r.entries[1].kind, DiffKind::Removed);
1859 }
1860
1861 #[test]
1862 fn sorted_output() {
1863 let (_d, s) = fresh_store();
1864 let a = put_blob(&s, b"a");
1865 let b = put_blob(&s, b"b");
1866 let c = put_blob(&s, b"c");
1867 let new = put_tree(
1868 &s,
1869 vec![
1870 entry(b"a.txt", EntryMode::Blob, a),
1871 entry(b"m.txt", EntryMode::Blob, b),
1872 entry(b"z.txt", EntryMode::Blob, c),
1873 ],
1874 );
1875 let r = diff_trees(&s, None, Some(new)).unwrap();
1876 assert_eq!(r.entries.len(), 3);
1877 assert_eq!(r.entries[0].path, "a.txt");
1878 assert_eq!(r.entries[1].path, "m.txt");
1879 assert_eq!(r.entries[2].path, "z.txt");
1880 }
1881
1882 #[test]
1883 fn max_length_entry_names() {
1884 let (_d, s) = fresh_store();
1885 let blob = put_blob(&s, b"data");
1886 let long_name = vec![b'A'; 255];
1887 let new = put_tree(&s, vec![entry(&long_name, EntryMode::Blob, blob)]);
1888 let r = diff_trees(&s, None, Some(new)).unwrap();
1889 assert_eq!(r.entries.len(), 1);
1890 assert_eq!(r.entries[0].path.len(), 255);
1891 assert_eq!(r.entries[0].kind, DiffKind::Added);
1892 }
1893
1894 #[test]
1895 fn both_none_is_empty() {
1896 let (_d, s) = fresh_store();
1897 let r = diff_trees(&s, None, None).unwrap();
1898 assert!(r.is_empty());
1899 }
1900
1901 fn fresh_workdir() -> TempDir {
1906 TempDir::new().unwrap()
1907 }
1908
1909 #[test]
1910 fn status_diff_does_not_fsync() {
1911 use crate::batch::testing::{Ev, RecordingSyncer};
1916 use std::sync::Arc;
1917
1918 let (_sd, mut store) = fresh_store();
1919 let work = fresh_workdir();
1920 std::fs::write(work.path().join("a.txt"), b"some content").unwrap();
1921 std::fs::write(work.path().join("b.txt"), b"other content").unwrap();
1922
1923 let rec = Arc::new(RecordingSyncer::default());
1924 store.set_syncer(rec.clone());
1925
1926 let result = status_diff(&store, None, work.path(), None).unwrap();
1927 assert!(!result.is_empty(), "untracked files must surface");
1928
1929 let evs = rec.events();
1930 assert!(
1931 evs.iter().all(|e| matches!(e, Ev::Rename { .. })),
1932 "status must not emit any flush events; got {evs:?}"
1933 );
1934 }
1935
1936 #[test]
1937 fn status_empty_worktree_no_head() {
1938 let (_sd, store) = fresh_store();
1940 let work = fresh_workdir();
1941 let result = status_diff(&store, None, work.path(), None).unwrap();
1942 assert!(result.is_empty());
1943 }
1944
1945 #[test]
1946 fn status_worktree_equals_head_is_clean() {
1947 let (_sd, store) = fresh_store();
1949 let work = fresh_workdir();
1950 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1951 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1953 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1954 assert!(result.is_empty(), "expected clean, got {result:?}");
1955 }
1956
1957 #[cfg(not(unix))]
1958 #[test]
1959 fn status_no_index_ignores_unrepresentable_executable_mode_on_non_unix() {
1960 let (_sd, store) = fresh_store();
1961 let work = fresh_workdir();
1962 std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
1963 let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
1964 let head_hash = put_tree(&store, vec![entry(b"run.sh", EntryMode::Executable, h)]);
1965
1966 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1967 assert!(
1968 result.is_empty(),
1969 "non-Unix should not report executable-only noise, got {result:?}"
1970 );
1971 }
1972
1973 #[test]
1974 fn status_added_only() {
1975 let (_sd, store) = fresh_store();
1977 let work = fresh_workdir();
1978 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1979 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1980 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1981 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1982 assert_eq!(result.len(), 1);
1983 assert_eq!(result[0].diff.path, "b.txt");
1984 assert_eq!(result[0].diff.kind, DiffKind::Added);
1985 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1986 }
1987
1988 #[test]
1989 fn status_removed_only() {
1990 let (_sd, store) = fresh_store();
1992 let work = fresh_workdir();
1993 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1994 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1995 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1996 std::fs::remove_file(work.path().join("b.txt")).unwrap();
1997 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1998 assert_eq!(result.len(), 1);
1999 assert_eq!(result[0].diff.path, "b.txt");
2000 assert_eq!(result[0].diff.kind, DiffKind::Removed);
2001 assert_eq!(result[0].staging, StatusStaging::Unstaged);
2002 }
2003
2004 #[test]
2005 fn status_modified_only() {
2006 let (_sd, store) = fresh_store();
2008 let work = fresh_workdir();
2009 std::fs::write(work.path().join("a.txt"), b"old").unwrap();
2010 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
2011 std::fs::write(work.path().join("a.txt"), b"new").unwrap();
2012 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
2013 assert_eq!(result.len(), 1);
2014 assert_eq!(result[0].diff.path, "a.txt");
2015 assert_eq!(result[0].diff.kind, DiffKind::Modified);
2016 assert_eq!(result[0].staging, StatusStaging::Unstaged);
2017 }
2018
2019 #[test]
2020 fn status_mixed_changes() {
2021 let (_sd, store) = fresh_store();
2023 let work = fresh_workdir();
2024 std::fs::write(work.path().join("a.txt"), b"original").unwrap();
2025 std::fs::write(work.path().join("b.txt"), b"stays").unwrap();
2026 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
2027 std::fs::write(work.path().join("a.txt"), b"changed").unwrap();
2028 std::fs::remove_file(work.path().join("b.txt")).unwrap();
2029 std::fs::write(work.path().join("c.txt"), b"new").unwrap();
2030 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
2031 assert_eq!(result.len(), 3);
2032 let paths: Vec<&str> = result.iter().map(|e| e.diff.path.as_str()).collect();
2033 assert!(paths.contains(&"a.txt"), "missing a.txt: {paths:?}");
2034 assert!(paths.contains(&"b.txt"), "missing b.txt: {paths:?}");
2035 assert!(paths.contains(&"c.txt"), "missing c.txt: {paths:?}");
2036 }
2037
2038 #[test]
2039 fn status_no_head_shows_all_as_added() {
2040 let (_sd, store) = fresh_store();
2042 let work = fresh_workdir();
2043 std::fs::write(work.path().join("a.txt"), b"aaa").unwrap();
2044 std::fs::write(work.path().join("b.txt"), b"bbb").unwrap();
2045 let result = status_diff(&store, None, work.path(), None).unwrap();
2046 assert_eq!(result.len(), 2);
2047 for e in &result {
2048 assert_eq!(e.diff.kind, DiffKind::Added);
2049 assert_eq!(e.staging, StatusStaging::Unstaged);
2050 }
2051 }
2052
2053 #[test]
2058 fn status_staged_entry_is_classified_staged() {
2059 use crate::index::{EntryStatus, Index, IndexEntry};
2060 let (_sd, store) = fresh_store();
2061 let work = fresh_workdir();
2062 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
2063 let b_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
2064 let mut idx = Index::new();
2065 idx.entries.push(IndexEntry {
2066 path: "b.txt".to_string(),
2067 status: EntryStatus::Blob,
2068 object_hash: b_hash,
2069 mtime_ns: 0,
2070 size: 0,
2071 ino: 0,
2072 ctime_ns: 0,
2073 });
2074 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2076 assert_eq!(result.len(), 1);
2077 assert_eq!(result[0].diff.path, "b.txt");
2078 assert_eq!(result[0].staging, StatusStaging::Staged);
2079 }
2080
2081 #[cfg(not(unix))]
2082 #[test]
2083 fn status_ignores_unrepresentable_executable_mode_on_non_unix_worktree() {
2084 use crate::index::{EntryStatus, Index, IndexEntry};
2085
2086 let (_sd, store) = fresh_store();
2087 let work = fresh_workdir();
2088 std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
2089 let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
2090 let mut idx = Index::new();
2091 idx.entries.push(IndexEntry {
2092 path: "run.sh".to_string(),
2093 status: EntryStatus::Executable,
2094 object_hash: h,
2095 mtime_ns: 0,
2096 size: 0,
2097 ino: 0,
2098 ctime_ns: 0,
2099 });
2100
2101 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2102 assert_eq!(result.len(), 1, "expected only the staged addition");
2103 assert_eq!(result[0].staging, StatusStaging::Staged);
2104 }
2105
2106 #[test]
2111 fn text_patch_modified_line_emits_hunk() {
2112 let old = b"line1\nline2\nline3\n";
2113 let new = b"line1\nCHANGED\nline3\n";
2114 let patch = text_patch(old, new, "f.txt", "f.txt");
2115 assert!(patch.starts_with("--- a/f.txt\n+++ b/f.txt\n"), "{patch}");
2116 assert!(patch.contains("@@ -1,3 +1,3 @@"), "{patch}");
2117 assert!(patch.contains("-line2\n"), "{patch}");
2118 assert!(patch.contains("+CHANGED\n"), "{patch}");
2119 assert!(patch.contains(" line1\n"), "{patch}");
2120 assert!(patch.contains(" line3\n"), "{patch}");
2121 }
2122
2123 #[test]
2124 fn text_patch_pure_addition() {
2125 let old = b"a\nb\n";
2126 let new = b"a\nb\nc\n";
2127 let patch = text_patch(old, new, "f", "f");
2128 assert!(patch.contains("+c\n"), "{patch}");
2129 assert!(
2131 !patch
2132 .lines()
2133 .any(|l| l.starts_with('-') && !l.starts_with("---")),
2134 "should be no deletions: {patch}"
2135 );
2136 }
2137
2138 #[test]
2139 fn text_patch_identical_is_empty() {
2140 let patch = text_patch(b"same\n", b"same\n", "f", "f");
2141 assert!(patch.is_empty(), "{patch}");
2142 }
2143
2144 #[test]
2145 fn text_patch_binary_reports_differ() {
2146 let old = &[0x00, 0xff, 0x01][..];
2147 let new = &[0x00, 0xfe, 0x02][..];
2148 let patch = text_patch(old, new, "bin", "bin");
2149 assert_eq!(patch, "Binary files a/bin and b/bin differ\n");
2150 }
2151
2152 #[test]
2153 fn text_patch_nul_byte_is_binary_like_git() {
2154 let patch = text_patch(b"a\0b\n", b"a\0c\n", "f", "f");
2157 assert_eq!(patch, "Binary files a/f and b/f differ\n");
2158 }
2159
2160 #[test]
2161 fn unified_hunks_non_utf8_without_nul_is_text_like_git() {
2162 let hunks = unified_hunks(b"\xff\n", b"\xfe\n").expect("not binary");
2166 assert_eq!(
2167 hunks,
2168 b"@@ -1 +1 @@\n-\xff\n+\xfe\n".to_vec(),
2169 "got {hunks:?}"
2170 );
2171 }
2172
2173 #[test]
2174 fn hunk_header_omits_count_one_like_git() {
2175 let patch = text_patch(b"old\n", b"new\n", "f", "f");
2177 assert_eq!(
2178 patch, "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n",
2179 "{patch}"
2180 );
2181 }
2182
2183 #[test]
2184 fn text_patch_no_trailing_newline_marker() {
2185 let old = b"x\ny";
2186 let new = b"x\nz";
2187 let patch = text_patch(old, new, "f", "f");
2188 assert!(patch.contains("\\ No newline at end of file\n"), "{patch}");
2189 }
2190
2191 #[test]
2192 fn text_patch_separate_hunks_for_distant_changes() {
2193 let old = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
2194 let new = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
2196 let patch = text_patch(old, new, "f", "f");
2197 let hunk_count = patch.matches("@@ ").count();
2198 assert_eq!(hunk_count, 2, "expected two hunks: {patch}");
2199 }
2200
2201 #[test]
2202 fn text_patch_inserts_compact_to_git_position() {
2203 let patch = text_patch(b"a\na\nb\n", b"a\na\na\nb\n", "f", "f");
2208 assert_eq!(
2209 patch, "--- a/f\n+++ b/f\n@@ -1,3 +1,4 @@\n a\n a\n+a\n b\n",
2210 "{patch}"
2211 );
2212 }
2213
2214 #[test]
2215 fn text_patch_deletes_compact_to_git_position() {
2216 let patch = text_patch(b"a\na\na\nb\n", b"a\na\nb\n", "f", "f");
2219 assert_eq!(
2220 patch, "--- a/f\n+++ b/f\n@@ -1,4 +1,3 @@\n a\n a\n-a\n b\n",
2221 "{patch}"
2222 );
2223 }
2224
2225 #[test]
2232 fn status_partially_staged_entry_emits_both_legs() {
2233 use crate::index::{EntryStatus, Index, IndexEntry};
2234 let (_sd, store) = fresh_store();
2235 let work = fresh_workdir();
2236 std::fs::write(work.path().join("b.txt"), b"v1").unwrap();
2238 let b_v1_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
2239 std::fs::write(work.path().join("b.txt"), b"v2").unwrap();
2240 let mut idx = Index::new();
2241 idx.entries.push(IndexEntry {
2242 path: "b.txt".to_string(),
2243 status: EntryStatus::Blob,
2244 object_hash: b_v1_hash,
2245 mtime_ns: 0,
2246 size: 0,
2247 ino: 0,
2248 ctime_ns: 0,
2249 });
2250 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2251 assert_eq!(result.len(), 2, "expected staged + unstaged entries");
2252 let stagings: Vec<_> = result.iter().map(|e| e.staging).collect();
2253 assert!(stagings.contains(&StatusStaging::Staged));
2254 assert!(stagings.contains(&StatusStaging::Unstaged));
2255 assert!(result.iter().all(|e| e.diff.path == "b.txt"));
2256 }
2257
2258 #[test]
2261 fn enumerate_hunks_binary_returns_none() {
2262 assert!(enumerate_hunks(b"a\0b", b"c").is_none());
2263 assert!(enumerate_hunks(b"text\n", b"with\0nul").is_none());
2264 }
2265
2266 #[test]
2267 fn enumerate_hunks_identical_is_empty() {
2268 assert_eq!(enumerate_hunks(b"a\nb\n", b"a\nb\n"), Some(vec![]));
2269 }
2270
2271 const HUNK2_OLD: &[u8] = b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n";
2274 const HUNK2_NEW: &[u8] = b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n";
2275
2276 #[test]
2277 fn apply_all_hunks_reproduces_new_apply_none_reproduces_base() {
2278 let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
2279 assert_eq!(hunks.len(), 2, "expected two distinct hunks");
2280 let all: Vec<usize> = (0..hunks.len()).collect();
2281 assert_eq!(
2282 apply_hunks_subset(HUNK2_OLD, &hunks, &all),
2283 HUNK2_NEW,
2284 "apply all == new"
2285 );
2286 assert_eq!(
2287 apply_hunks_subset(HUNK2_OLD, &hunks, &[]),
2288 HUNK2_OLD,
2289 "apply none == old"
2290 );
2291 }
2292
2293 #[test]
2294 fn apply_single_hunk_picks_only_that_region() {
2295 let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
2296 let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[0]);
2298 assert_eq!(
2299 staged, b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n",
2300 "only the first hunk applied"
2301 );
2302 let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[1]);
2304 assert_eq!(
2305 staged, b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n",
2306 "only the second hunk applied"
2307 );
2308 }
2309
2310 #[test]
2311 fn apply_hunks_into_empty_base_adds_lines() {
2312 let old = b"";
2313 let new = b"alpha\nbeta\n";
2314 let hunks = enumerate_hunks(old, new).unwrap();
2315 assert_eq!(apply_hunks_subset(old, &hunks, &[0]), new);
2316 assert_eq!(apply_hunks_subset(old, &hunks, &[]), old);
2317 }
2318
2319 #[test]
2320 fn apply_hunks_preserves_missing_eof_newline() {
2321 let old = b"a\nb\nc";
2322 let new = b"a\nB\nc"; let hunks = enumerate_hunks(old, new).unwrap();
2324 let staged = apply_hunks_subset(old, &hunks, &[0]);
2325 assert_eq!(staged, new, "no trailing newline must be preserved");
2326 }
2327
2328 proptest::proptest! {
2329 #[test]
2339 fn proptest_myers_changed_matches_unelided_core(
2340 old in proptest::collection::vec(0u8..3, 0..10),
2341 new in proptest::collection::vec(0u8..3, 0..10),
2342 ) {
2343 fn to_lines(v: &[u8]) -> Vec<DiffLine<'_>> {
2344 const SYMBOLS: [&[u8]; 3] = [b"0", b"1", b"2"];
2345 v.iter().map(|&n| DiffLine { text: SYMBOLS[n as usize], has_newline: true }).collect()
2346 }
2347 let old_lines = to_lines(&old);
2348 let new_lines = to_lines(&new);
2349 let elided = myers_changed(&old_lines, &new_lines, WhitespaceMode::Exact);
2350 let ground_truth = myers_changed_core(&old_lines, &new_lines, WhitespaceMode::Exact);
2351 proptest::prop_assert_eq!(elided, ground_truth);
2352 }
2353
2354 #[test]
2365 fn proptest_hunks_roundtrip_with_shared_affix(
2366 prefix in proptest::collection::vec(0u8..6, 0..8),
2367 suffix in proptest::collection::vec(0u8..6, 0..8),
2368 old_mid in proptest::collection::vec(0u8..6, 0..8),
2369 new_mid in proptest::collection::vec(0u8..6, 0..8),
2370 ) {
2371 fn build(prefix: &[u8], mid: &[u8], suffix: &[u8]) -> Vec<u8> {
2372 let mut out = Vec::new();
2373 for &n in prefix.iter().chain(mid).chain(suffix) {
2374 out.extend_from_slice(format!("l{n}\n").as_bytes());
2375 }
2376 out
2377 }
2378 let old = build(&prefix, &old_mid, &suffix);
2379 let new = build(&prefix, &new_mid, &suffix);
2380 let hunks = enumerate_hunks(&old, &new).unwrap();
2381 let all: Vec<usize> = (0..hunks.len()).collect();
2382 proptest::prop_assert_eq!(apply_hunks_subset(&old, &hunks, &all), new);
2383 proptest::prop_assert_eq!(apply_hunks_subset(&old, &hunks, &[]), old);
2384 }
2385 }
2386
2387 #[test]
2390 fn merge3_identical_sides_merge_trivially() {
2391 let x = b"a\nb\n";
2392 assert_eq!(merge_blob_3way(b"base\n", x, x), Some(x.to_vec()));
2393 }
2394
2395 #[test]
2396 fn merge3_disjoint_line_edits_auto_merge() {
2397 let base = b"a\nb\nc\nd\ne\n";
2398 let ours = b"A\nb\nc\nd\ne\n"; let theirs = b"a\nb\nc\nd\nE\n"; assert_eq!(
2401 merge_blob_3way(base, ours, theirs),
2402 Some(b"A\nb\nc\nd\nE\n".to_vec())
2403 );
2404 }
2405
2406 #[test]
2407 fn merge3_adjacent_line_edits_auto_merge() {
2408 let base = b"x\ny\n";
2411 let ours = b"X\ny\n"; let theirs = b"x\nY\n"; assert_eq!(
2414 merge_blob_3way(base, ours, theirs),
2415 Some(b"X\nY\n".to_vec())
2416 );
2417 }
2418
2419 #[test]
2420 fn merge3_overlapping_line_edits_conflict() {
2421 let base = b"a\nb\nc\n";
2422 let ours = b"A\nb\nc\n"; let theirs = b"Z\nb\nc\n"; assert_eq!(merge_blob_3way(base, ours, theirs), None);
2425 }
2426
2427 #[test]
2428 fn merge3_one_side_only_takes_that_side() {
2429 let base = b"a\nb\nc\n";
2431 let ours = b"a\nB\nc\n";
2432 assert_eq!(merge_blob_3way(base, ours, base), Some(ours.to_vec()));
2433 }
2434
2435 #[test]
2436 fn merge3_disjoint_insertions_auto_merge() {
2437 let base = b"a\nb\n";
2438 let ours = b"a\nX\nb\n"; let theirs = b"a\nb\nY\n"; assert_eq!(
2441 merge_blob_3way(base, ours, theirs),
2442 Some(b"a\nX\nb\nY\n".to_vec())
2443 );
2444 }
2445
2446 #[test]
2447 fn merge3_coincident_insertions_conflict() {
2448 let base = b"a\nb\n";
2449 let ours = b"a\nX\nb\n"; let theirs = b"a\nY\nb\n";
2451 assert_eq!(merge_blob_3way(base, ours, theirs), None);
2452 }
2453
2454 #[test]
2455 fn merge3_binary_side_conflicts() {
2456 assert_eq!(merge_blob_3way(b"a\nb\n", b"a\0\nb\n", b"a\nb\nc\n"), None);
2457 }
2458
2459 #[test]
2464 fn ignore_all_space_treats_whitespace_only_change_as_unchanged() {
2465 let old = b"head\nfoo(a, b)\ntail\n";
2466 let new = b"head\nfoo(a,b)\ntail\n";
2467 let exact = unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact).unwrap();
2468 assert!(!exact.is_empty(), "exact mode should see the change");
2469 let ws =
2470 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
2471 assert!(
2472 ws.is_empty(),
2473 "-w should ignore a line that only gained/lost whitespace: {ws:?}"
2474 );
2475 }
2476
2477 #[test]
2478 fn ignore_space_change_does_not_ignore_whitespace_appearing_from_nothing() {
2479 let old = b"foo(a, b)\n";
2482 let new = b"foo(a,b)\n";
2483 let hunks =
2484 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2485 assert!(
2486 !hunks.is_empty(),
2487 "-b should still flag whitespace appearing where there was none"
2488 );
2489 }
2490
2491 #[test]
2492 fn ignore_space_change_ignores_differing_amounts() {
2493 let old = b"foo(a, b)\n";
2496 let new = b"foo(a, b)\n";
2497 let hunks =
2498 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2499 assert!(
2500 hunks.is_empty(),
2501 "-b should ignore a pure whitespace-amount change: {hunks:?}"
2502 );
2503 let ws_hunks =
2505 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
2506 assert!(ws_hunks.is_empty());
2507 }
2508
2509 #[test]
2510 fn whitespace_mode_never_changes_the_rendered_line_bytes() {
2511 let old = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-OLD\npad6\n";
2517 let new = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-NEW\npad6\n";
2518 let hunks =
2519 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2520 let text = String::from_utf8(hunks).unwrap();
2521 assert!(text.contains("-CHANGED-OLD\n"), "{text}");
2522 assert!(text.contains("+CHANGED-NEW\n"), "{text}");
2523 assert!(!text.contains("foo(x"), "{text}");
2526 }
2527
2528 #[test]
2529 fn unified_hunks_opts_default_matches_unified_hunks() {
2530 let old = b"a\nb\nc\n";
2531 let new = b"a\nB\nc\n";
2532 assert_eq!(
2533 unified_hunks(old, new),
2534 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact)
2535 );
2536 }
2537
2538 fn ten_lines_changing_the_fifth() -> (Vec<u8>, Vec<u8>) {
2543 let lines: Vec<String> = (1..=10).map(|n| format!("l{n}")).collect();
2544 let mut old = lines.join("\n");
2545 old.push('\n');
2546 let mut changed = lines;
2547 changed[4] = "l5-changed".to_string();
2548 let mut new = changed.join("\n");
2549 new.push('\n');
2550 (old.into_bytes(), new.into_bytes())
2551 }
2552
2553 fn context_line_count(hunks: &[u8]) -> usize {
2554 String::from_utf8_lossy(hunks)
2555 .lines()
2556 .skip_while(|l| !l.starts_with("@@"))
2557 .skip(1)
2558 .filter(|l| l.starts_with(' '))
2559 .count()
2560 }
2561
2562 #[test]
2563 fn context_zero_shows_no_surrounding_lines() {
2564 let (old, new) = ten_lines_changing_the_fifth();
2565 let hunks = unified_hunks_opts(&old, &new, 0, WhitespaceMode::Exact).unwrap();
2566 assert_eq!(context_line_count(&hunks), 0);
2567 let text = String::from_utf8(hunks).unwrap();
2568 assert!(text.contains("-l5\n"), "{text}");
2569 assert!(text.contains("+l5-changed\n"), "{text}");
2570 }
2571
2572 #[test]
2573 fn context_one_shows_one_line_each_side() {
2574 let (old, new) = ten_lines_changing_the_fifth();
2575 let hunks = unified_hunks_opts(&old, &new, 1, WhitespaceMode::Exact).unwrap();
2576 assert_eq!(context_line_count(&hunks), 2);
2577 }
2578
2579 #[test]
2580 fn context_default_matches_three() {
2581 let (old, new) = ten_lines_changing_the_fifth();
2582 let hunks =
2583 unified_hunks_opts(&old, &new, DEFAULT_CONTEXT_LINES, WhitespaceMode::Exact).unwrap();
2584 assert_eq!(DEFAULT_CONTEXT_LINES, 3);
2585 assert_eq!(context_line_count(&hunks), 6);
2586 }
2587}