1use std::ops::ControlFlow;
8
9use super::FileChangeSet;
10#[cfg(feature = "async-source")]
11use crate::store::AsyncObjectSource;
12use crate::{
13 object::{DiffKind, EntryType, 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.hash != to_entry.hash {
113 if from_entry.entry_type == EntryType::Tree
114 && to_entry.entry_type == EntryType::Tree
115 {
116 let from_subtree = store.get_tree(&from_entry.hash)?;
117 let to_subtree = store.get_tree(&to_entry.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 to_entry.entry_type == EntryType::Tree {
168 let to_subtree = store.get_tree(&to_entry.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 from_entry.entry_type == EntryType::Tree {
187 let from_subtree = store.get_tree(&from_entry.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.hash != to_entry.hash {
255 if from_entry.entry_type == EntryType::Tree
256 && to_entry.entry_type == EntryType::Tree
257 {
258 let from_subtree = store.get_tree(&from_entry.hash).await?;
259 let to_subtree = store.get_tree(&to_entry.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 to_entry.entry_type == EntryType::Tree {
320 let to_subtree = store.get_tree(&to_entry.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 from_entry.entry_type == EntryType::Tree {
348 let from_subtree = store.get_tree(&from_entry.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, FileMode, 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)| TreeEntry {
394 name: name.to_string(),
395 mode: FileMode::Normal,
396 hash,
397 entry_type,
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![TreeEntry {
468 name: "nested.txt".to_string(),
469 mode: FileMode::Normal,
470 hash: sub_blob,
471 entry_type: EntryType::Blob,
472 }]);
473 let sub_hash = store.put_tree(&sub_tree).unwrap();
474
475 let from_hash = create_tree(&store, vec![("subdir", sub_hash, EntryType::Tree)]);
476 let to_hash = create_tree(&store, vec![]);
477 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
478
479 assert_eq!(changes.len(), 1);
480 assert_eq!(changes.deleted_count(), 1);
481
482 let deleted: Vec<_> = changes.deleted().collect();
483 assert_eq!(deleted[0].path, "subdir/nested.txt");
484 }
485
486 #[test]
487 fn test_diff_added_directory_recurses() {
488 let store = InMemoryStore::new();
496 let sub_blob = create_blob(&store, "sub content");
497 let sub_tree = Tree::from_entries(vec![TreeEntry {
498 name: "nested.txt".to_string(),
499 mode: FileMode::Normal,
500 hash: sub_blob,
501 entry_type: EntryType::Blob,
502 }]);
503 let sub_hash = store.put_tree(&sub_tree).unwrap();
504
505 let from_hash = create_tree(&store, vec![]);
506 let to_hash = create_tree(&store, vec![("subdir", sub_hash, EntryType::Tree)]);
507 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
508
509 assert_eq!(changes.len(), 1);
510 assert_eq!(changes.added_count(), 1);
511
512 let added: Vec<_> = changes.added().collect();
513 assert_eq!(added[0].path, "subdir/nested.txt");
514 }
515
516 #[test]
517 fn test_diff_added_directory_deep_nesting() {
518 let store = InMemoryStore::new();
522 let leaf_blob = create_blob(&store, "leaf");
523 let c_tree = Tree::from_entries(vec![TreeEntry {
524 name: "c.txt".to_string(),
525 mode: FileMode::Normal,
526 hash: leaf_blob,
527 entry_type: EntryType::Blob,
528 }]);
529 let c_hash = store.put_tree(&c_tree).unwrap();
530 let b_tree = Tree::from_entries(vec![TreeEntry {
531 name: "b".to_string(),
532 mode: FileMode::Normal,
533 hash: c_hash,
534 entry_type: EntryType::Tree,
535 }]);
536 let b_hash = store.put_tree(&b_tree).unwrap();
537 let from_hash = create_tree(&store, vec![]);
538 let to_hash = create_tree(&store, vec![("a", b_hash, EntryType::Tree)]);
539
540 let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
541 assert_eq!(changes.added_count(), 1);
542 let added: Vec<_> = changes.added().collect();
543 assert_eq!(added[0].path, "a/b/c.txt");
544 }
545
546 #[test]
547 fn test_diff_changes_follow_sorted_tree_entry_order() {
548 let store = InMemoryStore::new();
549 let from_sub_blob = create_blob(&store, "old nested");
550 let from_sub_tree = Tree::from_entries(vec![TreeEntry {
551 name: "c.txt".to_string(),
552 mode: FileMode::Normal,
553 hash: from_sub_blob,
554 entry_type: EntryType::Blob,
555 }]);
556 let from_sub_hash = store.put_tree(&from_sub_tree).unwrap();
557 let to_sub_blob = create_blob(&store, "new nested");
558 let to_sub_tree = Tree::from_entries(vec![TreeEntry {
559 name: "b.txt".to_string(),
560 mode: FileMode::Normal,
561 hash: to_sub_blob,
562 entry_type: EntryType::Blob,
563 }]);
564 let to_sub_hash = store.put_tree(&to_sub_tree).unwrap();
565
566 let from_hash = create_tree(
567 &store,
568 vec![
569 ("z.txt", create_blob(&store, "old z"), EntryType::Blob),
570 ("dir", from_sub_hash, EntryType::Tree),
571 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
572 ("a.txt", create_blob(&store, "old a"), EntryType::Blob),
573 ],
574 );
575 let to_hash = create_tree(
576 &store,
577 vec![
578 ("b.txt", create_blob(&store, "new b"), EntryType::Blob),
579 ("dir", to_sub_hash, EntryType::Tree),
580 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
581 ("z.txt", create_blob(&store, "new z"), EntryType::Blob),
582 ],
583 );
584
585 let changes: Vec<_> = diff_trees(&store, &from_hash, &to_hash)
586 .unwrap()
587 .into_iter()
588 .map(|change| (change.path, change.kind))
589 .collect();
590
591 assert_eq!(
592 changes,
593 vec![
594 ("a.txt".to_string(), crate::object::DiffKind::Deleted),
595 ("b.txt".to_string(), crate::object::DiffKind::Added),
596 ("dir/b.txt".to_string(), crate::object::DiffKind::Added),
597 ("dir/c.txt".to_string(), crate::object::DiffKind::Deleted),
598 ("z.txt".to_string(), crate::object::DiffKind::Modified),
599 ]
600 );
601 }
602
603 #[test]
608 fn test_visit_matches_collect_order() {
609 let store = InMemoryStore::new();
610 let from_sub_blob = create_blob(&store, "old nested");
611 let from_sub_tree = Tree::from_entries(vec![TreeEntry {
612 name: "c.txt".to_string(),
613 mode: FileMode::Normal,
614 hash: from_sub_blob,
615 entry_type: EntryType::Blob,
616 }]);
617 let from_sub_hash = store.put_tree(&from_sub_tree).unwrap();
618 let to_sub_blob = create_blob(&store, "new nested");
619 let to_sub_tree = Tree::from_entries(vec![TreeEntry {
620 name: "b.txt".to_string(),
621 mode: FileMode::Normal,
622 hash: to_sub_blob,
623 entry_type: EntryType::Blob,
624 }]);
625 let to_sub_hash = store.put_tree(&to_sub_tree).unwrap();
626
627 let from_hash = create_tree(
628 &store,
629 vec![
630 ("z.txt", create_blob(&store, "old z"), EntryType::Blob),
631 ("dir", from_sub_hash, EntryType::Tree),
632 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
633 ("a.txt", create_blob(&store, "old a"), EntryType::Blob),
634 ],
635 );
636 let to_hash = create_tree(
637 &store,
638 vec![
639 ("b.txt", create_blob(&store, "new b"), EntryType::Blob),
640 ("dir", to_sub_hash, EntryType::Tree),
641 ("m.txt", create_blob(&store, "same"), EntryType::Blob),
642 ("z.txt", create_blob(&store, "new z"), EntryType::Blob),
643 ],
644 );
645
646 let collected: Vec<_> = diff_trees(&store, &from_hash, &to_hash)
647 .unwrap()
648 .into_iter()
649 .map(FileChange::into_tuple)
650 .collect();
651
652 let mut visited = Vec::new();
653 let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
654 visited.push(change.into_tuple());
655 ControlFlow::<()>::Continue(())
656 })
657 .unwrap();
658
659 assert!(flow.is_continue());
660 assert_eq!(visited, collected);
661 }
662
663 #[test]
664 fn test_visit_identical_trees_never_calls_visitor() {
665 let store = InMemoryStore::new();
666 let hash = create_tree(
667 &store,
668 vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
669 );
670 let mut count = 0usize;
671 let flow = diff_trees_visit(&store, &hash, &hash, |_change| {
672 count += 1;
673 ControlFlow::<()>::Continue(())
674 })
675 .unwrap();
676 assert!(flow.is_continue());
677 assert_eq!(count, 0);
678 }
679
680 #[test]
684 fn test_visit_early_exit_stops_walk() {
685 let store = InMemoryStore::new();
686 let from_hash = create_tree(&store, vec![]);
689 let to_hash = create_tree(
690 &store,
691 vec![
692 ("a.txt", create_blob(&store, "a"), EntryType::Blob),
693 ("b.txt", create_blob(&store, "b"), EntryType::Blob),
694 ("c.txt", create_blob(&store, "c"), EntryType::Blob),
695 ("d.txt", create_blob(&store, "d"), EntryType::Blob),
696 ("e.txt", create_blob(&store, "e"), EntryType::Blob),
697 ],
698 );
699
700 let mut seen = Vec::new();
701 let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
702 seen.push(change.path.clone());
703 if change.path == "c.txt" {
704 ControlFlow::Break("found c")
705 } else {
706 ControlFlow::Continue(())
707 }
708 })
709 .unwrap();
710
711 assert_eq!(flow, ControlFlow::Break("found c"));
712 assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]);
714 }
715
716 #[test]
719 fn test_visit_early_exit_inside_subtree() {
720 let store = InMemoryStore::new();
721 let sub_tree = Tree::from_entries(vec![
722 TreeEntry {
723 name: "x.txt".to_string(),
724 mode: FileMode::Normal,
725 hash: create_blob(&store, "x"),
726 entry_type: EntryType::Blob,
727 },
728 TreeEntry {
729 name: "y.txt".to_string(),
730 mode: FileMode::Normal,
731 hash: create_blob(&store, "y"),
732 entry_type: EntryType::Blob,
733 },
734 ]);
735 let sub_hash = store.put_tree(&sub_tree).unwrap();
736 let from_hash = create_tree(&store, vec![]);
737 let to_hash = create_tree(
738 &store,
739 vec![
740 ("dir", sub_hash, EntryType::Tree),
741 ("z.txt", create_blob(&store, "z"), EntryType::Blob),
742 ],
743 );
744
745 let mut seen = Vec::new();
746 let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
747 seen.push(change.path.clone());
748 ControlFlow::Break(())
749 })
750 .unwrap();
751
752 assert_eq!(flow, ControlFlow::Break(()));
753 assert_eq!(seen, vec!["dir/x.txt"]);
756 }
757}