Skip to main content

objects/object/
tree_diff.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared tree-to-tree diffing implementation.
3//!
4//! This module provides a generic tree diffing algorithm that can be used
5//! by both `repo` and `semantic` crates.
6
7use 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
17/// Collect all file changes between two trees.
18///
19/// This is the materializing variant: it walks the trees via
20/// [`diff_trees_visit`] and collects every [`FileChange`] into a
21/// [`FileChangeSet`]. Streaming or early-exit consumers should prefer
22/// [`diff_trees_visit`], which avoids allocating the full change list.
23pub 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    // The visitor never short-circuits here, so the `ControlFlow` result is
30    // always `Continue(())`; we ignore it and return the collected set.
31    let _ = diff_trees_visit(store, from, to, |change| {
32        changes.push(change);
33        ControlFlow::<()>::Continue(())
34    })?;
35    Ok(changes)
36}
37
38/// Diff two trees with internal iteration, invoking `visitor` for each
39/// [`FileChange`] in traversal order.
40///
41/// This is the streaming counterpart to [`diff_trees`]. The visitor returns a
42/// [`ControlFlow`]: `Continue(())` keeps walking, while `Break(value)` stops
43/// the traversal immediately — no further subtrees are loaded and no further
44/// changes are produced. Early-exit consumers (e.g. "does anything under path
45/// X differ?", first-N, quick-status checks) use this to avoid materializing
46/// the entire change list.
47///
48/// On early exit the carried `B` is returned as `Ok(ControlFlow::Break(b))`;
49/// on full completion it returns `Ok(ControlFlow::Continue(()))`. Changes are
50/// emitted in exactly the same order as [`diff_trees`] collects them, so the
51/// two paths are behavior-identical.
52pub 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
70/// Recursively diff two trees, invoking `visitor` for each change.
71///
72/// Returns `Ok(ControlFlow::Break(b))` as soon as the visitor breaks, so the
73/// caller can propagate the short-circuit up the recursion without walking the
74/// remaining entries or loading further subtrees.
75fn 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    // Symmetric with the delete branch below: if the added entry is itself a
165    // directory, recurse into it so callers see per-leaf `added` entries.
166    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        // Mirror of `test_diff_nested_directories` for the add side.
486        // An added subdirectory should surface each leaf file it
487        // contains — not just the directory name. Previously the add
488        // branch was asymmetric with the delete branch and returned a
489        // single `"subdir"` entry; the root-commit case (empty →
490        // full) hit this every time and broke downstream code that
491        // expected leaf paths.
492        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        // `a/b/c.txt` added to an empty tree should produce one `added`
513        // entry with the full slash-joined path. Exercises multi-level
514        // recursion on the add side.
515        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    /// The visitor variant must emit changes in exactly the same order as
581    /// `diff_trees` collects them. This is the byte-identical guarantee that
582    /// lets `diff_trees` delegate to `diff_trees_visit` without changing
583    /// observable output.
584    #[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    /// Early-exit: breaking from the visitor stops the walk and stops loading
651    /// further subtrees. We assert both the carried `Break` payload and that
652    /// the visitor saw strictly fewer changes than the full diff.
653    #[test]
654    fn test_visit_early_exit_stops_walk() {
655        let store = InMemoryStore::new();
656        // Five distinct top-level files all added → five `added` changes in
657        // sorted order: a, b, c, d, e.
658        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        // Stopped at c.txt — never visited d.txt or e.txt.
683        assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]);
684    }
685
686    /// Early-exit must also short-circuit out of nested-subtree recursion, not
687    /// just the top level.
688    #[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        // Broke on the very first leaf inside `dir`; `dir/y.txt` and `z.txt`
714        // were never visited.
715        assert_eq!(seen, vec!["dir/x.txt"]);
716    }
717}