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, EntryType, 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.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    // 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 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        // 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![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        // `a/b/c.txt` added to an empty tree should produce one `added`
519        // entry with the full slash-joined path. Exercises multi-level
520        // recursion on the add side.
521        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    /// The visitor variant must emit changes in exactly the same order as
604    /// `diff_trees` collects them. This is the byte-identical guarantee that
605    /// lets `diff_trees` delegate to `diff_trees_visit` without changing
606    /// observable output.
607    #[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    /// Early-exit: breaking from the visitor stops the walk and stops loading
681    /// further subtrees. We assert both the carried `Break` payload and that
682    /// the visitor saw strictly fewer changes than the full diff.
683    #[test]
684    fn test_visit_early_exit_stops_walk() {
685        let store = InMemoryStore::new();
686        // Five distinct top-level files all added → five `added` changes in
687        // sorted order: a, b, c, d, e.
688        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        // Stopped at c.txt — never visited d.txt or e.txt.
713        assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]);
714    }
715
716    /// Early-exit must also short-circuit out of nested-subtree recursion, not
717    /// just the top level.
718    #[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        // Broke on the very first leaf inside `dir`; `dir/y.txt` and `z.txt`
754        // were never visited.
755        assert_eq!(seen, vec!["dir/x.txt"]);
756    }
757}