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                EntryType::Spoollink => {
399                    panic!("use TreeEntry::spoollink for spoollink tests")
400                }
401            })
402            .collect();
403        let tree = Tree::from_entries(tree_entries);
404        store.put_tree(&tree).unwrap()
405    }
406
407    #[test]
408    fn test_diff_identical_trees() {
409        let store = InMemoryStore::new();
410        let hash = create_tree(
411            &store,
412            vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
413        );
414        let changes = diff_trees(&store, &hash, &hash).unwrap();
415        assert!(changes.is_empty());
416    }
417
418    #[test]
419    fn test_diff_added_file() {
420        let store = InMemoryStore::new();
421        let from_hash = create_tree(&store, vec![]);
422        let to_hash = create_tree(
423            &store,
424            vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
425        );
426        let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
427
428        assert_eq!(changes.len(), 1);
429        assert_eq!(changes.added_count(), 1);
430
431        let added: Vec<_> = changes.added().collect();
432        assert_eq!(added[0].path, "a.txt");
433    }
434
435    #[test]
436    fn test_diff_deleted_file() {
437        let store = InMemoryStore::new();
438        let blob_hash = create_blob(&store, "content");
439        let from_hash = create_tree(&store, vec![("a.txt", blob_hash, EntryType::Blob)]);
440        let to_hash = create_tree(&store, vec![]);
441        let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
442
443        assert_eq!(changes.len(), 1);
444        assert_eq!(changes.deleted_count(), 1);
445
446        let deleted: Vec<_> = changes.deleted().collect();
447        assert_eq!(deleted[0].path, "a.txt");
448    }
449
450    #[test]
451    fn test_diff_modified_file() {
452        let store = InMemoryStore::new();
453        let blob1_hash = create_blob(&store, "original");
454        let blob2_hash = create_blob(&store, "modified");
455        let from_hash = create_tree(&store, vec![("a.txt", blob1_hash, EntryType::Blob)]);
456        let to_hash = create_tree(&store, vec![("a.txt", blob2_hash, EntryType::Blob)]);
457        let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
458
459        assert_eq!(changes.len(), 1);
460        assert_eq!(changes.modified_count(), 1);
461
462        let modified: Vec<_> = changes.modified().collect();
463        assert_eq!(modified[0].path, "a.txt");
464    }
465
466    #[test]
467    fn test_diff_nested_directories() {
468        let store = InMemoryStore::new();
469        let sub_blob = create_blob(&store, "sub content");
470        let sub_tree = Tree::from_entries(vec![
471            TreeEntry::file("nested.txt", sub_blob, false).unwrap(),
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        // Mirror of `test_diff_nested_directories` for the add side.
489        // An added subdirectory should surface each leaf file it
490        // contains — not just the directory name. Previously the add
491        // branch was asymmetric with the delete branch and returned a
492        // single `"subdir"` entry; the root-commit case (empty →
493        // full) hit this every time and broke downstream code that
494        // expected leaf paths.
495        let store = InMemoryStore::new();
496        let sub_blob = create_blob(&store, "sub content");
497        let sub_tree = Tree::from_entries(vec![
498            TreeEntry::file("nested.txt", sub_blob, false).unwrap(),
499        ]);
500        let sub_hash = store.put_tree(&sub_tree).unwrap();
501
502        let from_hash = create_tree(&store, vec![]);
503        let to_hash = create_tree(&store, vec![("subdir", sub_hash, EntryType::Tree)]);
504        let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
505
506        assert_eq!(changes.len(), 1);
507        assert_eq!(changes.added_count(), 1);
508
509        let added: Vec<_> = changes.added().collect();
510        assert_eq!(added[0].path, "subdir/nested.txt");
511    }
512
513    #[test]
514    fn test_diff_added_directory_deep_nesting() {
515        // `a/b/c.txt` added to an empty tree should produce one `added`
516        // entry with the full slash-joined path. Exercises multi-level
517        // recursion on the add side.
518        let store = InMemoryStore::new();
519        let leaf_blob = create_blob(&store, "leaf");
520        let c_tree = Tree::from_entries(vec![TreeEntry::file("c.txt", leaf_blob, false).unwrap()]);
521        let c_hash = store.put_tree(&c_tree).unwrap();
522        let b_tree = Tree::from_entries(vec![TreeEntry::directory("b", c_hash).unwrap()]);
523        let b_hash = store.put_tree(&b_tree).unwrap();
524        let from_hash = create_tree(&store, vec![]);
525        let to_hash = create_tree(&store, vec![("a", b_hash, EntryType::Tree)]);
526
527        let changes = diff_trees(&store, &from_hash, &to_hash).unwrap();
528        assert_eq!(changes.added_count(), 1);
529        let added: Vec<_> = changes.added().collect();
530        assert_eq!(added[0].path, "a/b/c.txt");
531    }
532
533    #[test]
534    fn test_diff_changes_follow_sorted_tree_entry_order() {
535        let store = InMemoryStore::new();
536        let from_sub_blob = create_blob(&store, "old nested");
537        let from_sub_tree = Tree::from_entries(vec![
538            TreeEntry::file("c.txt", from_sub_blob, false).unwrap(),
539        ]);
540        let from_sub_hash = store.put_tree(&from_sub_tree).unwrap();
541        let to_sub_blob = create_blob(&store, "new nested");
542        let to_sub_tree =
543            Tree::from_entries(vec![TreeEntry::file("b.txt", to_sub_blob, false).unwrap()]);
544        let to_sub_hash = store.put_tree(&to_sub_tree).unwrap();
545
546        let from_hash = create_tree(
547            &store,
548            vec![
549                ("z.txt", create_blob(&store, "old z"), EntryType::Blob),
550                ("dir", from_sub_hash, EntryType::Tree),
551                ("m.txt", create_blob(&store, "same"), EntryType::Blob),
552                ("a.txt", create_blob(&store, "old a"), EntryType::Blob),
553            ],
554        );
555        let to_hash = create_tree(
556            &store,
557            vec![
558                ("b.txt", create_blob(&store, "new b"), EntryType::Blob),
559                ("dir", to_sub_hash, EntryType::Tree),
560                ("m.txt", create_blob(&store, "same"), EntryType::Blob),
561                ("z.txt", create_blob(&store, "new z"), EntryType::Blob),
562            ],
563        );
564
565        let changes: Vec<_> = diff_trees(&store, &from_hash, &to_hash)
566            .unwrap()
567            .into_iter()
568            .map(|change| (change.path, change.kind))
569            .collect();
570
571        assert_eq!(
572            changes,
573            vec![
574                ("a.txt".to_string(), crate::object::DiffKind::Deleted),
575                ("b.txt".to_string(), crate::object::DiffKind::Added),
576                ("dir/b.txt".to_string(), crate::object::DiffKind::Added),
577                ("dir/c.txt".to_string(), crate::object::DiffKind::Deleted),
578                ("z.txt".to_string(), crate::object::DiffKind::Modified),
579            ]
580        );
581    }
582
583    /// The visitor variant must emit changes in exactly the same order as
584    /// `diff_trees` collects them. This is the byte-identical guarantee that
585    /// lets `diff_trees` delegate to `diff_trees_visit` without changing
586    /// observable output.
587    #[test]
588    fn test_visit_matches_collect_order() {
589        let store = InMemoryStore::new();
590        let from_sub_blob = create_blob(&store, "old nested");
591        let from_sub_tree = Tree::from_entries(vec![
592            TreeEntry::file("c.txt", from_sub_blob, false).unwrap(),
593        ]);
594        let from_sub_hash = store.put_tree(&from_sub_tree).unwrap();
595        let to_sub_blob = create_blob(&store, "new nested");
596        let to_sub_tree =
597            Tree::from_entries(vec![TreeEntry::file("b.txt", to_sub_blob, false).unwrap()]);
598        let to_sub_hash = store.put_tree(&to_sub_tree).unwrap();
599
600        let from_hash = create_tree(
601            &store,
602            vec![
603                ("z.txt", create_blob(&store, "old z"), EntryType::Blob),
604                ("dir", from_sub_hash, EntryType::Tree),
605                ("m.txt", create_blob(&store, "same"), EntryType::Blob),
606                ("a.txt", create_blob(&store, "old a"), EntryType::Blob),
607            ],
608        );
609        let to_hash = create_tree(
610            &store,
611            vec![
612                ("b.txt", create_blob(&store, "new b"), EntryType::Blob),
613                ("dir", to_sub_hash, EntryType::Tree),
614                ("m.txt", create_blob(&store, "same"), EntryType::Blob),
615                ("z.txt", create_blob(&store, "new z"), EntryType::Blob),
616            ],
617        );
618
619        let collected: Vec<_> = diff_trees(&store, &from_hash, &to_hash)
620            .unwrap()
621            .into_iter()
622            .map(FileChange::into_tuple)
623            .collect();
624
625        let mut visited = Vec::new();
626        let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
627            visited.push(change.into_tuple());
628            ControlFlow::<()>::Continue(())
629        })
630        .unwrap();
631
632        assert!(flow.is_continue());
633        assert_eq!(visited, collected);
634    }
635
636    #[test]
637    fn test_visit_identical_trees_never_calls_visitor() {
638        let store = InMemoryStore::new();
639        let hash = create_tree(
640            &store,
641            vec![("a.txt", create_blob(&store, "content"), EntryType::Blob)],
642        );
643        let mut count = 0usize;
644        let flow = diff_trees_visit(&store, &hash, &hash, |_change| {
645            count += 1;
646            ControlFlow::<()>::Continue(())
647        })
648        .unwrap();
649        assert!(flow.is_continue());
650        assert_eq!(count, 0);
651    }
652
653    /// Early-exit: breaking from the visitor stops the walk and stops loading
654    /// further subtrees. We assert both the carried `Break` payload and that
655    /// the visitor saw strictly fewer changes than the full diff.
656    #[test]
657    fn test_visit_early_exit_stops_walk() {
658        let store = InMemoryStore::new();
659        // Five distinct top-level files all added → five `added` changes in
660        // sorted order: a, b, c, d, e.
661        let from_hash = create_tree(&store, vec![]);
662        let to_hash = create_tree(
663            &store,
664            vec![
665                ("a.txt", create_blob(&store, "a"), EntryType::Blob),
666                ("b.txt", create_blob(&store, "b"), EntryType::Blob),
667                ("c.txt", create_blob(&store, "c"), EntryType::Blob),
668                ("d.txt", create_blob(&store, "d"), EntryType::Blob),
669                ("e.txt", create_blob(&store, "e"), EntryType::Blob),
670            ],
671        );
672
673        let mut seen = Vec::new();
674        let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
675            seen.push(change.path.clone());
676            if change.path == "c.txt" {
677                ControlFlow::Break("found c")
678            } else {
679                ControlFlow::Continue(())
680            }
681        })
682        .unwrap();
683
684        assert_eq!(flow, ControlFlow::Break("found c"));
685        // Stopped at c.txt — never visited d.txt or e.txt.
686        assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]);
687    }
688
689    /// Early-exit must also short-circuit out of nested-subtree recursion, not
690    /// just the top level.
691    #[test]
692    fn test_visit_early_exit_inside_subtree() {
693        let store = InMemoryStore::new();
694        let sub_tree = Tree::from_entries(vec![
695            TreeEntry::file("x.txt", create_blob(&store, "x"), false).unwrap(),
696            TreeEntry::file("y.txt", create_blob(&store, "y"), false).unwrap(),
697        ]);
698        let sub_hash = store.put_tree(&sub_tree).unwrap();
699        let from_hash = create_tree(&store, vec![]);
700        let to_hash = create_tree(
701            &store,
702            vec![
703                ("dir", sub_hash, EntryType::Tree),
704                ("z.txt", create_blob(&store, "z"), EntryType::Blob),
705            ],
706        );
707
708        let mut seen = Vec::new();
709        let flow = diff_trees_visit(&store, &from_hash, &to_hash, |change| {
710            seen.push(change.path.clone());
711            ControlFlow::Break(())
712        })
713        .unwrap();
714
715        assert_eq!(flow, ControlFlow::Break(()));
716        // Broke on the very first leaf inside `dir`; `dir/y.txt` and `z.txt`
717        // were never visited.
718        assert_eq!(seen, vec!["dir/x.txt"]);
719    }
720}