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