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,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DiffEntry {
45 pub path: String,
46 pub kind: DiffKind,
47 pub old_hash: Option<Hash>,
48 pub new_hash: Option<Hash>,
49 pub old_mode: Option<EntryMode>,
52 pub new_mode: Option<EntryMode>,
53 pub old_path: Option<String>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct DiffResult {
61 pub entries: Vec<DiffEntry>,
62}
63
64impl DiffResult {
65 #[must_use]
66 pub fn is_empty(&self) -> bool {
67 self.entries.is_empty()
68 }
69
70 #[must_use]
71 pub fn len(&self) -> usize {
72 self.entries.len()
73 }
74}
75
76pub fn detect_exact_renames(entries: &mut Vec<DiffEntry>) {
93 use std::collections::HashMap;
94
95 let mut removed: HashMap<Hash, Vec<usize>> = HashMap::new();
96 let mut added: HashMap<Hash, Vec<usize>> = HashMap::new();
97 for (i, e) in entries.iter().enumerate() {
98 match e.kind {
99 DiffKind::Removed => {
100 if let Some(h) = e.old_hash {
101 removed.entry(h).or_default().push(i);
102 }
103 }
104 DiffKind::Added => {
105 if let Some(h) = e.new_hash {
106 added.entry(h).or_default().push(i);
107 }
108 }
109 _ => {}
110 }
111 }
112
113 let mut consumed = vec![false; entries.len()];
114 let mut renames: Vec<DiffEntry> = Vec::new();
115 for (h, rem_idx) in &removed {
116 let Some(add_idx) = added.get(h) else {
117 continue;
118 };
119 let mut r = rem_idx.clone();
120 let mut a = add_idx.clone();
121 r.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
122 a.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
123 for (&ri, &ai) in r.iter().zip(a.iter()) {
124 renames.push(DiffEntry {
125 path: entries[ai].path.clone(),
126 kind: DiffKind::Renamed,
127 old_hash: Some(*h),
128 new_hash: Some(*h),
129 old_mode: entries[ri].old_mode,
130 new_mode: entries[ai].new_mode,
131 old_path: Some(entries[ri].path.clone()),
132 });
133 consumed[ri] = true;
134 consumed[ai] = true;
135 }
136 }
137
138 if renames.is_empty() {
139 return;
140 }
141 let mut out: Vec<DiffEntry> = Vec::with_capacity(entries.len());
142 for (i, e) in entries.drain(..).enumerate() {
143 if !consumed[i] {
144 out.push(e);
145 }
146 }
147 out.extend(renames);
148 out.sort_by(|a, b| a.path.cmp(&b.path));
149 *entries = out;
150}
151
152pub fn diff_trees<S: ObjectSource + ?Sized>(
161 store: &S,
162 old_hash: Option<Hash>,
163 new_hash: Option<Hash>,
164) -> Result<DiffResult, StoreError> {
165 diff_trees_inner(store, old_hash, new_hash, false)
166}
167
168fn diff_trees_inner<S: ObjectSource + ?Sized>(
169 store: &S,
170 old_hash: Option<Hash>,
171 new_hash: Option<Hash>,
172 ignore_regular_executable_mode: bool,
173) -> Result<DiffResult, StoreError> {
174 match (old_hash, new_hash) {
176 (None, None) => return Ok(DiffResult::default()),
177 (Some(a), Some(b)) if a == b => return Ok(DiffResult::default()),
178 _ => {}
179 }
180
181 let old_entries = load_entries(store, old_hash)?;
182 let new_entries = load_entries(store, new_hash)?;
183
184 let mut out: Vec<DiffEntry> = Vec::new();
185 diff_entries_recursive(
186 store,
187 &old_entries,
188 &new_entries,
189 "",
190 &mut out,
191 ignore_regular_executable_mode,
192 0,
193 )?;
194 out.sort_by(|a, b| a.path.cmp(&b.path));
199 Ok(DiffResult { entries: out })
200}
201
202fn diff_entries_recursive<S: ObjectSource + ?Sized>(
204 store: &S,
205 old_entries: &[TreeEntry],
206 new_entries: &[TreeEntry],
207 prefix: &str,
208 out: &mut Vec<DiffEntry>,
209 ignore_regular_executable_mode: bool,
210 depth: usize,
211) -> Result<(), StoreError> {
212 if depth > MAX_TREE_DEPTH {
213 return Err(StoreError::TreeTooDeep);
214 }
215 let mut i = 0usize;
216 let mut j = 0usize;
217
218 while i < old_entries.len() && j < new_entries.len() {
219 let o = &old_entries[i];
220 let n = &new_entries[j];
221 match o.name.as_slice().cmp(n.name.as_slice()) {
222 std::cmp::Ordering::Less => {
223 add_removed_entries(store, o, prefix, out, depth)?;
224 i += 1;
225 }
226 std::cmp::Ordering::Greater => {
227 add_added_entries(store, n, prefix, out, depth)?;
228 j += 1;
229 }
230 std::cmp::Ordering::Equal => {
231 if o.mode == EntryMode::Tree && n.mode == EntryMode::Tree {
232 if o.object_hash != n.object_hash {
233 let sub_prefix = join_path(prefix, &o.name);
234 let old_sub = load_tree(store, o.object_hash)?;
235 let new_sub = load_tree(store, n.object_hash)?;
236 diff_entries_recursive(
237 store,
238 &old_sub,
239 &new_sub,
240 &sub_prefix,
241 out,
242 ignore_regular_executable_mode,
243 depth + 1,
244 )?;
245 }
246 } else if o.mode == EntryMode::Tree || n.mode == EntryMode::Tree {
248 add_removed_entries(store, o, prefix, out, depth)?;
254 add_added_entries(store, n, prefix, out, depth)?;
255 } else if o.mode != n.mode && o.object_hash == n.object_hash {
256 if !ignore_regular_executable_mode || !regular_executable_pair(o.mode, n.mode) {
257 out.push(DiffEntry {
258 path: join_path(prefix, &o.name),
259 kind: DiffKind::ModeChanged,
260 old_hash: Some(o.object_hash),
261 new_hash: Some(n.object_hash),
262 old_mode: Some(o.mode),
263 new_mode: Some(n.mode),
264 old_path: None,
265 });
266 }
267 } else if o.object_hash != n.object_hash || o.mode != n.mode {
268 out.push(DiffEntry {
269 path: join_path(prefix, &o.name),
270 kind: DiffKind::Modified,
271 old_hash: Some(o.object_hash),
272 new_hash: Some(n.object_hash),
273 old_mode: Some(o.mode),
274 new_mode: Some(n.mode),
275 old_path: None,
276 });
277 }
278 i += 1;
279 j += 1;
280 }
281 }
282 }
283
284 while i < old_entries.len() {
285 add_removed_entries(store, &old_entries[i], prefix, out, depth)?;
286 i += 1;
287 }
288 while j < new_entries.len() {
289 add_added_entries(store, &new_entries[j], prefix, out, depth)?;
290 j += 1;
291 }
292 Ok(())
293}
294
295fn regular_executable_pair(a: EntryMode, b: EntryMode) -> bool {
296 matches!(
297 (a, b),
298 (EntryMode::Blob, EntryMode::Executable) | (EntryMode::Executable, EntryMode::Blob)
299 )
300}
301
302fn add_removed_entries<S: ObjectSource + ?Sized>(
303 store: &S,
304 entry: &TreeEntry,
305 prefix: &str,
306 out: &mut Vec<DiffEntry>,
307 depth: usize,
308) -> Result<(), StoreError> {
309 if depth > MAX_TREE_DEPTH {
310 return Err(StoreError::TreeTooDeep);
311 }
312 if entry.mode == EntryMode::Tree {
313 let sub_prefix = join_path(prefix, &entry.name);
314 let sub = load_tree(store, entry.object_hash)?;
315 for sub_entry in &sub {
316 add_removed_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
317 }
318 } else {
319 out.push(DiffEntry {
320 path: join_path(prefix, &entry.name),
321 kind: DiffKind::Removed,
322 old_hash: Some(entry.object_hash),
323 new_hash: None,
324 old_mode: Some(entry.mode),
325 new_mode: None,
326 old_path: None,
327 });
328 }
329 Ok(())
330}
331
332fn add_added_entries<S: ObjectSource + ?Sized>(
333 store: &S,
334 entry: &TreeEntry,
335 prefix: &str,
336 out: &mut Vec<DiffEntry>,
337 depth: usize,
338) -> Result<(), StoreError> {
339 if depth > MAX_TREE_DEPTH {
340 return Err(StoreError::TreeTooDeep);
341 }
342 if entry.mode == EntryMode::Tree {
343 let sub_prefix = join_path(prefix, &entry.name);
344 let sub = load_tree(store, entry.object_hash)?;
345 for sub_entry in &sub {
346 add_added_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
347 }
348 } else {
349 out.push(DiffEntry {
350 path: join_path(prefix, &entry.name),
351 kind: DiffKind::Added,
352 old_hash: None,
353 new_hash: Some(entry.object_hash),
354 old_mode: None,
355 new_mode: Some(entry.mode),
356 old_path: None,
357 });
358 }
359 Ok(())
360}
361
362fn load_entries<S: ObjectSource + ?Sized>(
363 store: &S,
364 hash: Option<Hash>,
365) -> Result<Vec<TreeEntry>, StoreError> {
366 match hash {
367 Some(h) => load_tree(store, h),
368 None => Ok(Vec::new()),
369 }
370}
371
372fn load_tree<S: ObjectSource + ?Sized>(store: &S, h: Hash) -> Result<Vec<TreeEntry>, StoreError> {
373 match store.read_object(&h)? {
374 Object::Tree(t) => Ok(t.entries),
375 other => Err(StoreError::Decode(
376 crate::object::MkitError::InvalidObjectType(other.object_type() as u8),
377 )),
378 }
379}
380
381fn join_path(prefix: &str, name: &[u8]) -> String {
389 let name_str = String::from_utf8_lossy(name);
390 if prefix.is_empty() {
391 name_str.into_owned()
392 } else {
393 let mut s = String::with_capacity(prefix.len() + 1 + name_str.len());
394 s.push_str(prefix);
395 s.push('/');
396 s.push_str(&name_str);
397 s
398 }
399}
400
401const PATCH_CONTEXT: usize = 3;
407
408pub const DEFAULT_CONTEXT_LINES: usize = PATCH_CONTEXT;
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
422pub enum WhitespaceMode {
423 #[default]
425 Exact,
426 IgnoreSpaceChange,
430 IgnoreAllSpace,
433}
434
435fn ws_key(text: &[u8], mode: WhitespaceMode) -> Cow<'_, [u8]> {
438 match mode {
439 WhitespaceMode::Exact => Cow::Borrowed(text),
440 WhitespaceMode::IgnoreAllSpace => Cow::Owned(strip_all_ws(text)),
441 WhitespaceMode::IgnoreSpaceChange => Cow::Owned(collapse_ws(text)),
442 }
443}
444
445fn is_ws_byte(b: u8) -> bool {
448 b.is_ascii_whitespace() || b == 0x0B
449}
450
451fn strip_all_ws(line: &[u8]) -> Vec<u8> {
453 line.iter().copied().filter(|&b| !is_ws_byte(b)).collect()
454}
455
456fn collapse_ws(line: &[u8]) -> Vec<u8> {
461 let mut end = line.len();
462 while end > 0 && is_ws_byte(line[end - 1]) {
463 end -= 1;
464 }
465 let line = &line[..end];
466 let mut out = Vec::with_capacity(line.len());
467 let mut i = 0;
468 while i < line.len() {
469 if is_ws_byte(line[i]) {
470 out.push(b' ');
471 while i < line.len() && is_ws_byte(line[i]) {
472 i += 1;
473 }
474 } else {
475 out.push(line[i]);
476 i += 1;
477 }
478 }
479 out
480}
481
482#[must_use]
509pub fn text_patch(old_bytes: &[u8], new_bytes: &[u8], old_path: &str, new_path: &str) -> String {
510 match unified_hunks(old_bytes, new_bytes) {
511 None => format!("Binary files a/{old_path} and b/{new_path} differ\n"),
512 Some(hunks) if hunks.is_empty() => String::new(),
513 Some(hunks) => {
514 format!(
515 "--- a/{old_path}\n+++ b/{new_path}\n{}",
516 String::from_utf8_lossy(&hunks)
517 )
518 }
519 }
520}
521
522#[must_use]
533pub fn unified_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<u8>> {
534 unified_hunks_opts(old_bytes, new_bytes, PATCH_CONTEXT, WhitespaceMode::Exact)
535}
536
537#[must_use]
545pub fn unified_hunks_opts(
546 old_bytes: &[u8],
547 new_bytes: &[u8],
548 context: usize,
549 mode: WhitespaceMode,
550) -> Option<Vec<u8>> {
551 if is_binary(old_bytes) || is_binary(new_bytes) {
552 return None;
553 }
554 let old_lines = split_lines(old_bytes);
555 let new_lines = split_lines(new_bytes);
556 let ops = edit_script(&old_lines, &new_lines, mode);
557 let hunks = group_hunks(&ops, context);
558 let mut out = Vec::new();
559 for hunk in &hunks {
560 render_hunk(&mut out, hunk, &old_lines, &new_lines);
561 }
562 Some(out)
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub enum HunkLineKind {
569 Context,
571 Added,
573 Removed,
575}
576
577#[derive(Debug, Clone, PartialEq, Eq)]
580pub struct HunkLine {
581 pub kind: HunkLineKind,
583 pub text: Vec<u8>,
585 pub has_newline: bool,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct PatchHunk {
594 pub old_start: usize,
596 pub old_len: usize,
598 pub new_start: usize,
600 pub new_len: usize,
602 pub lines: Vec<HunkLine>,
604}
605
606#[must_use]
614pub fn enumerate_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<PatchHunk>> {
615 if is_binary(old_bytes) || is_binary(new_bytes) {
616 return None;
617 }
618 let old_lines = split_lines(old_bytes);
619 let new_lines = split_lines(new_bytes);
620 let ops = edit_script(&old_lines, &new_lines, WhitespaceMode::Exact);
621 let hunks = group_hunks(&ops, PATCH_CONTEXT);
622 Some(
623 hunks
624 .iter()
625 .map(|h| to_patch_hunk(h, &old_lines, &new_lines))
626 .collect(),
627 )
628}
629
630fn to_patch_hunk(hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> PatchHunk {
631 let lines = hunk
632 .ops
633 .iter()
634 .map(|op| {
635 let (kind, dl) = match *op {
636 DiffOp::Equal(oi, _) => (HunkLineKind::Context, &old[oi]),
637 DiffOp::Delete(oi) => (HunkLineKind::Removed, &old[oi]),
638 DiffOp::Insert(ni) => (HunkLineKind::Added, &new[ni]),
639 };
640 HunkLine {
641 kind,
642 text: dl.text.to_vec(),
643 has_newline: dl.has_newline,
644 }
645 })
646 .collect();
647 PatchHunk {
648 old_start: hunk.old_start,
649 old_len: hunk.old_len,
650 new_start: hunk.new_start,
651 new_len: hunk.new_len,
652 lines,
653 }
654}
655
656#[must_use]
667pub fn apply_hunks_subset(base_bytes: &[u8], hunks: &[PatchHunk], selected: &[usize]) -> Vec<u8> {
668 let old_lines = split_lines(base_bytes);
669 let sel: std::collections::HashSet<usize> = selected.iter().copied().collect();
670 let mut out: Vec<u8> = Vec::with_capacity(base_bytes.len());
671 let mut cursor = 0usize; for (i, h) in hunks.iter().enumerate() {
673 let region_start = if h.old_len == 0 {
677 h.old_start
678 } else {
679 h.old_start - 1
680 }
681 .min(old_lines.len());
682 let region_end = (region_start + h.old_len).min(old_lines.len());
683 for dl in &old_lines[cursor..region_start] {
685 emit_raw_line(&mut out, dl.text, dl.has_newline);
686 }
687 if sel.contains(&i) {
688 for l in &h.lines {
690 if l.kind != HunkLineKind::Removed {
691 emit_raw_line(&mut out, &l.text, l.has_newline);
692 }
693 }
694 } else {
695 for dl in &old_lines[region_start..region_end] {
697 emit_raw_line(&mut out, dl.text, dl.has_newline);
698 }
699 }
700 cursor = region_end;
701 }
702 for dl in &old_lines[cursor..] {
703 emit_raw_line(&mut out, dl.text, dl.has_newline);
704 }
705 out
706}
707
708fn emit_raw_line(out: &mut Vec<u8>, text: &[u8], has_newline: bool) {
709 out.extend_from_slice(text);
710 if has_newline {
711 out.push(b'\n');
712 }
713}
714
715struct MergeRegion {
719 base_start: usize,
720 base_len: usize,
721 new: Vec<(Vec<u8>, bool)>,
722}
723
724#[must_use]
734pub fn merge_blob_3way(base: &[u8], ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
735 if ours == theirs {
736 return Some(ours.to_vec());
737 }
738 if is_binary(base) || is_binary(ours) || is_binary(theirs) {
739 return None;
740 }
741 let base_lines = split_lines(base);
742 let ours_regions = changed_regions(&base_lines, &split_lines(ours));
743 let theirs_regions = changed_regions(&base_lines, &split_lines(theirs));
744 if regions_overlap(&ours_regions, &theirs_regions) {
745 return None;
746 }
747 Some(apply_merge_regions(
748 &base_lines,
749 &ours_regions,
750 &theirs_regions,
751 ))
752}
753
754fn changed_regions(base: &[DiffLine<'_>], side: &[DiffLine<'_>]) -> Vec<MergeRegion> {
757 let mut regions: Vec<MergeRegion> = Vec::new();
758 let mut cur: Option<MergeRegion> = None;
759 let mut base_idx = 0usize; for op in edit_script(base, side, WhitespaceMode::Exact) {
761 match op {
762 DiffOp::Equal(bi, _) => {
763 if let Some(r) = cur.take() {
764 regions.push(r);
765 }
766 base_idx = bi + 1;
767 }
768 DiffOp::Delete(bi) => {
769 cur.get_or_insert(MergeRegion {
770 base_start: base_idx,
771 base_len: 0,
772 new: Vec::new(),
773 })
774 .base_len += 1;
775 base_idx = bi + 1;
776 }
777 DiffOp::Insert(si) => {
778 let line = &side[si];
779 cur.get_or_insert(MergeRegion {
780 base_start: base_idx,
781 base_len: 0,
782 new: Vec::new(),
783 })
784 .new
785 .push((line.text.to_vec(), line.has_newline));
786 }
787 }
788 }
789 if let Some(r) = cur.take() {
790 regions.push(r);
791 }
792 regions
793}
794
795fn regions_overlap(ours: &[MergeRegion], theirs: &[MergeRegion]) -> bool {
801 ours.iter().any(|a| {
802 theirs.iter().any(|b| {
803 let (a_s, a_e) = (a.base_start, a.base_start + a.base_len);
804 let (b_s, b_e) = (b.base_start, b.base_start + b.base_len);
805 match (a.base_len == 0, b.base_len == 0) {
806 (true, true) => a_s == b_s,
807 (true, false) => b_s < a_s && a_s < b_e,
808 (false, true) => a_s < b_s && b_s < a_e,
809 (false, false) => a_s < b_e && b_s < a_e,
810 }
811 })
812 })
813}
814
815fn apply_merge_regions(
819 base: &[DiffLine<'_>],
820 ours: &[MergeRegion],
821 theirs: &[MergeRegion],
822) -> Vec<u8> {
823 let mut all: Vec<&MergeRegion> = ours.iter().chain(theirs.iter()).collect();
824 all.sort_by_key(|r| (r.base_start, r.base_len));
825 let mut out: Vec<u8> = Vec::new();
826 let mut cursor = 0usize;
827 for r in all {
828 for line in &base[cursor..r.base_start] {
829 emit_raw_line(&mut out, line.text, line.has_newline);
830 }
831 for (text, nl) in &r.new {
832 emit_raw_line(&mut out, text, *nl);
833 }
834 cursor = r.base_start + r.base_len;
835 }
836 for line in &base[cursor..] {
837 emit_raw_line(&mut out, line.text, line.has_newline);
838 }
839 out
840}
841
842#[must_use]
853pub fn diff_line_counts(old_bytes: &[u8], new_bytes: &[u8]) -> Option<(usize, usize)> {
854 if is_binary(old_bytes) || is_binary(new_bytes) {
855 return None;
856 }
857 let old_lines = split_lines(old_bytes);
858 let new_lines = split_lines(new_bytes);
859 let mut added = 0;
860 let mut deleted = 0;
861 for op in edit_script(&old_lines, &new_lines, WhitespaceMode::Exact) {
862 match op {
863 DiffOp::Insert(_) => added += 1,
864 DiffOp::Delete(_) => deleted += 1,
865 DiffOp::Equal(_, _) => {}
866 }
867 }
868 Some((added, deleted))
869}
870
871pub const BINARY_SNIFF_LEN: usize = 8000;
877
878#[must_use]
884pub fn is_binary(bytes: &[u8]) -> bool {
885 bytes.iter().take(BINARY_SNIFF_LEN).any(|&b| b == 0)
886}
887
888struct DiffLine<'a> {
891 text: &'a [u8],
892 has_newline: bool,
894}
895
896fn split_lines(text: &[u8]) -> Vec<DiffLine<'_>> {
899 let mut lines = Vec::new();
900 let mut rest = text;
901 while !rest.is_empty() {
902 if let Some(idx) = rest.iter().position(|&b| b == b'\n') {
903 lines.push(DiffLine {
904 text: &rest[..idx],
905 has_newline: true,
906 });
907 rest = &rest[idx + 1..];
908 } else {
909 lines.push(DiffLine {
910 text: rest,
911 has_newline: false,
912 });
913 rest = b"";
914 }
915 }
916 lines
917}
918
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
921enum DiffOp {
922 Equal(usize, usize),
924 Delete(usize),
926 Insert(usize),
928}
929
930fn edit_script(old: &[DiffLine<'_>], new: &[DiffLine<'_>], mode: WhitespaceMode) -> Vec<DiffOp> {
936 let (mut old_changed, mut new_changed) = myers_changed(old, new, mode);
937 compact_changes(old, &mut old_changed, mode);
938 compact_changes(new, &mut new_changed, mode);
939 script_from_flags(old, new, &old_changed, &new_changed)
940}
941
942#[allow(
952 clippy::cast_sign_loss,
953 clippy::cast_possible_wrap,
954 clippy::many_single_char_names
955)]
956fn myers_changed(
957 old: &[DiffLine<'_>],
958 new: &[DiffLine<'_>],
959 mode: WhitespaceMode,
960) -> (Vec<bool>, Vec<bool>) {
961 let n = old.len();
962 let m = new.len();
963 let mut old_changed = vec![false; n];
964 let mut new_changed = vec![false; m];
965 if n == 0 {
966 new_changed.fill(true);
967 return (old_changed, new_changed);
968 }
969 if m == 0 {
970 old_changed.fill(true);
971 return (old_changed, new_changed);
972 }
973
974 let max = n + m;
975 let offset = max as isize; let mut v = vec![0isize; 2 * max + 1];
977 let mut trace: Vec<Vec<isize>> = Vec::new();
978
979 let idx = |k: isize| (k + offset) as usize;
980 let mut found = max as isize;
981 'outer: for d in 0..=max as isize {
982 trace.push(v.clone());
983 let mut k = -d;
984 while k <= d {
985 let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) {
987 v[idx(k + 1)] } else {
989 v[idx(k - 1)] + 1 };
991 let mut y = x - k;
992 while (x as usize) < n
993 && (y as usize) < m
994 && lines_equal(&old[x as usize], &new[y as usize], mode)
995 {
996 x += 1;
997 y += 1;
998 }
999 v[idx(k)] = x;
1000 if x as usize >= n && y as usize >= m {
1001 found = d;
1002 break 'outer;
1003 }
1004 k += 2;
1005 }
1006 }
1007
1008 let mut x = n as isize;
1010 let mut y = m as isize;
1011 for d in (0..=found).rev() {
1012 let vd = &trace[d as usize];
1013 let k = x - y;
1014 let down = k == -d || (k != d && vd[idx(k - 1)] < vd[idx(k + 1)]);
1015 let prev_k = if down { k + 1 } else { k - 1 };
1016 let prev_x = vd[idx(prev_k)];
1017 let prev_y = prev_x - prev_k;
1018 while x > prev_x && y > prev_y {
1020 x -= 1;
1021 y -= 1;
1022 }
1023 if d > 0 {
1024 if down {
1025 new_changed[(y - 1) as usize] = true; y -= 1;
1027 } else {
1028 old_changed[(x - 1) as usize] = true; x -= 1;
1030 }
1031 }
1032 }
1033 (old_changed, new_changed)
1034}
1035
1036fn compact_changes(lines: &[DiffLine<'_>], changed: &mut [bool], mode: WhitespaceMode) {
1041 let n = lines.len();
1042 let mut i = 0;
1043 while i < n {
1044 if !changed[i] {
1045 i += 1;
1046 continue;
1047 }
1048 let start = i;
1050 let mut end = i;
1051 while end < n && changed[end] {
1052 end += 1;
1053 }
1054 let (mut s, mut e) = (start, end);
1057 while e < n && lines_equal(&lines[s], &lines[e], mode) {
1058 changed[s] = false;
1059 changed[e] = true;
1060 s += 1;
1061 e += 1;
1062 }
1063 i = e;
1064 }
1065}
1066
1067fn script_from_flags(
1071 old: &[DiffLine<'_>],
1072 new: &[DiffLine<'_>],
1073 old_changed: &[bool],
1074 new_changed: &[bool],
1075) -> Vec<DiffOp> {
1076 let (n, m) = (old.len(), new.len());
1077 let mut ops = Vec::new();
1078 let (mut i, mut j) = (0usize, 0usize);
1079 while i < n || j < m {
1080 if i < n && j < m && !old_changed[i] && !new_changed[j] {
1081 ops.push(DiffOp::Equal(i, j));
1082 i += 1;
1083 j += 1;
1084 } else {
1085 while i < n && old_changed[i] {
1086 ops.push(DiffOp::Delete(i));
1087 i += 1;
1088 }
1089 while j < m && new_changed[j] {
1090 ops.push(DiffOp::Insert(j));
1091 j += 1;
1092 }
1093 }
1094 }
1095 ops
1096}
1097
1098fn lines_equal(a: &DiffLine<'_>, b: &DiffLine<'_>, mode: WhitespaceMode) -> bool {
1103 a.has_newline == b.has_newline && ws_key(a.text, mode) == ws_key(b.text, mode)
1104}
1105
1106struct Hunk {
1109 old_start: usize,
1110 old_len: usize,
1111 new_start: usize,
1112 new_len: usize,
1113 ops: Vec<DiffOp>,
1114}
1115
1116fn group_hunks(ops: &[DiffOp], context: usize) -> Vec<Hunk> {
1119 let change_positions: Vec<usize> = ops
1121 .iter()
1122 .enumerate()
1123 .filter(|(_, op)| !matches!(op, DiffOp::Equal(_, _)))
1124 .map(|(idx, _)| idx)
1125 .collect();
1126 if change_positions.is_empty() {
1127 return Vec::new();
1128 }
1129
1130 let mut ranges: Vec<(usize, usize)> = Vec::new();
1133 for &pos in &change_positions {
1134 let start = pos.saturating_sub(context);
1135 let end = (pos + context + 1).min(ops.len());
1136 match ranges.last_mut() {
1137 Some(last) if start <= last.1 => last.1 = last.1.max(end),
1138 _ => ranges.push((start, end)),
1139 }
1140 }
1141
1142 ranges
1143 .into_iter()
1144 .map(|(start, end)| build_hunk(&ops[start..end]))
1145 .collect()
1146}
1147
1148fn build_hunk(slice: &[DiffOp]) -> Hunk {
1149 let mut old_start = None;
1150 let mut new_start = None;
1151 let mut old_len = 0usize;
1152 let mut new_len = 0usize;
1153 for op in slice {
1154 match *op {
1155 DiffOp::Equal(oi, ni) => {
1156 old_start.get_or_insert(oi);
1157 new_start.get_or_insert(ni);
1158 old_len += 1;
1159 new_len += 1;
1160 }
1161 DiffOp::Delete(oi) => {
1162 old_start.get_or_insert(oi);
1163 old_len += 1;
1164 }
1165 DiffOp::Insert(ni) => {
1166 new_start.get_or_insert(ni);
1167 new_len += 1;
1168 }
1169 }
1170 }
1171 Hunk {
1172 old_start: old_start.map_or(0, |s| s + 1),
1174 old_len,
1175 new_start: new_start.map_or(0, |s| s + 1),
1176 new_len,
1177 ops: slice.to_vec(),
1178 }
1179}
1180
1181fn hunk_range(start: usize, len: usize) -> String {
1184 if len == 1 {
1185 start.to_string()
1186 } else {
1187 format!("{start},{len}")
1188 }
1189}
1190
1191fn render_hunk(out: &mut Vec<u8>, hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) {
1192 let header = format!(
1193 "@@ -{} +{} @@\n",
1194 hunk_range(hunk.old_start, hunk.old_len),
1195 hunk_range(hunk.new_start, hunk.new_len)
1196 );
1197 out.extend_from_slice(header.as_bytes());
1198 for op in &hunk.ops {
1199 match *op {
1200 DiffOp::Equal(oi, _) => emit_line(out, b' ', &old[oi]),
1201 DiffOp::Delete(oi) => emit_line(out, b'-', &old[oi]),
1202 DiffOp::Insert(ni) => emit_line(out, b'+', &new[ni]),
1203 }
1204 }
1205}
1206
1207fn emit_line(out: &mut Vec<u8>, prefix: u8, line: &DiffLine<'_>) {
1208 out.push(prefix);
1209 out.extend_from_slice(line.text);
1210 out.push(b'\n');
1211 if !line.has_newline {
1212 out.extend_from_slice(b"\\ No newline at end of file\n");
1213 }
1214}
1215
1216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1225pub enum StatusStaging {
1226 Unstaged,
1228 Staged,
1230 PartiallyStaged,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Eq)]
1238pub struct StatusEntry {
1239 pub diff: DiffEntry,
1241 pub staging: StatusStaging,
1243}
1244
1245#[derive(Debug, thiserror::Error)]
1247pub enum DiffError {
1248 #[error(transparent)]
1250 Store(#[from] StoreError),
1251 #[error(transparent)]
1253 Worktree(#[from] WorktreeError),
1254 #[error(transparent)]
1256 Index(#[from] IndexError),
1257}
1258
1259#[allow(clippy::too_many_lines)]
1290pub fn status_diff(
1291 store: &ObjectStore,
1292 head_tree: Option<&Hash>,
1293 worktree_root: &Path,
1294 index: Option<&Index>,
1295) -> Result<Vec<StatusEntry>, DiffError> {
1296 status_diff_observed(store, head_tree, worktree_root, index).map(|(entries, _)| entries)
1297}
1298
1299#[allow(clippy::type_complexity)]
1308pub fn status_diff_observed(
1309 store: &ObjectStore,
1310 head_tree: Option<&Hash>,
1311 worktree_root: &Path,
1312 index: Option<&Index>,
1313) -> Result<(Vec<StatusEntry>, Vec<worktree::StatObservation>), DiffError> {
1314 let head_seed;
1319 let tracked = if let Some(i) = index {
1320 Some(i)
1321 } else if let Some(ht) = head_tree {
1322 head_seed = crate::index::from_tree(store, *ht)?;
1323 Some(&head_seed)
1324 } else {
1325 None
1326 };
1327 let snapshot = crate::store::EphemeralSink::new(store);
1333 let mut observations = Vec::new();
1334 let work_tree_hash = worktree::build_tree_filtered_observed(
1335 &snapshot,
1336 worktree_root,
1337 tracked,
1338 &mut observations,
1339 )?;
1340
1341 let Some(idx) = index else {
1342 let diff = diff_worktree_trees(&snapshot, head_tree.copied(), Some(work_tree_hash))?;
1344 return Ok((
1345 diff.entries
1346 .into_iter()
1347 .map(|d| StatusEntry {
1348 diff: d,
1349 staging: StatusStaging::Unstaged,
1350 })
1351 .collect(),
1352 observations,
1353 ));
1354 };
1355
1356 let index_tree = worktree::build_tree_from_index_with(store, &snapshot, idx, false)?;
1360
1361 let staged = diff_trees(&snapshot, head_tree.copied(), Some(index_tree))?;
1362 let unstaged = diff_worktree_trees(&snapshot, Some(index_tree), Some(work_tree_hash))?;
1363
1364 let mut out: Vec<StatusEntry> =
1371 Vec::with_capacity(staged.entries.len() + unstaged.entries.len());
1372 for d in staged.entries {
1373 out.push(StatusEntry {
1374 diff: d,
1375 staging: StatusStaging::Staged,
1376 });
1377 }
1378 for d in unstaged.entries {
1379 out.push(StatusEntry {
1380 diff: d,
1381 staging: StatusStaging::Unstaged,
1382 });
1383 }
1384 out.sort_by(|a, b| {
1385 a.diff.path.cmp(&b.diff.path).then_with(|| {
1387 #[allow(clippy::match_same_arms)]
1388 match (a.staging, b.staging) {
1389 (StatusStaging::Staged, StatusStaging::Staged) => std::cmp::Ordering::Equal,
1390 (StatusStaging::Staged, _) => std::cmp::Ordering::Less,
1391 (_, StatusStaging::Staged) => std::cmp::Ordering::Greater,
1392 _ => std::cmp::Ordering::Equal,
1393 }
1394 })
1395 });
1396 Ok((out, observations))
1397}
1398
1399#[cfg(unix)]
1400fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1401 store: &S,
1402 old_hash: Option<Hash>,
1403 new_hash: Option<Hash>,
1404) -> Result<DiffResult, StoreError> {
1405 diff_trees(store, old_hash, new_hash)
1406}
1407
1408#[cfg(not(unix))]
1409fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1410 store: &S,
1411 old_hash: Option<Hash>,
1412 new_hash: Option<Hash>,
1413) -> Result<DiffResult, StoreError> {
1414 diff_trees_inner(store, old_hash, new_hash, true)
1415}
1416
1417#[cfg(test)]
1422#[allow(clippy::many_single_char_names)] mod tests {
1424 use super::*;
1425 use crate::object::{Blob, Tree};
1426 use crate::serialize;
1427 use tempfile::TempDir;
1428
1429 #[test]
1430 fn diff_line_counts_counts_text_and_flags_binary() {
1431 assert_eq!(
1433 diff_line_counts(b"a\nb\nc\n", b"a\nB\nc\nd\ne\n"),
1434 Some((3, 1))
1435 );
1436 assert_eq!(diff_line_counts(b"a\nb\n", b"a\x00b\n"), None);
1438 assert_eq!(diff_line_counts(b"x\x00y", b"z"), None);
1439 assert!(diff_line_counts(b"\xff\xfe\n", b"\xff\xfe\nmore\n").is_some());
1441 }
1442
1443 fn h(b: &[u8]) -> Hash {
1446 crate::hash::hash(b)
1447 }
1448 fn removed(path: &str, content: &[u8]) -> DiffEntry {
1449 DiffEntry {
1450 path: path.into(),
1451 kind: DiffKind::Removed,
1452 old_hash: Some(h(content)),
1453 new_hash: None,
1454 old_mode: Some(EntryMode::Blob),
1455 new_mode: None,
1456 old_path: None,
1457 }
1458 }
1459 fn added(path: &str, content: &[u8]) -> DiffEntry {
1460 DiffEntry {
1461 path: path.into(),
1462 kind: DiffKind::Added,
1463 old_hash: None,
1464 new_hash: Some(h(content)),
1465 old_mode: None,
1466 new_mode: Some(EntryMode::Blob),
1467 old_path: None,
1468 }
1469 }
1470
1471 #[test]
1472 fn detect_pairs_identical_content_into_one_rename() {
1473 let mut es = vec![removed("old.txt", b"hello"), added("new.txt", b"hello")];
1474 detect_exact_renames(&mut es);
1475 assert_eq!(es.len(), 1);
1476 assert_eq!(es[0].kind, DiffKind::Renamed);
1477 assert_eq!(es[0].path, "new.txt");
1478 assert_eq!(es[0].old_path.as_deref(), Some("old.txt"));
1479 assert_eq!(es[0].old_hash, Some(h(b"hello")));
1480 assert_eq!(es[0].new_hash, Some(h(b"hello")));
1481 }
1482
1483 #[test]
1484 fn detect_leaves_unrelated_delete_add_alone() {
1485 let mut es = vec![removed("a", b"one"), added("b", b"two")];
1486 let before = es.clone();
1487 detect_exact_renames(&mut es);
1488 assert_eq!(es, before);
1489 }
1490
1491 #[test]
1492 fn detect_ignores_modified_in_place() {
1493 let mut es = vec![DiffEntry {
1494 path: "f".into(),
1495 kind: DiffKind::Modified,
1496 old_hash: Some(h(b"x")),
1497 new_hash: Some(h(b"y")),
1498 old_mode: Some(EntryMode::Blob),
1499 new_mode: Some(EntryMode::Blob),
1500 old_path: None,
1501 }];
1502 let before = es.clone();
1503 detect_exact_renames(&mut es);
1504 assert_eq!(es, before);
1505 }
1506
1507 #[test]
1508 fn detect_pairs_duplicates_deterministically_by_path() {
1509 let mut es = vec![
1513 added("new_b", b"dup"),
1514 removed("old_b", b"dup"),
1515 added("new_a", b"dup"),
1516 removed("old_a", b"dup"),
1517 ];
1518 detect_exact_renames(&mut es);
1519 assert_eq!(es.len(), 2);
1520 assert_eq!(
1521 (es[0].old_path.as_deref(), es[0].path.as_str()),
1522 (Some("old_a"), "new_a")
1523 );
1524 assert_eq!(
1525 (es[1].old_path.as_deref(), es[1].path.as_str()),
1526 (Some("old_b"), "new_b")
1527 );
1528 }
1529
1530 #[test]
1531 fn detect_leaves_unpaired_remainder() {
1532 let mut es = vec![
1535 removed("old_a", b"c"),
1536 removed("old_b", b"c"),
1537 added("new", b"c"),
1538 ];
1539 detect_exact_renames(&mut es);
1540 assert_eq!(es.len(), 2);
1541 let r = es.iter().find(|e| e.kind == DiffKind::Renamed).unwrap();
1542 assert_eq!(r.old_path.as_deref(), Some("old_a"));
1543 let d = es.iter().find(|e| e.kind == DiffKind::Removed).unwrap();
1544 assert_eq!(d.path, "old_b");
1545 }
1546
1547 #[test]
1548 fn detect_preserves_modes_on_rename_with_mode_change() {
1549 let r = removed("old", b"same");
1552 let mut a = added("new", b"same");
1553 a.new_mode = Some(EntryMode::Executable);
1554 let mut es = vec![r, a];
1555 detect_exact_renames(&mut es);
1556 assert_eq!(es.len(), 1);
1557 assert_eq!(es[0].kind, DiffKind::Renamed);
1558 assert_eq!(es[0].old_mode, Some(EntryMode::Blob));
1559 assert_eq!(es[0].new_mode, Some(EntryMode::Executable));
1560 }
1561
1562 fn fresh_store() -> (TempDir, ObjectStore) {
1563 let dir = TempDir::new().unwrap();
1564 let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
1565 (dir, store)
1566 }
1567
1568 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
1569 let obj = Object::Blob(Blob {
1570 data: data.to_vec(),
1571 });
1572 let bytes = serialize::serialize(&obj).unwrap();
1573 store.write(&bytes).unwrap()
1574 }
1575
1576 fn put_tree(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
1577 let obj = Object::Tree(Tree { entries });
1578 let bytes = serialize::serialize(&obj).unwrap();
1579 store.write(&bytes).unwrap()
1580 }
1581
1582 fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
1583 TreeEntry {
1584 name: name.to_vec(),
1585 mode,
1586 object_hash: h,
1587 }
1588 }
1589
1590 #[test]
1591 fn identical_trees_no_diff() {
1592 let (_d, s) = fresh_store();
1593 let blob = put_blob(&s, b"content");
1594 let tree = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob)]);
1595 let result = diff_trees(&s, Some(tree), Some(tree)).unwrap();
1596 assert_eq!(result.len(), 0);
1597 }
1598
1599 #[test]
1600 fn added_file_detected() {
1601 let (_d, s) = fresh_store();
1602 let blob_a = put_blob(&s, b"aaa");
1603 let blob_b = put_blob(&s, b"bbb");
1604 let old = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1605 let new = put_tree(
1606 &s,
1607 vec![
1608 entry(b"a.txt", EntryMode::Blob, blob_a),
1609 entry(b"b.txt", EntryMode::Blob, blob_b),
1610 ],
1611 );
1612 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1613 assert_eq!(r.entries.len(), 1);
1614 assert_eq!(r.entries[0].path, "b.txt");
1615 assert_eq!(r.entries[0].kind, DiffKind::Added);
1616 assert_eq!(r.entries[0].old_hash, None);
1617 assert_eq!(r.entries[0].new_hash, Some(blob_b));
1618 }
1619
1620 #[test]
1621 fn removed_file_detected() {
1622 let (_d, s) = fresh_store();
1623 let blob_a = put_blob(&s, b"aaa");
1624 let blob_b = put_blob(&s, b"bbb");
1625 let old = put_tree(
1626 &s,
1627 vec![
1628 entry(b"a.txt", EntryMode::Blob, blob_a),
1629 entry(b"b.txt", EntryMode::Blob, blob_b),
1630 ],
1631 );
1632 let new = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1633 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1634 assert_eq!(r.entries.len(), 1);
1635 assert_eq!(r.entries[0].path, "b.txt");
1636 assert_eq!(r.entries[0].kind, DiffKind::Removed);
1637 assert_eq!(r.entries[0].old_hash, Some(blob_b));
1638 assert_eq!(r.entries[0].new_hash, None);
1639 }
1640
1641 #[test]
1642 fn modified_file_detected() {
1643 let (_d, s) = fresh_store();
1644 let v1 = put_blob(&s, b"version 1");
1645 let v2 = put_blob(&s, b"version 2");
1646 let old = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v1)]);
1647 let new = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v2)]);
1648 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1649 assert_eq!(r.entries.len(), 1);
1650 assert_eq!(r.entries[0].path, "file.txt");
1651 assert_eq!(r.entries[0].kind, DiffKind::Modified);
1652 assert_eq!(r.entries[0].old_hash, Some(v1));
1653 assert_eq!(r.entries[0].new_hash, Some(v2));
1654 }
1655
1656 #[test]
1657 fn mode_change_detected() {
1658 let (_d, s) = fresh_store();
1659 let blob = put_blob(&s, b"content");
1660 let old = put_tree(&s, vec![entry(b"link", EntryMode::Blob, blob)]);
1661 let new = put_tree(&s, vec![entry(b"link", EntryMode::Symlink, blob)]);
1662 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1663 assert_eq!(r.entries.len(), 1);
1664 assert_eq!(r.entries[0].path, "link");
1665 assert_eq!(r.entries[0].kind, DiffKind::ModeChanged);
1666 assert_eq!(r.entries[0].old_hash, Some(blob));
1667 assert_eq!(r.entries[0].new_hash, Some(blob));
1668 }
1669
1670 #[test]
1671 fn nested_tree_diff() {
1672 let (_d, s) = fresh_store();
1673 let v1 = put_blob(&s, b"old content");
1674 let v2 = put_blob(&s, b"new content");
1675 let other = put_blob(&s, b"unchanged");
1676 let old_sub = put_tree(
1677 &s,
1678 vec![
1679 entry(b"file.txt", EntryMode::Blob, v1),
1680 entry(b"other.txt", EntryMode::Blob, other),
1681 ],
1682 );
1683 let new_sub = put_tree(
1684 &s,
1685 vec![
1686 entry(b"file.txt", EntryMode::Blob, v2),
1687 entry(b"other.txt", EntryMode::Blob, other),
1688 ],
1689 );
1690 let old_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, old_sub)]);
1691 let new_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, new_sub)]);
1692 let r = diff_trees(&s, Some(old_root), Some(new_root)).unwrap();
1693 assert_eq!(r.entries.len(), 1);
1694 assert_eq!(r.entries[0].path, "subdir/file.txt");
1695 assert_eq!(r.entries[0].kind, DiffKind::Modified);
1696 }
1697
1698 #[test]
1699 fn diff_against_empty_tree() {
1700 let (_d, s) = fresh_store();
1701 let blob_a = put_blob(&s, b"aaa");
1702 let blob_b = put_blob(&s, b"bbb");
1703 let new = put_tree(
1704 &s,
1705 vec![
1706 entry(b"a.txt", EntryMode::Blob, blob_a),
1707 entry(b"b.txt", EntryMode::Blob, blob_b),
1708 ],
1709 );
1710 let r = diff_trees(&s, None, Some(new)).unwrap();
1711 assert_eq!(r.entries.len(), 2);
1712 assert_eq!(r.entries[0].path, "a.txt");
1713 assert_eq!(r.entries[0].kind, DiffKind::Added);
1714 assert_eq!(r.entries[1].path, "b.txt");
1715 assert_eq!(r.entries[1].kind, DiffKind::Added);
1716 }
1717
1718 #[test]
1719 fn empty_tree_against_non_empty() {
1720 let (_d, s) = fresh_store();
1721 let blob_a = put_blob(&s, b"aaa");
1722 let blob_b = put_blob(&s, b"bbb");
1723 let old = put_tree(
1724 &s,
1725 vec![
1726 entry(b"a.txt", EntryMode::Blob, blob_a),
1727 entry(b"b.txt", EntryMode::Blob, blob_b),
1728 ],
1729 );
1730 let r = diff_trees(&s, Some(old), None).unwrap();
1731 assert_eq!(r.entries.len(), 2);
1732 assert_eq!(r.entries[0].kind, DiffKind::Removed);
1733 assert_eq!(r.entries[1].kind, DiffKind::Removed);
1734 }
1735
1736 #[test]
1737 fn sorted_output() {
1738 let (_d, s) = fresh_store();
1739 let a = put_blob(&s, b"a");
1740 let b = put_blob(&s, b"b");
1741 let c = put_blob(&s, b"c");
1742 let new = put_tree(
1743 &s,
1744 vec![
1745 entry(b"a.txt", EntryMode::Blob, a),
1746 entry(b"m.txt", EntryMode::Blob, b),
1747 entry(b"z.txt", EntryMode::Blob, c),
1748 ],
1749 );
1750 let r = diff_trees(&s, None, Some(new)).unwrap();
1751 assert_eq!(r.entries.len(), 3);
1752 assert_eq!(r.entries[0].path, "a.txt");
1753 assert_eq!(r.entries[1].path, "m.txt");
1754 assert_eq!(r.entries[2].path, "z.txt");
1755 }
1756
1757 #[test]
1758 fn max_length_entry_names() {
1759 let (_d, s) = fresh_store();
1760 let blob = put_blob(&s, b"data");
1761 let long_name = vec![b'A'; 255];
1762 let new = put_tree(&s, vec![entry(&long_name, EntryMode::Blob, blob)]);
1763 let r = diff_trees(&s, None, Some(new)).unwrap();
1764 assert_eq!(r.entries.len(), 1);
1765 assert_eq!(r.entries[0].path.len(), 255);
1766 assert_eq!(r.entries[0].kind, DiffKind::Added);
1767 }
1768
1769 #[test]
1770 fn both_none_is_empty() {
1771 let (_d, s) = fresh_store();
1772 let r = diff_trees(&s, None, None).unwrap();
1773 assert!(r.is_empty());
1774 }
1775
1776 fn fresh_workdir() -> TempDir {
1781 TempDir::new().unwrap()
1782 }
1783
1784 #[test]
1785 fn status_diff_does_not_fsync() {
1786 use crate::batch::testing::{Ev, RecordingSyncer};
1791 use std::sync::Arc;
1792
1793 let (_sd, mut store) = fresh_store();
1794 let work = fresh_workdir();
1795 std::fs::write(work.path().join("a.txt"), b"some content").unwrap();
1796 std::fs::write(work.path().join("b.txt"), b"other content").unwrap();
1797
1798 let rec = Arc::new(RecordingSyncer::default());
1799 store.set_syncer(rec.clone());
1800
1801 let result = status_diff(&store, None, work.path(), None).unwrap();
1802 assert!(!result.is_empty(), "untracked files must surface");
1803
1804 let evs = rec.events();
1805 assert!(
1806 evs.iter().all(|e| matches!(e, Ev::Rename { .. })),
1807 "status must not emit any flush events; got {evs:?}"
1808 );
1809 }
1810
1811 #[test]
1812 fn status_empty_worktree_no_head() {
1813 let (_sd, store) = fresh_store();
1815 let work = fresh_workdir();
1816 let result = status_diff(&store, None, work.path(), None).unwrap();
1817 assert!(result.is_empty());
1818 }
1819
1820 #[test]
1821 fn status_worktree_equals_head_is_clean() {
1822 let (_sd, store) = fresh_store();
1824 let work = fresh_workdir();
1825 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1826 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1828 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1829 assert!(result.is_empty(), "expected clean, got {result:?}");
1830 }
1831
1832 #[cfg(not(unix))]
1833 #[test]
1834 fn status_no_index_ignores_unrepresentable_executable_mode_on_non_unix() {
1835 let (_sd, store) = fresh_store();
1836 let work = fresh_workdir();
1837 std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
1838 let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
1839 let head_hash = put_tree(&store, vec![entry(b"run.sh", EntryMode::Executable, h)]);
1840
1841 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1842 assert!(
1843 result.is_empty(),
1844 "non-Unix should not report executable-only noise, got {result:?}"
1845 );
1846 }
1847
1848 #[test]
1849 fn status_added_only() {
1850 let (_sd, store) = fresh_store();
1852 let work = fresh_workdir();
1853 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1854 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1855 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1856 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1857 assert_eq!(result.len(), 1);
1858 assert_eq!(result[0].diff.path, "b.txt");
1859 assert_eq!(result[0].diff.kind, DiffKind::Added);
1860 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1861 }
1862
1863 #[test]
1864 fn status_removed_only() {
1865 let (_sd, store) = fresh_store();
1867 let work = fresh_workdir();
1868 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1869 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1870 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1871 std::fs::remove_file(work.path().join("b.txt")).unwrap();
1872 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1873 assert_eq!(result.len(), 1);
1874 assert_eq!(result[0].diff.path, "b.txt");
1875 assert_eq!(result[0].diff.kind, DiffKind::Removed);
1876 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1877 }
1878
1879 #[test]
1880 fn status_modified_only() {
1881 let (_sd, store) = fresh_store();
1883 let work = fresh_workdir();
1884 std::fs::write(work.path().join("a.txt"), b"old").unwrap();
1885 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1886 std::fs::write(work.path().join("a.txt"), b"new").unwrap();
1887 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1888 assert_eq!(result.len(), 1);
1889 assert_eq!(result[0].diff.path, "a.txt");
1890 assert_eq!(result[0].diff.kind, DiffKind::Modified);
1891 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1892 }
1893
1894 #[test]
1895 fn status_mixed_changes() {
1896 let (_sd, store) = fresh_store();
1898 let work = fresh_workdir();
1899 std::fs::write(work.path().join("a.txt"), b"original").unwrap();
1900 std::fs::write(work.path().join("b.txt"), b"stays").unwrap();
1901 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1902 std::fs::write(work.path().join("a.txt"), b"changed").unwrap();
1903 std::fs::remove_file(work.path().join("b.txt")).unwrap();
1904 std::fs::write(work.path().join("c.txt"), b"new").unwrap();
1905 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1906 assert_eq!(result.len(), 3);
1907 let paths: Vec<&str> = result.iter().map(|e| e.diff.path.as_str()).collect();
1908 assert!(paths.contains(&"a.txt"), "missing a.txt: {paths:?}");
1909 assert!(paths.contains(&"b.txt"), "missing b.txt: {paths:?}");
1910 assert!(paths.contains(&"c.txt"), "missing c.txt: {paths:?}");
1911 }
1912
1913 #[test]
1914 fn status_no_head_shows_all_as_added() {
1915 let (_sd, store) = fresh_store();
1917 let work = fresh_workdir();
1918 std::fs::write(work.path().join("a.txt"), b"aaa").unwrap();
1919 std::fs::write(work.path().join("b.txt"), b"bbb").unwrap();
1920 let result = status_diff(&store, None, work.path(), None).unwrap();
1921 assert_eq!(result.len(), 2);
1922 for e in &result {
1923 assert_eq!(e.diff.kind, DiffKind::Added);
1924 assert_eq!(e.staging, StatusStaging::Unstaged);
1925 }
1926 }
1927
1928 #[test]
1933 fn status_staged_entry_is_classified_staged() {
1934 use crate::index::{EntryStatus, Index, IndexEntry};
1935 let (_sd, store) = fresh_store();
1936 let work = fresh_workdir();
1937 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1938 let b_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
1939 let mut idx = Index::new();
1940 idx.entries.push(IndexEntry {
1941 path: "b.txt".to_string(),
1942 status: EntryStatus::Blob,
1943 object_hash: b_hash,
1944 mtime_ns: 0,
1945 size: 0,
1946 ino: 0,
1947 ctime_ns: 0,
1948 });
1949 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
1951 assert_eq!(result.len(), 1);
1952 assert_eq!(result[0].diff.path, "b.txt");
1953 assert_eq!(result[0].staging, StatusStaging::Staged);
1954 }
1955
1956 #[cfg(not(unix))]
1957 #[test]
1958 fn status_ignores_unrepresentable_executable_mode_on_non_unix_worktree() {
1959 use crate::index::{EntryStatus, Index, IndexEntry};
1960
1961 let (_sd, store) = fresh_store();
1962 let work = fresh_workdir();
1963 std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
1964 let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
1965 let mut idx = Index::new();
1966 idx.entries.push(IndexEntry {
1967 path: "run.sh".to_string(),
1968 status: EntryStatus::Executable,
1969 object_hash: h,
1970 mtime_ns: 0,
1971 size: 0,
1972 ino: 0,
1973 ctime_ns: 0,
1974 });
1975
1976 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
1977 assert_eq!(result.len(), 1, "expected only the staged addition");
1978 assert_eq!(result[0].staging, StatusStaging::Staged);
1979 }
1980
1981 #[test]
1986 fn text_patch_modified_line_emits_hunk() {
1987 let old = b"line1\nline2\nline3\n";
1988 let new = b"line1\nCHANGED\nline3\n";
1989 let patch = text_patch(old, new, "f.txt", "f.txt");
1990 assert!(patch.starts_with("--- a/f.txt\n+++ b/f.txt\n"), "{patch}");
1991 assert!(patch.contains("@@ -1,3 +1,3 @@"), "{patch}");
1992 assert!(patch.contains("-line2\n"), "{patch}");
1993 assert!(patch.contains("+CHANGED\n"), "{patch}");
1994 assert!(patch.contains(" line1\n"), "{patch}");
1995 assert!(patch.contains(" line3\n"), "{patch}");
1996 }
1997
1998 #[test]
1999 fn text_patch_pure_addition() {
2000 let old = b"a\nb\n";
2001 let new = b"a\nb\nc\n";
2002 let patch = text_patch(old, new, "f", "f");
2003 assert!(patch.contains("+c\n"), "{patch}");
2004 assert!(
2006 !patch
2007 .lines()
2008 .any(|l| l.starts_with('-') && !l.starts_with("---")),
2009 "should be no deletions: {patch}"
2010 );
2011 }
2012
2013 #[test]
2014 fn text_patch_identical_is_empty() {
2015 let patch = text_patch(b"same\n", b"same\n", "f", "f");
2016 assert!(patch.is_empty(), "{patch}");
2017 }
2018
2019 #[test]
2020 fn text_patch_binary_reports_differ() {
2021 let old = &[0x00, 0xff, 0x01][..];
2022 let new = &[0x00, 0xfe, 0x02][..];
2023 let patch = text_patch(old, new, "bin", "bin");
2024 assert_eq!(patch, "Binary files a/bin and b/bin differ\n");
2025 }
2026
2027 #[test]
2028 fn text_patch_nul_byte_is_binary_like_git() {
2029 let patch = text_patch(b"a\0b\n", b"a\0c\n", "f", "f");
2032 assert_eq!(patch, "Binary files a/f and b/f differ\n");
2033 }
2034
2035 #[test]
2036 fn unified_hunks_non_utf8_without_nul_is_text_like_git() {
2037 let hunks = unified_hunks(b"\xff\n", b"\xfe\n").expect("not binary");
2041 assert_eq!(
2042 hunks,
2043 b"@@ -1 +1 @@\n-\xff\n+\xfe\n".to_vec(),
2044 "got {hunks:?}"
2045 );
2046 }
2047
2048 #[test]
2049 fn hunk_header_omits_count_one_like_git() {
2050 let patch = text_patch(b"old\n", b"new\n", "f", "f");
2052 assert_eq!(
2053 patch, "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n",
2054 "{patch}"
2055 );
2056 }
2057
2058 #[test]
2059 fn text_patch_no_trailing_newline_marker() {
2060 let old = b"x\ny";
2061 let new = b"x\nz";
2062 let patch = text_patch(old, new, "f", "f");
2063 assert!(patch.contains("\\ No newline at end of file\n"), "{patch}");
2064 }
2065
2066 #[test]
2067 fn text_patch_separate_hunks_for_distant_changes() {
2068 let old = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
2069 let new = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
2071 let patch = text_patch(old, new, "f", "f");
2072 let hunk_count = patch.matches("@@ ").count();
2073 assert_eq!(hunk_count, 2, "expected two hunks: {patch}");
2074 }
2075
2076 #[test]
2077 fn text_patch_inserts_compact_to_git_position() {
2078 let patch = text_patch(b"a\na\nb\n", b"a\na\na\nb\n", "f", "f");
2083 assert_eq!(
2084 patch, "--- a/f\n+++ b/f\n@@ -1,3 +1,4 @@\n a\n a\n+a\n b\n",
2085 "{patch}"
2086 );
2087 }
2088
2089 #[test]
2090 fn text_patch_deletes_compact_to_git_position() {
2091 let patch = text_patch(b"a\na\na\nb\n", b"a\na\nb\n", "f", "f");
2094 assert_eq!(
2095 patch, "--- a/f\n+++ b/f\n@@ -1,4 +1,3 @@\n a\n a\n-a\n b\n",
2096 "{patch}"
2097 );
2098 }
2099
2100 #[test]
2107 fn status_partially_staged_entry_emits_both_legs() {
2108 use crate::index::{EntryStatus, Index, IndexEntry};
2109 let (_sd, store) = fresh_store();
2110 let work = fresh_workdir();
2111 std::fs::write(work.path().join("b.txt"), b"v1").unwrap();
2113 let b_v1_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
2114 std::fs::write(work.path().join("b.txt"), b"v2").unwrap();
2115 let mut idx = Index::new();
2116 idx.entries.push(IndexEntry {
2117 path: "b.txt".to_string(),
2118 status: EntryStatus::Blob,
2119 object_hash: b_v1_hash,
2120 mtime_ns: 0,
2121 size: 0,
2122 ino: 0,
2123 ctime_ns: 0,
2124 });
2125 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2126 assert_eq!(result.len(), 2, "expected staged + unstaged entries");
2127 let stagings: Vec<_> = result.iter().map(|e| e.staging).collect();
2128 assert!(stagings.contains(&StatusStaging::Staged));
2129 assert!(stagings.contains(&StatusStaging::Unstaged));
2130 assert!(result.iter().all(|e| e.diff.path == "b.txt"));
2131 }
2132
2133 #[test]
2136 fn enumerate_hunks_binary_returns_none() {
2137 assert!(enumerate_hunks(b"a\0b", b"c").is_none());
2138 assert!(enumerate_hunks(b"text\n", b"with\0nul").is_none());
2139 }
2140
2141 #[test]
2142 fn enumerate_hunks_identical_is_empty() {
2143 assert_eq!(enumerate_hunks(b"a\nb\n", b"a\nb\n"), Some(vec![]));
2144 }
2145
2146 const HUNK2_OLD: &[u8] = b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n";
2149 const HUNK2_NEW: &[u8] = b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n";
2150
2151 #[test]
2152 fn apply_all_hunks_reproduces_new_apply_none_reproduces_base() {
2153 let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
2154 assert_eq!(hunks.len(), 2, "expected two distinct hunks");
2155 let all: Vec<usize> = (0..hunks.len()).collect();
2156 assert_eq!(
2157 apply_hunks_subset(HUNK2_OLD, &hunks, &all),
2158 HUNK2_NEW,
2159 "apply all == new"
2160 );
2161 assert_eq!(
2162 apply_hunks_subset(HUNK2_OLD, &hunks, &[]),
2163 HUNK2_OLD,
2164 "apply none == old"
2165 );
2166 }
2167
2168 #[test]
2169 fn apply_single_hunk_picks_only_that_region() {
2170 let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
2171 let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[0]);
2173 assert_eq!(
2174 staged, b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n",
2175 "only the first hunk applied"
2176 );
2177 let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[1]);
2179 assert_eq!(
2180 staged, b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n",
2181 "only the second hunk applied"
2182 );
2183 }
2184
2185 #[test]
2186 fn apply_hunks_into_empty_base_adds_lines() {
2187 let old = b"";
2188 let new = b"alpha\nbeta\n";
2189 let hunks = enumerate_hunks(old, new).unwrap();
2190 assert_eq!(apply_hunks_subset(old, &hunks, &[0]), new);
2191 assert_eq!(apply_hunks_subset(old, &hunks, &[]), old);
2192 }
2193
2194 #[test]
2195 fn apply_hunks_preserves_missing_eof_newline() {
2196 let old = b"a\nb\nc";
2197 let new = b"a\nB\nc"; let hunks = enumerate_hunks(old, new).unwrap();
2199 let staged = apply_hunks_subset(old, &hunks, &[0]);
2200 assert_eq!(staged, new, "no trailing newline must be preserved");
2201 }
2202
2203 #[test]
2206 fn merge3_identical_sides_merge_trivially() {
2207 let x = b"a\nb\n";
2208 assert_eq!(merge_blob_3way(b"base\n", x, x), Some(x.to_vec()));
2209 }
2210
2211 #[test]
2212 fn merge3_disjoint_line_edits_auto_merge() {
2213 let base = b"a\nb\nc\nd\ne\n";
2214 let ours = b"A\nb\nc\nd\ne\n"; let theirs = b"a\nb\nc\nd\nE\n"; assert_eq!(
2217 merge_blob_3way(base, ours, theirs),
2218 Some(b"A\nb\nc\nd\nE\n".to_vec())
2219 );
2220 }
2221
2222 #[test]
2223 fn merge3_adjacent_line_edits_auto_merge() {
2224 let base = b"x\ny\n";
2227 let ours = b"X\ny\n"; let theirs = b"x\nY\n"; assert_eq!(
2230 merge_blob_3way(base, ours, theirs),
2231 Some(b"X\nY\n".to_vec())
2232 );
2233 }
2234
2235 #[test]
2236 fn merge3_overlapping_line_edits_conflict() {
2237 let base = b"a\nb\nc\n";
2238 let ours = b"A\nb\nc\n"; let theirs = b"Z\nb\nc\n"; assert_eq!(merge_blob_3way(base, ours, theirs), None);
2241 }
2242
2243 #[test]
2244 fn merge3_one_side_only_takes_that_side() {
2245 let base = b"a\nb\nc\n";
2247 let ours = b"a\nB\nc\n";
2248 assert_eq!(merge_blob_3way(base, ours, base), Some(ours.to_vec()));
2249 }
2250
2251 #[test]
2252 fn merge3_disjoint_insertions_auto_merge() {
2253 let base = b"a\nb\n";
2254 let ours = b"a\nX\nb\n"; let theirs = b"a\nb\nY\n"; assert_eq!(
2257 merge_blob_3way(base, ours, theirs),
2258 Some(b"a\nX\nb\nY\n".to_vec())
2259 );
2260 }
2261
2262 #[test]
2263 fn merge3_coincident_insertions_conflict() {
2264 let base = b"a\nb\n";
2265 let ours = b"a\nX\nb\n"; let theirs = b"a\nY\nb\n";
2267 assert_eq!(merge_blob_3way(base, ours, theirs), None);
2268 }
2269
2270 #[test]
2271 fn merge3_binary_side_conflicts() {
2272 assert_eq!(merge_blob_3way(b"a\nb\n", b"a\0\nb\n", b"a\nb\nc\n"), None);
2273 }
2274
2275 #[test]
2280 fn ignore_all_space_treats_whitespace_only_change_as_unchanged() {
2281 let old = b"head\nfoo(a, b)\ntail\n";
2282 let new = b"head\nfoo(a,b)\ntail\n";
2283 let exact = unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact).unwrap();
2284 assert!(!exact.is_empty(), "exact mode should see the change");
2285 let ws =
2286 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
2287 assert!(
2288 ws.is_empty(),
2289 "-w should ignore a line that only gained/lost whitespace: {ws:?}"
2290 );
2291 }
2292
2293 #[test]
2294 fn ignore_space_change_does_not_ignore_whitespace_appearing_from_nothing() {
2295 let old = b"foo(a, b)\n";
2298 let new = b"foo(a,b)\n";
2299 let hunks =
2300 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2301 assert!(
2302 !hunks.is_empty(),
2303 "-b should still flag whitespace appearing where there was none"
2304 );
2305 }
2306
2307 #[test]
2308 fn ignore_space_change_ignores_differing_amounts() {
2309 let old = b"foo(a, b)\n";
2312 let new = b"foo(a, b)\n";
2313 let hunks =
2314 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2315 assert!(
2316 hunks.is_empty(),
2317 "-b should ignore a pure whitespace-amount change: {hunks:?}"
2318 );
2319 let ws_hunks =
2321 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
2322 assert!(ws_hunks.is_empty());
2323 }
2324
2325 #[test]
2326 fn whitespace_mode_never_changes_the_rendered_line_bytes() {
2327 let old = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-OLD\npad6\n";
2333 let new = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-NEW\npad6\n";
2334 let hunks =
2335 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2336 let text = String::from_utf8(hunks).unwrap();
2337 assert!(text.contains("-CHANGED-OLD\n"), "{text}");
2338 assert!(text.contains("+CHANGED-NEW\n"), "{text}");
2339 assert!(!text.contains("foo(x"), "{text}");
2342 }
2343
2344 #[test]
2345 fn unified_hunks_opts_default_matches_unified_hunks() {
2346 let old = b"a\nb\nc\n";
2347 let new = b"a\nB\nc\n";
2348 assert_eq!(
2349 unified_hunks(old, new),
2350 unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact)
2351 );
2352 }
2353
2354 fn ten_lines_changing_the_fifth() -> (Vec<u8>, Vec<u8>) {
2359 let lines: Vec<String> = (1..=10).map(|n| format!("l{n}")).collect();
2360 let mut old = lines.join("\n");
2361 old.push('\n');
2362 let mut changed = lines;
2363 changed[4] = "l5-changed".to_string();
2364 let mut new = changed.join("\n");
2365 new.push('\n');
2366 (old.into_bytes(), new.into_bytes())
2367 }
2368
2369 fn context_line_count(hunks: &[u8]) -> usize {
2370 String::from_utf8_lossy(hunks)
2371 .lines()
2372 .skip_while(|l| !l.starts_with("@@"))
2373 .skip(1)
2374 .filter(|l| l.starts_with(' '))
2375 .count()
2376 }
2377
2378 #[test]
2379 fn context_zero_shows_no_surrounding_lines() {
2380 let (old, new) = ten_lines_changing_the_fifth();
2381 let hunks = unified_hunks_opts(&old, &new, 0, WhitespaceMode::Exact).unwrap();
2382 assert_eq!(context_line_count(&hunks), 0);
2383 let text = String::from_utf8(hunks).unwrap();
2384 assert!(text.contains("-l5\n"), "{text}");
2385 assert!(text.contains("+l5-changed\n"), "{text}");
2386 }
2387
2388 #[test]
2389 fn context_one_shows_one_line_each_side() {
2390 let (old, new) = ten_lines_changing_the_fifth();
2391 let hunks = unified_hunks_opts(&old, &new, 1, WhitespaceMode::Exact).unwrap();
2392 assert_eq!(context_line_count(&hunks), 2);
2393 }
2394
2395 #[test]
2396 fn context_default_matches_three() {
2397 let (old, new) = ten_lines_changing_the_fifth();
2398 let hunks =
2399 unified_hunks_opts(&old, &new, DEFAULT_CONTEXT_LINES, WhitespaceMode::Exact).unwrap();
2400 assert_eq!(DEFAULT_CONTEXT_LINES, 3);
2401 assert_eq!(context_line_count(&hunks), 6);
2402 }
2403}