1use std::ops::ControlFlow;
8
9use super::FileChangeSet;
10#[cfg(feature = "async-source")]
11use crate::store::AsyncObjectSource;
12use crate::{
13 object::{DiffKind, FileChange, Tree, TreeEntry},
14 store::ObjectSource,
15};
16
17pub fn diff_trees<S: ObjectSource + ?Sized>(
24 store: &S,
25 from: &crate::object::ContentHash,
26 to: &crate::object::ContentHash,
27) -> Result<FileChangeSet, anyhow::Error> {
28 let mut changes = FileChangeSet::new();
29 let _ = diff_trees_visit(store, from, to, |change| {
32 changes.push(change);
33 ControlFlow::<()>::Continue(())
34 })?;
35 Ok(changes)
36}
37
38pub fn diff_trees_visit<S, V, B>(
53 store: &S,
54 from: &crate::object::ContentHash,
55 to: &crate::object::ContentHash,
56 mut visitor: V,
57) -> Result<ControlFlow<B>, anyhow::Error>
58where
59 S: ObjectSource + ?Sized,
60 V: FnMut(FileChange) -> ControlFlow<B>,
61{
62 if from == to {
63 return Ok(ControlFlow::Continue(()));
64 }
65 let from_tree = store.get_tree(from)?;
66 let to_tree = store.get_tree(to)?;
67 diff_trees_recursive(store, &from_tree, &to_tree, "", &mut visitor)
68}
69
70fn diff_trees_recursive<S, V, B>(
76 store: &S,
77 from: &Option<Tree>,
78 to: &Option<Tree>,
79 prefix: &str,
80 visitor: &mut V,
81) -> Result<ControlFlow<B>, anyhow::Error>
82where
83 S: ObjectSource + ?Sized,
84 V: FnMut(FileChange) -> ControlFlow<B>,
85{
86 let from_entries = from.as_ref().map_or(&[][..], Tree::entries);
87 let to_entries = to.as_ref().map_or(&[][..], Tree::entries);
88
89 let mut from_index = 0;
90 let mut to_index = 0;
91
92 while let (Some(from_entry), Some(to_entry)) =
93 (from_entries.get(from_index), to_entries.get(to_index))
94 {
95 match from_entry.name().cmp(to_entry.name()) {
96 std::cmp::Ordering::Less => {
97 if let ControlFlow::Break(b) =
98 visit_deleted_entry(store, prefix, from_entry, visitor)?
99 {
100 return Ok(ControlFlow::Break(b));
101 }
102 from_index += 1;
103 }
104 std::cmp::Ordering::Greater => {
105 if let ControlFlow::Break(b) = visit_added_entry(store, prefix, to_entry, visitor)?
106 {
107 return Ok(ControlFlow::Break(b));
108 }
109 to_index += 1;
110 }
111 std::cmp::Ordering::Equal => {
112 if from_entry.target() != to_entry.target() {
113 if let (Some(from_hash), Some(to_hash)) =
114 (from_entry.tree_hash(), to_entry.tree_hash())
115 {
116 let from_subtree = store.get_tree(&from_hash)?;
117 let to_subtree = store.get_tree(&to_hash)?;
118 let path = child_path(prefix, to_entry.name());
119 if let ControlFlow::Break(b) =
120 diff_trees_recursive(store, &from_subtree, &to_subtree, &path, visitor)?
121 {
122 return Ok(ControlFlow::Break(b));
123 }
124 } else {
125 let path = child_path(prefix, to_entry.name());
126 if let ControlFlow::Break(b) =
127 visitor(FileChange::new(path, DiffKind::Modified))
128 {
129 return Ok(ControlFlow::Break(b));
130 }
131 }
132 }
133 from_index += 1;
134 to_index += 1;
135 }
136 }
137 }
138
139 for from_entry in &from_entries[from_index..] {
140 if let ControlFlow::Break(b) = visit_deleted_entry(store, prefix, from_entry, visitor)? {
141 return Ok(ControlFlow::Break(b));
142 }
143 }
144
145 for to_entry in &to_entries[to_index..] {
146 if let ControlFlow::Break(b) = visit_added_entry(store, prefix, to_entry, visitor)? {
147 return Ok(ControlFlow::Break(b));
148 }
149 }
150
151 Ok(ControlFlow::Continue(()))
152}
153
154fn visit_added_entry<S, V, B>(
155 store: &S,
156 prefix: &str,
157 to_entry: &TreeEntry,
158 visitor: &mut V,
159) -> Result<ControlFlow<B>, anyhow::Error>
160where
161 S: ObjectSource + ?Sized,
162 V: FnMut(FileChange) -> ControlFlow<B>,
163{
164 let path = child_path(prefix, to_entry.name());
167 if let Some(tree_hash) = to_entry.tree_hash() {
168 let to_subtree = store.get_tree(&tree_hash)?;
169 diff_trees_recursive(store, &None, &to_subtree, &path, visitor)
170 } else {
171 Ok(visitor(FileChange::new(path, DiffKind::Added)))
172 }
173}
174
175fn visit_deleted_entry<S, V, B>(
176 store: &S,
177 prefix: &str,
178 from_entry: &TreeEntry,
179 visitor: &mut V,
180) -> Result<ControlFlow<B>, anyhow::Error>
181where
182 S: ObjectSource + ?Sized,
183 V: FnMut(FileChange) -> ControlFlow<B>,
184{
185 let path = child_path(prefix, from_entry.name());
186 if let Some(tree_hash) = from_entry.tree_hash() {
187 let from_subtree = store.get_tree(&tree_hash)?;
188 diff_trees_recursive(store, &from_subtree, &None, &path, visitor)
189 } else {
190 Ok(visitor(FileChange::new(path, DiffKind::Deleted)))
191 }
192}
193
194#[cfg(feature = "async-source")]
195pub async fn diff_trees_visit_async<S, V, B>(
196 store: &S,
197 from: &crate::object::ContentHash,
198 to: &crate::object::ContentHash,
199 mut visitor: V,
200) -> Result<ControlFlow<B>, anyhow::Error>
201where
202 S: AsyncObjectSource + Sync + ?Sized,
203 V: FnMut(FileChange) -> ControlFlow<B> + Send,
204 B: Send,
205{
206 if from == to {
207 return Ok(ControlFlow::Continue(()));
208 }
209 let from_tree = store.get_tree(from).await?;
210 let to_tree = store.get_tree(to).await?;
211 diff_trees_recursive_async(store, &from_tree, &to_tree, "", &mut visitor).await
212}
213
214#[cfg(feature = "async-source")]
215async fn diff_trees_recursive_async<S, V, B>(
216 store: &S,
217 from: &Option<Tree>,
218 to: &Option<Tree>,
219 prefix: &str,
220 visitor: &mut V,
221) -> Result<ControlFlow<B>, anyhow::Error>
222where
223 S: AsyncObjectSource + Sync + ?Sized,
224 V: FnMut(FileChange) -> ControlFlow<B> + Send,
225 B: Send,
226{
227 let from_entries = from.as_ref().map_or(&[][..], Tree::entries);
228 let to_entries = to.as_ref().map_or(&[][..], Tree::entries);
229
230 let mut from_index = 0;
231 let mut to_index = 0;
232
233 while let (Some(from_entry), Some(to_entry)) =
234 (from_entries.get(from_index), to_entries.get(to_index))
235 {
236 match from_entry.name().cmp(to_entry.name()) {
237 std::cmp::Ordering::Less => {
238 if let ControlFlow::Break(b) =
239 visit_deleted_entry_async(store, prefix, from_entry, visitor).await?
240 {
241 return Ok(ControlFlow::Break(b));
242 }
243 from_index += 1;
244 }
245 std::cmp::Ordering::Greater => {
246 if let ControlFlow::Break(b) =
247 visit_added_entry_async(store, prefix, to_entry, visitor).await?
248 {
249 return Ok(ControlFlow::Break(b));
250 }
251 to_index += 1;
252 }
253 std::cmp::Ordering::Equal => {
254 if from_entry.target() != to_entry.target() {
255 if let (Some(from_hash), Some(to_hash)) =
256 (from_entry.tree_hash(), to_entry.tree_hash())
257 {
258 let from_subtree = store.get_tree(&from_hash).await?;
259 let to_subtree = store.get_tree(&to_hash).await?;
260 let path = child_path(prefix, to_entry.name());
261 if let ControlFlow::Break(b) = Box::pin(diff_trees_recursive_async(
262 store,
263 &from_subtree,
264 &to_subtree,
265 &path,
266 visitor,
267 ))
268 .await?
269 {
270 return Ok(ControlFlow::Break(b));
271 }
272 } else {
273 let path = child_path(prefix, to_entry.name());
274 if let ControlFlow::Break(b) =
275 visitor(FileChange::new(path, DiffKind::Modified))
276 {
277 return Ok(ControlFlow::Break(b));
278 }
279 }
280 }
281 from_index += 1;
282 to_index += 1;
283 }
284 }
285 }
286
287 for from_entry in &from_entries[from_index..] {
288 if let ControlFlow::Break(b) =
289 visit_deleted_entry_async(store, prefix, from_entry, visitor).await?
290 {
291 return Ok(ControlFlow::Break(b));
292 }
293 }
294
295 for to_entry in &to_entries[to_index..] {
296 if let ControlFlow::Break(b) =
297 visit_added_entry_async(store, prefix, to_entry, visitor).await?
298 {
299 return Ok(ControlFlow::Break(b));
300 }
301 }
302
303 Ok(ControlFlow::Continue(()))
304}
305
306#[cfg(feature = "async-source")]
307async fn visit_added_entry_async<S, V, B>(
308 store: &S,
309 prefix: &str,
310 to_entry: &TreeEntry,
311 visitor: &mut V,
312) -> Result<ControlFlow<B>, anyhow::Error>
313where
314 S: AsyncObjectSource + Sync + ?Sized,
315 V: FnMut(FileChange) -> ControlFlow<B> + Send,
316 B: Send,
317{
318 let path = child_path(prefix, to_entry.name());
319 if let Some(tree_hash) = to_entry.tree_hash() {
320 let to_subtree = store.get_tree(&tree_hash).await?;
321 Box::pin(diff_trees_recursive_async(
322 store,
323 &None,
324 &to_subtree,
325 &path,
326 visitor,
327 ))
328 .await
329 } else {
330 Ok(visitor(FileChange::new(path, DiffKind::Added)))
331 }
332}
333
334#[cfg(feature = "async-source")]
335async fn visit_deleted_entry_async<S, V, B>(
336 store: &S,
337 prefix: &str,
338 from_entry: &TreeEntry,
339 visitor: &mut V,
340) -> Result<ControlFlow<B>, anyhow::Error>
341where
342 S: AsyncObjectSource + Sync + ?Sized,
343 V: FnMut(FileChange) -> ControlFlow<B> + Send,
344 B: Send,
345{
346 let path = child_path(prefix, from_entry.name());
347 if let Some(tree_hash) = from_entry.tree_hash() {
348 let from_subtree = store.get_tree(&tree_hash).await?;
349 Box::pin(diff_trees_recursive_async(
350 store,
351 &from_subtree,
352 &None,
353 &path,
354 visitor,
355 ))
356 .await
357 } else {
358 Ok(visitor(FileChange::new(path, DiffKind::Deleted)))
359 }
360}
361
362fn child_path(prefix: &str, name: &str) -> String {
363 if prefix.is_empty() {
364 name.to_owned()
365 } else {
366 let mut path = String::with_capacity(prefix.len() + 1 + name.len());
367 path.push_str(prefix);
368 path.push('/');
369 path.push_str(name);
370 path
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::{
378 object::{Blob, ContentHash, EntryType, Tree, TreeEntry},
379 store::{InMemoryStore, ObjectStore},
380 };
381
382 fn create_blob(store: &InMemoryStore, content: &str) -> ContentHash {
383 let blob = Blob::from_slice(content.as_bytes());
384 store.put_blob(&blob).unwrap()
385 }
386
387 fn create_tree(
388 store: &InMemoryStore,
389 entries: Vec<(&str, ContentHash, EntryType)>,
390 ) -> ContentHash {
391 let tree_entries: Vec<TreeEntry> = entries
392 .into_iter()
393 .map(|(name, hash, entry_type)| match entry_type {
394 EntryType::Blob => TreeEntry::file(name, hash, false).unwrap(),
395 EntryType::Tree => TreeEntry::directory(name, hash).unwrap(),
396 EntryType::Symlink => TreeEntry::symlink(name, hash).unwrap(),
397 EntryType::Gitlink => panic!("use TreeEntry::gitlink for gitlink tests"),
398 })
399 .collect();
400 let tree = Tree::from_entries(tree_entries);
401 store.put_tree(&tree).unwrap()
402 }
403
404 #[test]
405 fn test_diff_identical_trees() {
406 let store = InMemoryStore::new();
407 let hash = create_tree(
408 &store,
409 vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
410 );
411 let changes = diff_trees(&store, &hash, &hash).unwrap();
412 assert!(changes.is_empty());
413 }
414
415 #[test]
416 fn test_diff_added_file() {
417 let store = InMemoryStore::new();
418 let from_hash = create_tree(&store, vec![]);
419 let to_hash = create_tree(
420 &store,
421 vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
422 );
423 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
424
425 assert_eq!(changes.len(), 1);
426 assert_eq!(changes.added_count(), 1);
427
428 let added: Vec<_> = changes.added().collect();
429 assert_eq!(added[0].path, "a.txt");
430 }
431
432 #[test]
433 fn test_diff_deleted_file() {
434 let store = InMemoryStore::new();
435 let blob_hash = create_blob(&store, "content");
436 let from_hash = create_tree(&store, vec![("a.txt", blob_hash, EntryType::Blob)]);
437 let to_hash = create_tree(&store, vec![]);
438 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
439
440 assert_eq!(changes.len(), 1);
441 assert_eq!(changes.deleted_count(), 1);
442
443 let deleted: Vec<_> = changes.deleted().collect();
444 assert_eq!(deleted[0].path, "a.txt");
445 }
446
447 #[test]
448 fn test_diff_modified_file() {
449 let store = InMemoryStore::new();
450 let blob1_hash = create_blob(&store, "original");
451 let blob2_hash = create_blob(&store, "modified");
452 let from_hash = create_tree(&store, vec![("a.txt", blob1_hash, EntryType::Blob)]);
453 let to_hash = create_tree(&store, vec![("a.txt", blob2_hash, EntryType::Blob)]);
454 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
455
456 assert_eq!(changes.len(), 1);
457 assert_eq!(changes.modified_count(), 1);
458
459 let modified: Vec<_> = changes.modified().collect();
460 assert_eq!(modified[0].path, "a.txt");
461 }
462
463 #[test]
464 fn test_diff_nested_directories() {
465 let store = InMemoryStore::new();
466 let sub_blob = create_blob(&store, "sub content");
467 let sub_tree = Tree::from_entries(vec![
468 TreeEntry::file("nested.txt", sub_blob, false).unwrap(),
469 ]);
470 let sub_hash = store.put_tree(&sub_tree).unwrap();
471
472 let from_hash = create_tree(&store, vec![("subdir", sub_hash, EntryType::Tree)]);
473 let to_hash = create_tree(&store, vec![]);
474 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
475
476 assert_eq!(changes.len(), 1);
477 assert_eq!(changes.deleted_count(), 1);
478
479 let deleted: Vec<_> = changes.deleted().collect();
480 assert_eq!(deleted[0].path, "subdir/nested.txt");
481 }
482
483 #[test]
484 fn test_diff_added_directory_recurses() {
485 let store = InMemoryStore::new();
493 let sub_blob = create_blob(&store, "sub content");
494 let sub_tree = Tree::from_entries(vec![
495 TreeEntry::file("nested.txt", sub_blob, false).unwrap(),
496 ]);
497 let sub_hash = store.put_tree(&sub_tree).unwrap();
498
499 let from_hash = create_tree(&store, vec![]);
500 let to_hash = create_tree(&store, vec![("subdir", sub_hash, EntryType::Tree)]);
501 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
502
503 assert_eq!(changes.len(), 1);
504 assert_eq!(changes.added_count(), 1);
505
506 let added: Vec<_> = changes.added().collect();
507 assert_eq!(added[0].path, "subdir/nested.txt");
508 }
509
510 #[test]
511 fn test_diff_added_directory_deep_nesting() {
512 let store = InMemoryStore::new();
516 let leaf_blob = create_blob(&store, "leaf");
517 let c_tree = Tree::from_entries(vec![TreeEntry::file("c.txt", leaf_blob, false).unwrap()]);
518 let c_hash = store.put_tree(&c_tree).unwrap();
519 let b_tree = Tree::from_entries(vec![TreeEntry::directory("b", c_hash).unwrap()]);
520 let b_hash = store.put_tree(&b_tree).unwrap();
521 let from_hash = create_tree(&store, vec![]);
522 let to_hash = create_tree(&store, vec![("a", b_hash, EntryType::Tree)]);
523
524 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
525 assert_eq!(changes.added_count(), 1);
526 let added: Vec<_> = changes.added().collect();
527 assert_eq!(added[0].path, "a/b/c.txt");
528 }
529
530 #[test]
531 fn test_diff_changes_follow_sorted_tree_entry_order() {
532 let store = InMemoryStore::new();
533 let from_sub_blob = create_blob(&store, "old nested");
534 let from_sub_tree = Tree::from_entries(vec![
535 TreeEntry::file("c.txt", from_sub_blob, false).unwrap(),
536 ]);
537 let from_sub_hash = store.put_tree(&from_sub_tree).unwrap();
538 let to_sub_blob = create_blob(&store, "new nested");
539 let to_sub_tree =
540 Tree::from_entries(vec![TreeEntry::file("b.txt", to_sub_blob, false).unwrap()]);
541 let to_sub_hash = store.put_tree(&to_sub_tree).unwrap();
542
543 let from_hash = create_tree(
544 &store,
545 vec![
546 ("z.txt", create_blob(&store, "old z"), EntryType::Blob),
547 ("dir", from_sub_hash, EntryType::Tree),
548 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
549 ("a.txt", create_blob(&store, "old a"), EntryType::Blob),
550 ],
551 );
552 let to_hash = create_tree(
553 &store,
554 vec![
555 ("b.txt", create_blob(&store, "new b"), EntryType::Blob),
556 ("dir", to_sub_hash, EntryType::Tree),
557 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
558 ("z.txt", create_blob(&store, "new z"), EntryType::Blob),
559 ],
560 );
561
562 let changes: Vec<_> = diff_trees(&store, &from_hash, &to_hash)
563 .unwrap()
564 .into_iter()
565 .map(|change| (change.path, change.kind))
566 .collect();
567
568 assert_eq!(
569 changes,
570 vec![
571 ("a.txt".to_string(), crate::object::DiffKind::Deleted),
572 ("b.txt".to_string(), crate::object::DiffKind::Added),
573 ("dir/b.txt".to_string(), crate::object::DiffKind::Added),
574 ("dir/c.txt".to_string(), crate::object::DiffKind::Deleted),
575 ("z.txt".to_string(), crate::object::DiffKind::Modified),
576 ]
577 );
578 }
579
580 #[test]
585 fn test_visit_matches_collect_order() {
586 let store = InMemoryStore::new();
587 let from_sub_blob = create_blob(&store, "old nested");
588 let from_sub_tree = Tree::from_entries(vec![
589 TreeEntry::file("c.txt", from_sub_blob, false).unwrap(),
590 ]);
591 let from_sub_hash = store.put_tree(&from_sub_tree).unwrap();
592 let to_sub_blob = create_blob(&store, "new nested");
593 let to_sub_tree =
594 Tree::from_entries(vec![TreeEntry::file("b.txt", to_sub_blob, false).unwrap()]);
595 let to_sub_hash = store.put_tree(&to_sub_tree).unwrap();
596
597 let from_hash = create_tree(
598 &store,
599 vec![
600 ("z.txt", create_blob(&store, "old z"), EntryType::Blob),
601 ("dir", from_sub_hash, EntryType::Tree),
602 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
603 ("a.txt", create_blob(&store, "old a"), EntryType::Blob),
604 ],
605 );
606 let to_hash = create_tree(
607 &store,
608 vec![
609 ("b.txt", create_blob(&store, "new b"), EntryType::Blob),
610 ("dir", to_sub_hash, EntryType::Tree),
611 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
612 ("z.txt", create_blob(&store, "new z"), EntryType::Blob),
613 ],
614 );
615
616 let collected: Vec<_> = diff_trees(&store, &from_hash, &to_hash)
617 .unwrap()
618 .into_iter()
619 .map(FileChange::into_tuple)
620 .collect();
621
622 let mut visited = Vec::new();
623 let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
624 visited.push(change.into_tuple());
625 ControlFlow::<()>::Continue(())
626 })
627 .unwrap();
628
629 assert!(flow.is_continue());
630 assert_eq!(visited, collected);
631 }
632
633 #[test]
634 fn test_visit_identical_trees_never_calls_visitor() {
635 let store = InMemoryStore::new();
636 let hash = create_tree(
637 &store,
638 vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
639 );
640 let mut count = 0usize;
641 let flow = diff_trees_visit(&store, &hash, &hash, |_change| {
642 count += 1;
643 ControlFlow::<()>::Continue(())
644 })
645 .unwrap();
646 assert!(flow.is_continue());
647 assert_eq!(count, 0);
648 }
649
650 #[test]
654 fn test_visit_early_exit_stops_walk() {
655 let store = InMemoryStore::new();
656 let from_hash = create_tree(&store, vec![]);
659 let to_hash = create_tree(
660 &store,
661 vec![
662 ("a.txt", create_blob(&store, "a"), EntryType::Blob),
663 ("b.txt", create_blob(&store, "b"), EntryType::Blob),
664 ("c.txt", create_blob(&store, "c"), EntryType::Blob),
665 ("d.txt", create_blob(&store, "d"), EntryType::Blob),
666 ("e.txt", create_blob(&store, "e"), EntryType::Blob),
667 ],
668 );
669
670 let mut seen = Vec::new();
671 let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
672 seen.push(change.path.clone());
673 if change.path == "c.txt" {
674 ControlFlow::Break("found c")
675 } else {
676 ControlFlow::Continue(())
677 }
678 })
679 .unwrap();
680
681 assert_eq!(flow, ControlFlow::Break("found c"));
682 assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]);
684 }
685
686 #[test]
689 fn test_visit_early_exit_inside_subtree() {
690 let store = InMemoryStore::new();
691 let sub_tree = Tree::from_entries(vec![
692 TreeEntry::file("x.txt", create_blob(&store, "x"), false).unwrap(),
693 TreeEntry::file("y.txt", create_blob(&store, "y"), false).unwrap(),
694 ]);
695 let sub_hash = store.put_tree(&sub_tree).unwrap();
696 let from_hash = create_tree(&store, vec![]);
697 let to_hash = create_tree(
698 &store,
699 vec![
700 ("dir", sub_hash, EntryType::Tree),
701 ("z.txt", create_blob(&store, "z"), EntryType::Blob),
702 ],
703 );
704
705 let mut seen = Vec::new();
706 let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
707 seen.push(change.path.clone());
708 ControlFlow::Break(())
709 })
710 .unwrap();
711
712 assert_eq!(flow, ControlFlow::Break(()));
713 assert_eq!(seen, vec!["dir/x.txt"]);
716 }
717}