1use std::path::Path;
14
15use crate::hash::Hash;
16use crate::index::{Index, IndexError};
17use crate::object::{EntryMode, Object, TreeEntry};
18use crate::store::{MAX_TREE_DEPTH, ObjectSource, ObjectStore, StoreError};
19use crate::worktree::{self, WorktreeError};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum DiffKind {
24 Added,
26 Removed,
28 Modified,
30 ModeChanged,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct DiffEntry {
39 pub path: String,
40 pub kind: DiffKind,
41 pub old_hash: Option<Hash>,
42 pub new_hash: Option<Hash>,
43 pub old_mode: Option<EntryMode>,
46 pub new_mode: Option<EntryMode>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct DiffResult {
52 pub entries: Vec<DiffEntry>,
53}
54
55impl DiffResult {
56 #[must_use]
57 pub fn is_empty(&self) -> bool {
58 self.entries.is_empty()
59 }
60
61 #[must_use]
62 pub fn len(&self) -> usize {
63 self.entries.len()
64 }
65}
66
67pub fn diff_trees<S: ObjectSource + ?Sized>(
76 store: &S,
77 old_hash: Option<Hash>,
78 new_hash: Option<Hash>,
79) -> Result<DiffResult, StoreError> {
80 diff_trees_inner(store, old_hash, new_hash, false)
81}
82
83fn diff_trees_inner<S: ObjectSource + ?Sized>(
84 store: &S,
85 old_hash: Option<Hash>,
86 new_hash: Option<Hash>,
87 ignore_regular_executable_mode: bool,
88) -> Result<DiffResult, StoreError> {
89 match (old_hash, new_hash) {
91 (None, None) => return Ok(DiffResult::default()),
92 (Some(a), Some(b)) if a == b => return Ok(DiffResult::default()),
93 _ => {}
94 }
95
96 let old_entries = load_entries(store, old_hash)?;
97 let new_entries = load_entries(store, new_hash)?;
98
99 let mut out: Vec<DiffEntry> = Vec::new();
100 diff_entries_recursive(
101 store,
102 &old_entries,
103 &new_entries,
104 "",
105 &mut out,
106 ignore_regular_executable_mode,
107 0,
108 )?;
109 out.sort_by(|a, b| a.path.cmp(&b.path));
114 Ok(DiffResult { entries: out })
115}
116
117fn diff_entries_recursive<S: ObjectSource + ?Sized>(
119 store: &S,
120 old_entries: &[TreeEntry],
121 new_entries: &[TreeEntry],
122 prefix: &str,
123 out: &mut Vec<DiffEntry>,
124 ignore_regular_executable_mode: bool,
125 depth: usize,
126) -> Result<(), StoreError> {
127 if depth > MAX_TREE_DEPTH {
128 return Err(StoreError::TreeTooDeep);
129 }
130 let mut i = 0usize;
131 let mut j = 0usize;
132
133 while i < old_entries.len() && j < new_entries.len() {
134 let o = &old_entries[i];
135 let n = &new_entries[j];
136 match o.name.as_slice().cmp(n.name.as_slice()) {
137 std::cmp::Ordering::Less => {
138 add_removed_entries(store, o, prefix, out, depth)?;
139 i += 1;
140 }
141 std::cmp::Ordering::Greater => {
142 add_added_entries(store, n, prefix, out, depth)?;
143 j += 1;
144 }
145 std::cmp::Ordering::Equal => {
146 if o.mode == EntryMode::Tree && n.mode == EntryMode::Tree {
147 if o.object_hash != n.object_hash {
148 let sub_prefix = join_path(prefix, &o.name);
149 let old_sub = load_tree(store, o.object_hash)?;
150 let new_sub = load_tree(store, n.object_hash)?;
151 diff_entries_recursive(
152 store,
153 &old_sub,
154 &new_sub,
155 &sub_prefix,
156 out,
157 ignore_regular_executable_mode,
158 depth + 1,
159 )?;
160 }
161 } else if o.mode == EntryMode::Tree || n.mode == EntryMode::Tree {
163 add_removed_entries(store, o, prefix, out, depth)?;
169 add_added_entries(store, n, prefix, out, depth)?;
170 } else if o.mode != n.mode && o.object_hash == n.object_hash {
171 if !ignore_regular_executable_mode || !regular_executable_pair(o.mode, n.mode) {
172 out.push(DiffEntry {
173 path: join_path(prefix, &o.name),
174 kind: DiffKind::ModeChanged,
175 old_hash: Some(o.object_hash),
176 new_hash: Some(n.object_hash),
177 old_mode: Some(o.mode),
178 new_mode: Some(n.mode),
179 });
180 }
181 } else if o.object_hash != n.object_hash || o.mode != n.mode {
182 out.push(DiffEntry {
183 path: join_path(prefix, &o.name),
184 kind: DiffKind::Modified,
185 old_hash: Some(o.object_hash),
186 new_hash: Some(n.object_hash),
187 old_mode: Some(o.mode),
188 new_mode: Some(n.mode),
189 });
190 }
191 i += 1;
192 j += 1;
193 }
194 }
195 }
196
197 while i < old_entries.len() {
198 add_removed_entries(store, &old_entries[i], prefix, out, depth)?;
199 i += 1;
200 }
201 while j < new_entries.len() {
202 add_added_entries(store, &new_entries[j], prefix, out, depth)?;
203 j += 1;
204 }
205 Ok(())
206}
207
208fn regular_executable_pair(a: EntryMode, b: EntryMode) -> bool {
209 matches!(
210 (a, b),
211 (EntryMode::Blob, EntryMode::Executable) | (EntryMode::Executable, EntryMode::Blob)
212 )
213}
214
215fn add_removed_entries<S: ObjectSource + ?Sized>(
216 store: &S,
217 entry: &TreeEntry,
218 prefix: &str,
219 out: &mut Vec<DiffEntry>,
220 depth: usize,
221) -> Result<(), StoreError> {
222 if depth > MAX_TREE_DEPTH {
223 return Err(StoreError::TreeTooDeep);
224 }
225 if entry.mode == EntryMode::Tree {
226 let sub_prefix = join_path(prefix, &entry.name);
227 let sub = load_tree(store, entry.object_hash)?;
228 for sub_entry in &sub {
229 add_removed_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
230 }
231 } else {
232 out.push(DiffEntry {
233 path: join_path(prefix, &entry.name),
234 kind: DiffKind::Removed,
235 old_hash: Some(entry.object_hash),
236 new_hash: None,
237 old_mode: Some(entry.mode),
238 new_mode: None,
239 });
240 }
241 Ok(())
242}
243
244fn add_added_entries<S: ObjectSource + ?Sized>(
245 store: &S,
246 entry: &TreeEntry,
247 prefix: &str,
248 out: &mut Vec<DiffEntry>,
249 depth: usize,
250) -> Result<(), StoreError> {
251 if depth > MAX_TREE_DEPTH {
252 return Err(StoreError::TreeTooDeep);
253 }
254 if entry.mode == EntryMode::Tree {
255 let sub_prefix = join_path(prefix, &entry.name);
256 let sub = load_tree(store, entry.object_hash)?;
257 for sub_entry in &sub {
258 add_added_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
259 }
260 } else {
261 out.push(DiffEntry {
262 path: join_path(prefix, &entry.name),
263 kind: DiffKind::Added,
264 old_hash: None,
265 new_hash: Some(entry.object_hash),
266 old_mode: None,
267 new_mode: Some(entry.mode),
268 });
269 }
270 Ok(())
271}
272
273fn load_entries<S: ObjectSource + ?Sized>(
274 store: &S,
275 hash: Option<Hash>,
276) -> Result<Vec<TreeEntry>, StoreError> {
277 match hash {
278 Some(h) => load_tree(store, h),
279 None => Ok(Vec::new()),
280 }
281}
282
283fn load_tree<S: ObjectSource + ?Sized>(store: &S, h: Hash) -> Result<Vec<TreeEntry>, StoreError> {
284 match store.read_object(&h)? {
285 Object::Tree(t) => Ok(t.entries),
286 other => Err(StoreError::Decode(
287 crate::object::MkitError::InvalidObjectType(other.object_type() as u8),
288 )),
289 }
290}
291
292fn join_path(prefix: &str, name: &[u8]) -> String {
300 let name_str = String::from_utf8_lossy(name);
301 if prefix.is_empty() {
302 name_str.into_owned()
303 } else {
304 let mut s = String::with_capacity(prefix.len() + 1 + name_str.len());
305 s.push_str(prefix);
306 s.push('/');
307 s.push_str(&name_str);
308 s
309 }
310}
311
312const PATCH_CONTEXT: usize = 3;
318
319#[must_use]
346pub fn text_patch(old_bytes: &[u8], new_bytes: &[u8], old_path: &str, new_path: &str) -> String {
347 match unified_hunks(old_bytes, new_bytes) {
348 None => format!("Binary files a/{old_path} and b/{new_path} differ\n"),
349 Some(hunks) if hunks.is_empty() => String::new(),
350 Some(hunks) => {
351 format!(
352 "--- a/{old_path}\n+++ b/{new_path}\n{}",
353 String::from_utf8_lossy(&hunks)
354 )
355 }
356 }
357}
358
359#[must_use]
370pub fn unified_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<u8>> {
371 if is_binary(old_bytes) || is_binary(new_bytes) {
372 return None;
373 }
374 let old_lines = split_lines(old_bytes);
375 let new_lines = split_lines(new_bytes);
376 let ops = edit_script(&old_lines, &new_lines);
377 let hunks = group_hunks(&ops, PATCH_CONTEXT);
378 let mut out = Vec::new();
379 for hunk in &hunks {
380 render_hunk(&mut out, hunk, &old_lines, &new_lines);
381 }
382 Some(out)
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum HunkLineKind {
389 Context,
391 Added,
393 Removed,
395}
396
397#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct HunkLine {
401 pub kind: HunkLineKind,
403 pub text: Vec<u8>,
405 pub has_newline: bool,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct PatchHunk {
414 pub old_start: usize,
416 pub old_len: usize,
418 pub new_start: usize,
420 pub new_len: usize,
422 pub lines: Vec<HunkLine>,
424}
425
426#[must_use]
434pub fn enumerate_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<PatchHunk>> {
435 if is_binary(old_bytes) || is_binary(new_bytes) {
436 return None;
437 }
438 let old_lines = split_lines(old_bytes);
439 let new_lines = split_lines(new_bytes);
440 let ops = edit_script(&old_lines, &new_lines);
441 let hunks = group_hunks(&ops, PATCH_CONTEXT);
442 Some(
443 hunks
444 .iter()
445 .map(|h| to_patch_hunk(h, &old_lines, &new_lines))
446 .collect(),
447 )
448}
449
450fn to_patch_hunk(hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> PatchHunk {
451 let lines = hunk
452 .ops
453 .iter()
454 .map(|op| {
455 let (kind, dl) = match *op {
456 DiffOp::Equal(oi, _) => (HunkLineKind::Context, &old[oi]),
457 DiffOp::Delete(oi) => (HunkLineKind::Removed, &old[oi]),
458 DiffOp::Insert(ni) => (HunkLineKind::Added, &new[ni]),
459 };
460 HunkLine {
461 kind,
462 text: dl.text.to_vec(),
463 has_newline: dl.has_newline,
464 }
465 })
466 .collect();
467 PatchHunk {
468 old_start: hunk.old_start,
469 old_len: hunk.old_len,
470 new_start: hunk.new_start,
471 new_len: hunk.new_len,
472 lines,
473 }
474}
475
476#[must_use]
487pub fn apply_hunks_subset(base_bytes: &[u8], hunks: &[PatchHunk], selected: &[usize]) -> Vec<u8> {
488 let old_lines = split_lines(base_bytes);
489 let sel: std::collections::HashSet<usize> = selected.iter().copied().collect();
490 let mut out: Vec<u8> = Vec::with_capacity(base_bytes.len());
491 let mut cursor = 0usize; for (i, h) in hunks.iter().enumerate() {
493 let region_start = if h.old_len == 0 {
497 h.old_start
498 } else {
499 h.old_start - 1
500 }
501 .min(old_lines.len());
502 let region_end = (region_start + h.old_len).min(old_lines.len());
503 for dl in &old_lines[cursor..region_start] {
505 emit_raw_line(&mut out, dl.text, dl.has_newline);
506 }
507 if sel.contains(&i) {
508 for l in &h.lines {
510 if l.kind != HunkLineKind::Removed {
511 emit_raw_line(&mut out, &l.text, l.has_newline);
512 }
513 }
514 } else {
515 for dl in &old_lines[region_start..region_end] {
517 emit_raw_line(&mut out, dl.text, dl.has_newline);
518 }
519 }
520 cursor = region_end;
521 }
522 for dl in &old_lines[cursor..] {
523 emit_raw_line(&mut out, dl.text, dl.has_newline);
524 }
525 out
526}
527
528fn emit_raw_line(out: &mut Vec<u8>, text: &[u8], has_newline: bool) {
529 out.extend_from_slice(text);
530 if has_newline {
531 out.push(b'\n');
532 }
533}
534
535struct MergeRegion {
539 base_start: usize,
540 base_len: usize,
541 new: Vec<(Vec<u8>, bool)>,
542}
543
544#[must_use]
554pub fn merge_blob_3way(base: &[u8], ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
555 if ours == theirs {
556 return Some(ours.to_vec());
557 }
558 if is_binary(base) || is_binary(ours) || is_binary(theirs) {
559 return None;
560 }
561 let base_lines = split_lines(base);
562 let ours_regions = changed_regions(&base_lines, &split_lines(ours));
563 let theirs_regions = changed_regions(&base_lines, &split_lines(theirs));
564 if regions_overlap(&ours_regions, &theirs_regions) {
565 return None;
566 }
567 Some(apply_merge_regions(
568 &base_lines,
569 &ours_regions,
570 &theirs_regions,
571 ))
572}
573
574fn changed_regions(base: &[DiffLine<'_>], side: &[DiffLine<'_>]) -> Vec<MergeRegion> {
577 let mut regions: Vec<MergeRegion> = Vec::new();
578 let mut cur: Option<MergeRegion> = None;
579 let mut base_idx = 0usize; for op in edit_script(base, side) {
581 match op {
582 DiffOp::Equal(bi, _) => {
583 if let Some(r) = cur.take() {
584 regions.push(r);
585 }
586 base_idx = bi + 1;
587 }
588 DiffOp::Delete(bi) => {
589 cur.get_or_insert(MergeRegion {
590 base_start: base_idx,
591 base_len: 0,
592 new: Vec::new(),
593 })
594 .base_len += 1;
595 base_idx = bi + 1;
596 }
597 DiffOp::Insert(si) => {
598 let line = &side[si];
599 cur.get_or_insert(MergeRegion {
600 base_start: base_idx,
601 base_len: 0,
602 new: Vec::new(),
603 })
604 .new
605 .push((line.text.to_vec(), line.has_newline));
606 }
607 }
608 }
609 if let Some(r) = cur.take() {
610 regions.push(r);
611 }
612 regions
613}
614
615fn regions_overlap(ours: &[MergeRegion], theirs: &[MergeRegion]) -> bool {
621 ours.iter().any(|a| {
622 theirs.iter().any(|b| {
623 let (a_s, a_e) = (a.base_start, a.base_start + a.base_len);
624 let (b_s, b_e) = (b.base_start, b.base_start + b.base_len);
625 match (a.base_len == 0, b.base_len == 0) {
626 (true, true) => a_s == b_s,
627 (true, false) => b_s < a_s && a_s < b_e,
628 (false, true) => a_s < b_s && b_s < a_e,
629 (false, false) => a_s < b_e && b_s < a_e,
630 }
631 })
632 })
633}
634
635fn apply_merge_regions(
639 base: &[DiffLine<'_>],
640 ours: &[MergeRegion],
641 theirs: &[MergeRegion],
642) -> Vec<u8> {
643 let mut all: Vec<&MergeRegion> = ours.iter().chain(theirs.iter()).collect();
644 all.sort_by_key(|r| (r.base_start, r.base_len));
645 let mut out: Vec<u8> = Vec::new();
646 let mut cursor = 0usize;
647 for r in all {
648 for line in &base[cursor..r.base_start] {
649 emit_raw_line(&mut out, line.text, line.has_newline);
650 }
651 for (text, nl) in &r.new {
652 emit_raw_line(&mut out, text, *nl);
653 }
654 cursor = r.base_start + r.base_len;
655 }
656 for line in &base[cursor..] {
657 emit_raw_line(&mut out, line.text, line.has_newline);
658 }
659 out
660}
661
662#[must_use]
673pub fn diff_line_counts(old_bytes: &[u8], new_bytes: &[u8]) -> Option<(usize, usize)> {
674 if is_binary(old_bytes) || is_binary(new_bytes) {
675 return None;
676 }
677 let old_lines = split_lines(old_bytes);
678 let new_lines = split_lines(new_bytes);
679 let mut added = 0;
680 let mut deleted = 0;
681 for op in edit_script(&old_lines, &new_lines) {
682 match op {
683 DiffOp::Insert(_) => added += 1,
684 DiffOp::Delete(_) => deleted += 1,
685 DiffOp::Equal(_, _) => {}
686 }
687 }
688 Some((added, deleted))
689}
690
691fn is_binary(bytes: &[u8]) -> bool {
694 const FIRST_FEW_BYTES: usize = 8000;
695 bytes.iter().take(FIRST_FEW_BYTES).any(|&b| b == 0)
696}
697
698struct DiffLine<'a> {
701 text: &'a [u8],
702 has_newline: bool,
704}
705
706fn split_lines(text: &[u8]) -> Vec<DiffLine<'_>> {
709 let mut lines = Vec::new();
710 let mut rest = text;
711 while !rest.is_empty() {
712 if let Some(idx) = rest.iter().position(|&b| b == b'\n') {
713 lines.push(DiffLine {
714 text: &rest[..idx],
715 has_newline: true,
716 });
717 rest = &rest[idx + 1..];
718 } else {
719 lines.push(DiffLine {
720 text: rest,
721 has_newline: false,
722 });
723 rest = b"";
724 }
725 }
726 lines
727}
728
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731enum DiffOp {
732 Equal(usize, usize),
734 Delete(usize),
736 Insert(usize),
738}
739
740fn edit_script(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
746 let (mut old_changed, mut new_changed) = myers_changed(old, new);
747 compact_changes(old, &mut old_changed);
748 compact_changes(new, &mut new_changed);
749 script_from_flags(old, new, &old_changed, &new_changed)
750}
751
752#[allow(
762 clippy::cast_sign_loss,
763 clippy::cast_possible_wrap,
764 clippy::many_single_char_names
765)]
766fn myers_changed(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> (Vec<bool>, Vec<bool>) {
767 let n = old.len();
768 let m = new.len();
769 let mut old_changed = vec![false; n];
770 let mut new_changed = vec![false; m];
771 if n == 0 {
772 new_changed.fill(true);
773 return (old_changed, new_changed);
774 }
775 if m == 0 {
776 old_changed.fill(true);
777 return (old_changed, new_changed);
778 }
779
780 let max = n + m;
781 let offset = max as isize; let mut v = vec![0isize; 2 * max + 1];
783 let mut trace: Vec<Vec<isize>> = Vec::new();
784
785 let idx = |k: isize| (k + offset) as usize;
786 let mut found = max as isize;
787 'outer: for d in 0..=max as isize {
788 trace.push(v.clone());
789 let mut k = -d;
790 while k <= d {
791 let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) {
793 v[idx(k + 1)] } else {
795 v[idx(k - 1)] + 1 };
797 let mut y = x - k;
798 while (x as usize) < n
799 && (y as usize) < m
800 && lines_equal(&old[x as usize], &new[y as usize])
801 {
802 x += 1;
803 y += 1;
804 }
805 v[idx(k)] = x;
806 if x as usize >= n && y as usize >= m {
807 found = d;
808 break 'outer;
809 }
810 k += 2;
811 }
812 }
813
814 let mut x = n as isize;
816 let mut y = m as isize;
817 for d in (0..=found).rev() {
818 let vd = &trace[d as usize];
819 let k = x - y;
820 let down = k == -d || (k != d && vd[idx(k - 1)] < vd[idx(k + 1)]);
821 let prev_k = if down { k + 1 } else { k - 1 };
822 let prev_x = vd[idx(prev_k)];
823 let prev_y = prev_x - prev_k;
824 while x > prev_x && y > prev_y {
826 x -= 1;
827 y -= 1;
828 }
829 if d > 0 {
830 if down {
831 new_changed[(y - 1) as usize] = true; y -= 1;
833 } else {
834 old_changed[(x - 1) as usize] = true; x -= 1;
836 }
837 }
838 }
839 (old_changed, new_changed)
840}
841
842fn compact_changes(lines: &[DiffLine<'_>], changed: &mut [bool]) {
847 let n = lines.len();
848 let mut i = 0;
849 while i < n {
850 if !changed[i] {
851 i += 1;
852 continue;
853 }
854 let start = i;
856 let mut end = i;
857 while end < n && changed[end] {
858 end += 1;
859 }
860 let (mut s, mut e) = (start, end);
863 while e < n && lines_equal(&lines[s], &lines[e]) {
864 changed[s] = false;
865 changed[e] = true;
866 s += 1;
867 e += 1;
868 }
869 i = e;
870 }
871}
872
873fn script_from_flags(
877 old: &[DiffLine<'_>],
878 new: &[DiffLine<'_>],
879 old_changed: &[bool],
880 new_changed: &[bool],
881) -> Vec<DiffOp> {
882 let (n, m) = (old.len(), new.len());
883 let mut ops = Vec::new();
884 let (mut i, mut j) = (0usize, 0usize);
885 while i < n || j < m {
886 if i < n && j < m && !old_changed[i] && !new_changed[j] {
887 ops.push(DiffOp::Equal(i, j));
888 i += 1;
889 j += 1;
890 } else {
891 while i < n && old_changed[i] {
892 ops.push(DiffOp::Delete(i));
893 i += 1;
894 }
895 while j < m && new_changed[j] {
896 ops.push(DiffOp::Insert(j));
897 j += 1;
898 }
899 }
900 }
901 ops
902}
903
904fn lines_equal(a: &DiffLine<'_>, b: &DiffLine<'_>) -> bool {
905 a.text == b.text && a.has_newline == b.has_newline
906}
907
908struct Hunk {
911 old_start: usize,
912 old_len: usize,
913 new_start: usize,
914 new_len: usize,
915 ops: Vec<DiffOp>,
916}
917
918fn group_hunks(ops: &[DiffOp], context: usize) -> Vec<Hunk> {
921 let change_positions: Vec<usize> = ops
923 .iter()
924 .enumerate()
925 .filter(|(_, op)| !matches!(op, DiffOp::Equal(_, _)))
926 .map(|(idx, _)| idx)
927 .collect();
928 if change_positions.is_empty() {
929 return Vec::new();
930 }
931
932 let mut ranges: Vec<(usize, usize)> = Vec::new();
935 for &pos in &change_positions {
936 let start = pos.saturating_sub(context);
937 let end = (pos + context + 1).min(ops.len());
938 match ranges.last_mut() {
939 Some(last) if start <= last.1 => last.1 = last.1.max(end),
940 _ => ranges.push((start, end)),
941 }
942 }
943
944 ranges
945 .into_iter()
946 .map(|(start, end)| build_hunk(&ops[start..end]))
947 .collect()
948}
949
950fn build_hunk(slice: &[DiffOp]) -> Hunk {
951 let mut old_start = None;
952 let mut new_start = None;
953 let mut old_len = 0usize;
954 let mut new_len = 0usize;
955 for op in slice {
956 match *op {
957 DiffOp::Equal(oi, ni) => {
958 old_start.get_or_insert(oi);
959 new_start.get_or_insert(ni);
960 old_len += 1;
961 new_len += 1;
962 }
963 DiffOp::Delete(oi) => {
964 old_start.get_or_insert(oi);
965 old_len += 1;
966 }
967 DiffOp::Insert(ni) => {
968 new_start.get_or_insert(ni);
969 new_len += 1;
970 }
971 }
972 }
973 Hunk {
974 old_start: old_start.map_or(0, |s| s + 1),
976 old_len,
977 new_start: new_start.map_or(0, |s| s + 1),
978 new_len,
979 ops: slice.to_vec(),
980 }
981}
982
983fn hunk_range(start: usize, len: usize) -> String {
986 if len == 1 {
987 start.to_string()
988 } else {
989 format!("{start},{len}")
990 }
991}
992
993fn render_hunk(out: &mut Vec<u8>, hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) {
994 let header = format!(
995 "@@ -{} +{} @@\n",
996 hunk_range(hunk.old_start, hunk.old_len),
997 hunk_range(hunk.new_start, hunk.new_len)
998 );
999 out.extend_from_slice(header.as_bytes());
1000 for op in &hunk.ops {
1001 match *op {
1002 DiffOp::Equal(oi, _) => emit_line(out, b' ', &old[oi]),
1003 DiffOp::Delete(oi) => emit_line(out, b'-', &old[oi]),
1004 DiffOp::Insert(ni) => emit_line(out, b'+', &new[ni]),
1005 }
1006 }
1007}
1008
1009fn emit_line(out: &mut Vec<u8>, prefix: u8, line: &DiffLine<'_>) {
1010 out.push(prefix);
1011 out.extend_from_slice(line.text);
1012 out.push(b'\n');
1013 if !line.has_newline {
1014 out.extend_from_slice(b"\\ No newline at end of file\n");
1015 }
1016}
1017
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027pub enum StatusStaging {
1028 Unstaged,
1030 Staged,
1032 PartiallyStaged,
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct StatusEntry {
1041 pub diff: DiffEntry,
1043 pub staging: StatusStaging,
1045}
1046
1047#[derive(Debug, thiserror::Error)]
1049pub enum DiffError {
1050 #[error(transparent)]
1052 Store(#[from] StoreError),
1053 #[error(transparent)]
1055 Worktree(#[from] WorktreeError),
1056 #[error(transparent)]
1058 Index(#[from] IndexError),
1059}
1060
1061#[allow(clippy::too_many_lines)]
1092pub fn status_diff(
1093 store: &ObjectStore,
1094 head_tree: Option<&Hash>,
1095 worktree_root: &Path,
1096 index: Option<&Index>,
1097) -> Result<Vec<StatusEntry>, DiffError> {
1098 status_diff_observed(store, head_tree, worktree_root, index).map(|(entries, _)| entries)
1099}
1100
1101#[allow(clippy::type_complexity)]
1110pub fn status_diff_observed(
1111 store: &ObjectStore,
1112 head_tree: Option<&Hash>,
1113 worktree_root: &Path,
1114 index: Option<&Index>,
1115) -> Result<(Vec<StatusEntry>, Vec<worktree::StatObservation>), DiffError> {
1116 let head_seed;
1121 let tracked = if let Some(i) = index {
1122 Some(i)
1123 } else if let Some(ht) = head_tree {
1124 head_seed = crate::index::from_tree(store, *ht)?;
1125 Some(&head_seed)
1126 } else {
1127 None
1128 };
1129 let snapshot = crate::store::EphemeralSink::new(store);
1135 let mut observations = Vec::new();
1136 let work_tree_hash = worktree::build_tree_filtered_observed(
1137 &snapshot,
1138 worktree_root,
1139 tracked,
1140 &mut observations,
1141 )?;
1142
1143 let Some(idx) = index else {
1144 let diff = diff_worktree_trees(&snapshot, head_tree.copied(), Some(work_tree_hash))?;
1146 return Ok((
1147 diff.entries
1148 .into_iter()
1149 .map(|d| StatusEntry {
1150 diff: d,
1151 staging: StatusStaging::Unstaged,
1152 })
1153 .collect(),
1154 observations,
1155 ));
1156 };
1157
1158 let index_tree = worktree::build_tree_from_index_with(store, &snapshot, idx, false)?;
1162
1163 let staged = diff_trees(&snapshot, head_tree.copied(), Some(index_tree))?;
1164 let unstaged = diff_worktree_trees(&snapshot, Some(index_tree), Some(work_tree_hash))?;
1165
1166 let mut out: Vec<StatusEntry> =
1173 Vec::with_capacity(staged.entries.len() + unstaged.entries.len());
1174 for d in staged.entries {
1175 out.push(StatusEntry {
1176 diff: d,
1177 staging: StatusStaging::Staged,
1178 });
1179 }
1180 for d in unstaged.entries {
1181 out.push(StatusEntry {
1182 diff: d,
1183 staging: StatusStaging::Unstaged,
1184 });
1185 }
1186 out.sort_by(|a, b| {
1187 a.diff.path.cmp(&b.diff.path).then_with(|| {
1189 #[allow(clippy::match_same_arms)]
1190 match (a.staging, b.staging) {
1191 (StatusStaging::Staged, StatusStaging::Staged) => std::cmp::Ordering::Equal,
1192 (StatusStaging::Staged, _) => std::cmp::Ordering::Less,
1193 (_, StatusStaging::Staged) => std::cmp::Ordering::Greater,
1194 _ => std::cmp::Ordering::Equal,
1195 }
1196 })
1197 });
1198 Ok((out, observations))
1199}
1200
1201#[cfg(unix)]
1202fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1203 store: &S,
1204 old_hash: Option<Hash>,
1205 new_hash: Option<Hash>,
1206) -> Result<DiffResult, StoreError> {
1207 diff_trees(store, old_hash, new_hash)
1208}
1209
1210#[cfg(not(unix))]
1211fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1212 store: &S,
1213 old_hash: Option<Hash>,
1214 new_hash: Option<Hash>,
1215) -> Result<DiffResult, StoreError> {
1216 diff_trees_inner(store, old_hash, new_hash, true)
1217}
1218
1219#[cfg(test)]
1224#[allow(clippy::many_single_char_names)] mod tests {
1226 use super::*;
1227 use crate::object::{Blob, Tree};
1228 use crate::serialize;
1229 use tempfile::TempDir;
1230
1231 #[test]
1232 fn diff_line_counts_counts_text_and_flags_binary() {
1233 assert_eq!(
1235 diff_line_counts(b"a\nb\nc\n", b"a\nB\nc\nd\ne\n"),
1236 Some((3, 1))
1237 );
1238 assert_eq!(diff_line_counts(b"a\nb\n", b"a\x00b\n"), None);
1240 assert_eq!(diff_line_counts(b"x\x00y", b"z"), None);
1241 assert!(diff_line_counts(b"\xff\xfe\n", b"\xff\xfe\nmore\n").is_some());
1243 }
1244
1245 fn fresh_store() -> (TempDir, ObjectStore) {
1246 let dir = TempDir::new().unwrap();
1247 let store = ObjectStore::init(dir.path()).unwrap();
1248 (dir, store)
1249 }
1250
1251 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
1252 let obj = Object::Blob(Blob {
1253 data: data.to_vec(),
1254 });
1255 let bytes = serialize::serialize(&obj).unwrap();
1256 store.write(&bytes).unwrap()
1257 }
1258
1259 fn put_tree(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
1260 let obj = Object::Tree(Tree { entries });
1261 let bytes = serialize::serialize(&obj).unwrap();
1262 store.write(&bytes).unwrap()
1263 }
1264
1265 fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
1266 TreeEntry {
1267 name: name.to_vec(),
1268 mode,
1269 object_hash: h,
1270 }
1271 }
1272
1273 #[test]
1274 fn identical_trees_no_diff() {
1275 let (_d, s) = fresh_store();
1276 let blob = put_blob(&s, b"content");
1277 let tree = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob)]);
1278 let result = diff_trees(&s, Some(tree), Some(tree)).unwrap();
1279 assert_eq!(result.len(), 0);
1280 }
1281
1282 #[test]
1283 fn added_file_detected() {
1284 let (_d, s) = fresh_store();
1285 let blob_a = put_blob(&s, b"aaa");
1286 let blob_b = put_blob(&s, b"bbb");
1287 let old = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1288 let new = put_tree(
1289 &s,
1290 vec![
1291 entry(b"a.txt", EntryMode::Blob, blob_a),
1292 entry(b"b.txt", EntryMode::Blob, blob_b),
1293 ],
1294 );
1295 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1296 assert_eq!(r.entries.len(), 1);
1297 assert_eq!(r.entries[0].path, "b.txt");
1298 assert_eq!(r.entries[0].kind, DiffKind::Added);
1299 assert_eq!(r.entries[0].old_hash, None);
1300 assert_eq!(r.entries[0].new_hash, Some(blob_b));
1301 }
1302
1303 #[test]
1304 fn removed_file_detected() {
1305 let (_d, s) = fresh_store();
1306 let blob_a = put_blob(&s, b"aaa");
1307 let blob_b = put_blob(&s, b"bbb");
1308 let old = put_tree(
1309 &s,
1310 vec![
1311 entry(b"a.txt", EntryMode::Blob, blob_a),
1312 entry(b"b.txt", EntryMode::Blob, blob_b),
1313 ],
1314 );
1315 let new = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1316 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1317 assert_eq!(r.entries.len(), 1);
1318 assert_eq!(r.entries[0].path, "b.txt");
1319 assert_eq!(r.entries[0].kind, DiffKind::Removed);
1320 assert_eq!(r.entries[0].old_hash, Some(blob_b));
1321 assert_eq!(r.entries[0].new_hash, None);
1322 }
1323
1324 #[test]
1325 fn modified_file_detected() {
1326 let (_d, s) = fresh_store();
1327 let v1 = put_blob(&s, b"version 1");
1328 let v2 = put_blob(&s, b"version 2");
1329 let old = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v1)]);
1330 let new = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v2)]);
1331 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1332 assert_eq!(r.entries.len(), 1);
1333 assert_eq!(r.entries[0].path, "file.txt");
1334 assert_eq!(r.entries[0].kind, DiffKind::Modified);
1335 assert_eq!(r.entries[0].old_hash, Some(v1));
1336 assert_eq!(r.entries[0].new_hash, Some(v2));
1337 }
1338
1339 #[test]
1340 fn mode_change_detected() {
1341 let (_d, s) = fresh_store();
1342 let blob = put_blob(&s, b"content");
1343 let old = put_tree(&s, vec![entry(b"link", EntryMode::Blob, blob)]);
1344 let new = put_tree(&s, vec![entry(b"link", EntryMode::Symlink, blob)]);
1345 let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1346 assert_eq!(r.entries.len(), 1);
1347 assert_eq!(r.entries[0].path, "link");
1348 assert_eq!(r.entries[0].kind, DiffKind::ModeChanged);
1349 assert_eq!(r.entries[0].old_hash, Some(blob));
1350 assert_eq!(r.entries[0].new_hash, Some(blob));
1351 }
1352
1353 #[test]
1354 fn nested_tree_diff() {
1355 let (_d, s) = fresh_store();
1356 let v1 = put_blob(&s, b"old content");
1357 let v2 = put_blob(&s, b"new content");
1358 let other = put_blob(&s, b"unchanged");
1359 let old_sub = put_tree(
1360 &s,
1361 vec![
1362 entry(b"file.txt", EntryMode::Blob, v1),
1363 entry(b"other.txt", EntryMode::Blob, other),
1364 ],
1365 );
1366 let new_sub = put_tree(
1367 &s,
1368 vec![
1369 entry(b"file.txt", EntryMode::Blob, v2),
1370 entry(b"other.txt", EntryMode::Blob, other),
1371 ],
1372 );
1373 let old_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, old_sub)]);
1374 let new_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, new_sub)]);
1375 let r = diff_trees(&s, Some(old_root), Some(new_root)).unwrap();
1376 assert_eq!(r.entries.len(), 1);
1377 assert_eq!(r.entries[0].path, "subdir/file.txt");
1378 assert_eq!(r.entries[0].kind, DiffKind::Modified);
1379 }
1380
1381 #[test]
1382 fn diff_against_empty_tree() {
1383 let (_d, s) = fresh_store();
1384 let blob_a = put_blob(&s, b"aaa");
1385 let blob_b = put_blob(&s, b"bbb");
1386 let new = put_tree(
1387 &s,
1388 vec![
1389 entry(b"a.txt", EntryMode::Blob, blob_a),
1390 entry(b"b.txt", EntryMode::Blob, blob_b),
1391 ],
1392 );
1393 let r = diff_trees(&s, None, Some(new)).unwrap();
1394 assert_eq!(r.entries.len(), 2);
1395 assert_eq!(r.entries[0].path, "a.txt");
1396 assert_eq!(r.entries[0].kind, DiffKind::Added);
1397 assert_eq!(r.entries[1].path, "b.txt");
1398 assert_eq!(r.entries[1].kind, DiffKind::Added);
1399 }
1400
1401 #[test]
1402 fn empty_tree_against_non_empty() {
1403 let (_d, s) = fresh_store();
1404 let blob_a = put_blob(&s, b"aaa");
1405 let blob_b = put_blob(&s, b"bbb");
1406 let old = put_tree(
1407 &s,
1408 vec![
1409 entry(b"a.txt", EntryMode::Blob, blob_a),
1410 entry(b"b.txt", EntryMode::Blob, blob_b),
1411 ],
1412 );
1413 let r = diff_trees(&s, Some(old), None).unwrap();
1414 assert_eq!(r.entries.len(), 2);
1415 assert_eq!(r.entries[0].kind, DiffKind::Removed);
1416 assert_eq!(r.entries[1].kind, DiffKind::Removed);
1417 }
1418
1419 #[test]
1420 fn sorted_output() {
1421 let (_d, s) = fresh_store();
1422 let a = put_blob(&s, b"a");
1423 let b = put_blob(&s, b"b");
1424 let c = put_blob(&s, b"c");
1425 let new = put_tree(
1426 &s,
1427 vec![
1428 entry(b"a.txt", EntryMode::Blob, a),
1429 entry(b"m.txt", EntryMode::Blob, b),
1430 entry(b"z.txt", EntryMode::Blob, c),
1431 ],
1432 );
1433 let r = diff_trees(&s, None, Some(new)).unwrap();
1434 assert_eq!(r.entries.len(), 3);
1435 assert_eq!(r.entries[0].path, "a.txt");
1436 assert_eq!(r.entries[1].path, "m.txt");
1437 assert_eq!(r.entries[2].path, "z.txt");
1438 }
1439
1440 #[test]
1441 fn max_length_entry_names() {
1442 let (_d, s) = fresh_store();
1443 let blob = put_blob(&s, b"data");
1444 let long_name = vec![b'A'; 255];
1445 let new = put_tree(&s, vec![entry(&long_name, EntryMode::Blob, blob)]);
1446 let r = diff_trees(&s, None, Some(new)).unwrap();
1447 assert_eq!(r.entries.len(), 1);
1448 assert_eq!(r.entries[0].path.len(), 255);
1449 assert_eq!(r.entries[0].kind, DiffKind::Added);
1450 }
1451
1452 #[test]
1453 fn both_none_is_empty() {
1454 let (_d, s) = fresh_store();
1455 let r = diff_trees(&s, None, None).unwrap();
1456 assert!(r.is_empty());
1457 }
1458
1459 fn fresh_workdir() -> TempDir {
1464 TempDir::new().unwrap()
1465 }
1466
1467 #[test]
1468 fn status_diff_does_not_fsync() {
1469 use crate::batch::testing::{Ev, RecordingSyncer};
1474 use std::sync::Arc;
1475
1476 let (_sd, mut store) = fresh_store();
1477 let work = fresh_workdir();
1478 std::fs::write(work.path().join("a.txt"), b"some content").unwrap();
1479 std::fs::write(work.path().join("b.txt"), b"other content").unwrap();
1480
1481 let rec = Arc::new(RecordingSyncer::default());
1482 store.set_syncer(rec.clone());
1483
1484 let result = status_diff(&store, None, work.path(), None).unwrap();
1485 assert!(!result.is_empty(), "untracked files must surface");
1486
1487 let evs = rec.events();
1488 assert!(
1489 evs.iter().all(|e| matches!(e, Ev::Rename { .. })),
1490 "status must not emit any flush events; got {evs:?}"
1491 );
1492 }
1493
1494 #[test]
1495 fn status_empty_worktree_no_head() {
1496 let (_sd, store) = fresh_store();
1498 let work = fresh_workdir();
1499 let result = status_diff(&store, None, work.path(), None).unwrap();
1500 assert!(result.is_empty());
1501 }
1502
1503 #[test]
1504 fn status_worktree_equals_head_is_clean() {
1505 let (_sd, store) = fresh_store();
1507 let work = fresh_workdir();
1508 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1509 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1511 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1512 assert!(result.is_empty(), "expected clean, got {result:?}");
1513 }
1514
1515 #[cfg(not(unix))]
1516 #[test]
1517 fn status_no_index_ignores_unrepresentable_executable_mode_on_non_unix() {
1518 let (_sd, store) = fresh_store();
1519 let work = fresh_workdir();
1520 std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
1521 let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
1522 let head_hash = put_tree(&store, vec![entry(b"run.sh", EntryMode::Executable, h)]);
1523
1524 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1525 assert!(
1526 result.is_empty(),
1527 "non-Unix should not report executable-only noise, got {result:?}"
1528 );
1529 }
1530
1531 #[test]
1532 fn status_added_only() {
1533 let (_sd, store) = fresh_store();
1535 let work = fresh_workdir();
1536 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1537 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1538 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1539 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1540 assert_eq!(result.len(), 1);
1541 assert_eq!(result[0].diff.path, "b.txt");
1542 assert_eq!(result[0].diff.kind, DiffKind::Added);
1543 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1544 }
1545
1546 #[test]
1547 fn status_removed_only() {
1548 let (_sd, store) = fresh_store();
1550 let work = fresh_workdir();
1551 std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1552 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1553 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1554 std::fs::remove_file(work.path().join("b.txt")).unwrap();
1555 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1556 assert_eq!(result.len(), 1);
1557 assert_eq!(result[0].diff.path, "b.txt");
1558 assert_eq!(result[0].diff.kind, DiffKind::Removed);
1559 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1560 }
1561
1562 #[test]
1563 fn status_modified_only() {
1564 let (_sd, store) = fresh_store();
1566 let work = fresh_workdir();
1567 std::fs::write(work.path().join("a.txt"), b"old").unwrap();
1568 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1569 std::fs::write(work.path().join("a.txt"), b"new").unwrap();
1570 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1571 assert_eq!(result.len(), 1);
1572 assert_eq!(result[0].diff.path, "a.txt");
1573 assert_eq!(result[0].diff.kind, DiffKind::Modified);
1574 assert_eq!(result[0].staging, StatusStaging::Unstaged);
1575 }
1576
1577 #[test]
1578 fn status_mixed_changes() {
1579 let (_sd, store) = fresh_store();
1581 let work = fresh_workdir();
1582 std::fs::write(work.path().join("a.txt"), b"original").unwrap();
1583 std::fs::write(work.path().join("b.txt"), b"stays").unwrap();
1584 let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1585 std::fs::write(work.path().join("a.txt"), b"changed").unwrap();
1586 std::fs::remove_file(work.path().join("b.txt")).unwrap();
1587 std::fs::write(work.path().join("c.txt"), b"new").unwrap();
1588 let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1589 assert_eq!(result.len(), 3);
1590 let paths: Vec<&str> = result.iter().map(|e| e.diff.path.as_str()).collect();
1591 assert!(paths.contains(&"a.txt"), "missing a.txt: {paths:?}");
1592 assert!(paths.contains(&"b.txt"), "missing b.txt: {paths:?}");
1593 assert!(paths.contains(&"c.txt"), "missing c.txt: {paths:?}");
1594 }
1595
1596 #[test]
1597 fn status_no_head_shows_all_as_added() {
1598 let (_sd, store) = fresh_store();
1600 let work = fresh_workdir();
1601 std::fs::write(work.path().join("a.txt"), b"aaa").unwrap();
1602 std::fs::write(work.path().join("b.txt"), b"bbb").unwrap();
1603 let result = status_diff(&store, None, work.path(), None).unwrap();
1604 assert_eq!(result.len(), 2);
1605 for e in &result {
1606 assert_eq!(e.diff.kind, DiffKind::Added);
1607 assert_eq!(e.staging, StatusStaging::Unstaged);
1608 }
1609 }
1610
1611 #[test]
1616 fn status_staged_entry_is_classified_staged() {
1617 use crate::index::{EntryStatus, Index, IndexEntry};
1618 let (_sd, store) = fresh_store();
1619 let work = fresh_workdir();
1620 std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1621 let b_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
1622 let mut idx = Index::new();
1623 idx.entries.push(IndexEntry {
1624 path: "b.txt".to_string(),
1625 status: EntryStatus::Blob,
1626 object_hash: b_hash,
1627 mtime_ns: 0,
1628 size: 0,
1629 ino: 0,
1630 ctime_ns: 0,
1631 });
1632 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
1634 assert_eq!(result.len(), 1);
1635 assert_eq!(result[0].diff.path, "b.txt");
1636 assert_eq!(result[0].staging, StatusStaging::Staged);
1637 }
1638
1639 #[cfg(not(unix))]
1640 #[test]
1641 fn status_ignores_unrepresentable_executable_mode_on_non_unix_worktree() {
1642 use crate::index::{EntryStatus, Index, IndexEntry};
1643
1644 let (_sd, store) = fresh_store();
1645 let work = fresh_workdir();
1646 std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
1647 let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
1648 let mut idx = Index::new();
1649 idx.entries.push(IndexEntry {
1650 path: "run.sh".to_string(),
1651 status: EntryStatus::Executable,
1652 object_hash: h,
1653 mtime_ns: 0,
1654 size: 0,
1655 ino: 0,
1656 ctime_ns: 0,
1657 });
1658
1659 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
1660 assert_eq!(result.len(), 1, "expected only the staged addition");
1661 assert_eq!(result[0].staging, StatusStaging::Staged);
1662 }
1663
1664 #[test]
1669 fn text_patch_modified_line_emits_hunk() {
1670 let old = b"line1\nline2\nline3\n";
1671 let new = b"line1\nCHANGED\nline3\n";
1672 let patch = text_patch(old, new, "f.txt", "f.txt");
1673 assert!(patch.starts_with("--- a/f.txt\n+++ b/f.txt\n"), "{patch}");
1674 assert!(patch.contains("@@ -1,3 +1,3 @@"), "{patch}");
1675 assert!(patch.contains("-line2\n"), "{patch}");
1676 assert!(patch.contains("+CHANGED\n"), "{patch}");
1677 assert!(patch.contains(" line1\n"), "{patch}");
1678 assert!(patch.contains(" line3\n"), "{patch}");
1679 }
1680
1681 #[test]
1682 fn text_patch_pure_addition() {
1683 let old = b"a\nb\n";
1684 let new = b"a\nb\nc\n";
1685 let patch = text_patch(old, new, "f", "f");
1686 assert!(patch.contains("+c\n"), "{patch}");
1687 assert!(
1689 !patch
1690 .lines()
1691 .any(|l| l.starts_with('-') && !l.starts_with("---")),
1692 "should be no deletions: {patch}"
1693 );
1694 }
1695
1696 #[test]
1697 fn text_patch_identical_is_empty() {
1698 let patch = text_patch(b"same\n", b"same\n", "f", "f");
1699 assert!(patch.is_empty(), "{patch}");
1700 }
1701
1702 #[test]
1703 fn text_patch_binary_reports_differ() {
1704 let old = &[0x00, 0xff, 0x01][..];
1705 let new = &[0x00, 0xfe, 0x02][..];
1706 let patch = text_patch(old, new, "bin", "bin");
1707 assert_eq!(patch, "Binary files a/bin and b/bin differ\n");
1708 }
1709
1710 #[test]
1711 fn text_patch_nul_byte_is_binary_like_git() {
1712 let patch = text_patch(b"a\0b\n", b"a\0c\n", "f", "f");
1715 assert_eq!(patch, "Binary files a/f and b/f differ\n");
1716 }
1717
1718 #[test]
1719 fn unified_hunks_non_utf8_without_nul_is_text_like_git() {
1720 let hunks = unified_hunks(b"\xff\n", b"\xfe\n").expect("not binary");
1724 assert_eq!(
1725 hunks,
1726 b"@@ -1 +1 @@\n-\xff\n+\xfe\n".to_vec(),
1727 "got {hunks:?}"
1728 );
1729 }
1730
1731 #[test]
1732 fn hunk_header_omits_count_one_like_git() {
1733 let patch = text_patch(b"old\n", b"new\n", "f", "f");
1735 assert_eq!(
1736 patch, "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n",
1737 "{patch}"
1738 );
1739 }
1740
1741 #[test]
1742 fn text_patch_no_trailing_newline_marker() {
1743 let old = b"x\ny";
1744 let new = b"x\nz";
1745 let patch = text_patch(old, new, "f", "f");
1746 assert!(patch.contains("\\ No newline at end of file\n"), "{patch}");
1747 }
1748
1749 #[test]
1750 fn text_patch_separate_hunks_for_distant_changes() {
1751 let old = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
1752 let new = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
1754 let patch = text_patch(old, new, "f", "f");
1755 let hunk_count = patch.matches("@@ ").count();
1756 assert_eq!(hunk_count, 2, "expected two hunks: {patch}");
1757 }
1758
1759 #[test]
1760 fn text_patch_inserts_compact_to_git_position() {
1761 let patch = text_patch(b"a\na\nb\n", b"a\na\na\nb\n", "f", "f");
1766 assert_eq!(
1767 patch, "--- a/f\n+++ b/f\n@@ -1,3 +1,4 @@\n a\n a\n+a\n b\n",
1768 "{patch}"
1769 );
1770 }
1771
1772 #[test]
1773 fn text_patch_deletes_compact_to_git_position() {
1774 let patch = text_patch(b"a\na\na\nb\n", b"a\na\nb\n", "f", "f");
1777 assert_eq!(
1778 patch, "--- a/f\n+++ b/f\n@@ -1,4 +1,3 @@\n a\n a\n-a\n b\n",
1779 "{patch}"
1780 );
1781 }
1782
1783 #[test]
1790 fn status_partially_staged_entry_emits_both_legs() {
1791 use crate::index::{EntryStatus, Index, IndexEntry};
1792 let (_sd, store) = fresh_store();
1793 let work = fresh_workdir();
1794 std::fs::write(work.path().join("b.txt"), b"v1").unwrap();
1796 let b_v1_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
1797 std::fs::write(work.path().join("b.txt"), b"v2").unwrap();
1798 let mut idx = Index::new();
1799 idx.entries.push(IndexEntry {
1800 path: "b.txt".to_string(),
1801 status: EntryStatus::Blob,
1802 object_hash: b_v1_hash,
1803 mtime_ns: 0,
1804 size: 0,
1805 ino: 0,
1806 ctime_ns: 0,
1807 });
1808 let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
1809 assert_eq!(result.len(), 2, "expected staged + unstaged entries");
1810 let stagings: Vec<_> = result.iter().map(|e| e.staging).collect();
1811 assert!(stagings.contains(&StatusStaging::Staged));
1812 assert!(stagings.contains(&StatusStaging::Unstaged));
1813 assert!(result.iter().all(|e| e.diff.path == "b.txt"));
1814 }
1815
1816 #[test]
1819 fn enumerate_hunks_binary_returns_none() {
1820 assert!(enumerate_hunks(b"a\0b", b"c").is_none());
1821 assert!(enumerate_hunks(b"text\n", b"with\0nul").is_none());
1822 }
1823
1824 #[test]
1825 fn enumerate_hunks_identical_is_empty() {
1826 assert_eq!(enumerate_hunks(b"a\nb\n", b"a\nb\n"), Some(vec![]));
1827 }
1828
1829 const HUNK2_OLD: &[u8] = b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n";
1832 const HUNK2_NEW: &[u8] = b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n";
1833
1834 #[test]
1835 fn apply_all_hunks_reproduces_new_apply_none_reproduces_base() {
1836 let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
1837 assert_eq!(hunks.len(), 2, "expected two distinct hunks");
1838 let all: Vec<usize> = (0..hunks.len()).collect();
1839 assert_eq!(
1840 apply_hunks_subset(HUNK2_OLD, &hunks, &all),
1841 HUNK2_NEW,
1842 "apply all == new"
1843 );
1844 assert_eq!(
1845 apply_hunks_subset(HUNK2_OLD, &hunks, &[]),
1846 HUNK2_OLD,
1847 "apply none == old"
1848 );
1849 }
1850
1851 #[test]
1852 fn apply_single_hunk_picks_only_that_region() {
1853 let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
1854 let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[0]);
1856 assert_eq!(
1857 staged, b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n",
1858 "only the first hunk applied"
1859 );
1860 let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[1]);
1862 assert_eq!(
1863 staged, b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n",
1864 "only the second hunk applied"
1865 );
1866 }
1867
1868 #[test]
1869 fn apply_hunks_into_empty_base_adds_lines() {
1870 let old = b"";
1871 let new = b"alpha\nbeta\n";
1872 let hunks = enumerate_hunks(old, new).unwrap();
1873 assert_eq!(apply_hunks_subset(old, &hunks, &[0]), new);
1874 assert_eq!(apply_hunks_subset(old, &hunks, &[]), old);
1875 }
1876
1877 #[test]
1878 fn apply_hunks_preserves_missing_eof_newline() {
1879 let old = b"a\nb\nc";
1880 let new = b"a\nB\nc"; let hunks = enumerate_hunks(old, new).unwrap();
1882 let staged = apply_hunks_subset(old, &hunks, &[0]);
1883 assert_eq!(staged, new, "no trailing newline must be preserved");
1884 }
1885
1886 #[test]
1889 fn merge3_identical_sides_merge_trivially() {
1890 let x = b"a\nb\n";
1891 assert_eq!(merge_blob_3way(b"base\n", x, x), Some(x.to_vec()));
1892 }
1893
1894 #[test]
1895 fn merge3_disjoint_line_edits_auto_merge() {
1896 let base = b"a\nb\nc\nd\ne\n";
1897 let ours = b"A\nb\nc\nd\ne\n"; let theirs = b"a\nb\nc\nd\nE\n"; assert_eq!(
1900 merge_blob_3way(base, ours, theirs),
1901 Some(b"A\nb\nc\nd\nE\n".to_vec())
1902 );
1903 }
1904
1905 #[test]
1906 fn merge3_adjacent_line_edits_auto_merge() {
1907 let base = b"x\ny\n";
1910 let ours = b"X\ny\n"; let theirs = b"x\nY\n"; assert_eq!(
1913 merge_blob_3way(base, ours, theirs),
1914 Some(b"X\nY\n".to_vec())
1915 );
1916 }
1917
1918 #[test]
1919 fn merge3_overlapping_line_edits_conflict() {
1920 let base = b"a\nb\nc\n";
1921 let ours = b"A\nb\nc\n"; let theirs = b"Z\nb\nc\n"; assert_eq!(merge_blob_3way(base, ours, theirs), None);
1924 }
1925
1926 #[test]
1927 fn merge3_one_side_only_takes_that_side() {
1928 let base = b"a\nb\nc\n";
1930 let ours = b"a\nB\nc\n";
1931 assert_eq!(merge_blob_3way(base, ours, base), Some(ours.to_vec()));
1932 }
1933
1934 #[test]
1935 fn merge3_disjoint_insertions_auto_merge() {
1936 let base = b"a\nb\n";
1937 let ours = b"a\nX\nb\n"; let theirs = b"a\nb\nY\n"; assert_eq!(
1940 merge_blob_3way(base, ours, theirs),
1941 Some(b"a\nX\nb\nY\n".to_vec())
1942 );
1943 }
1944
1945 #[test]
1946 fn merge3_coincident_insertions_conflict() {
1947 let base = b"a\nb\n";
1948 let ours = b"a\nX\nb\n"; let theirs = b"a\nY\nb\n";
1950 assert_eq!(merge_blob_3way(base, ours, theirs), None);
1951 }
1952
1953 #[test]
1954 fn merge3_binary_side_conflicts() {
1955 assert_eq!(merge_blob_3way(b"a\nb\n", b"a\0\nb\n", b"a\nb\nc\n"), None);
1956 }
1957}