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;
10use crate::{
11    object::{DiffKind, EntryType, FileChange, Tree, TreeEntry},
12    store::ObjectSource,
13};
14
15#[cfg(feature = "async-source")]
16use crate::store::AsyncObjectSource;
17
18/// Collect all file changes between two trees.
19///
20/// This is the materializing variant: it walks the trees via
21/// [`diff_trees_visit`] and collects every [`FileChange`] into a
22/// [`FileChangeSet`]. Streaming or early-exit consumers should prefer
23/// [`diff_trees_visit`], which avoids allocating the full change list.
24pub 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    // The visitor never short-circuits here, so the `ControlFlow` result is
31    // always `Continue(())`; we ignore it and return the collected set.
32    let _ = diff_trees_visit(store, from, to, |change| {
33        changes.push(change);
34        ControlFlow::<()>::Continue(())
35    })?;
36    Ok(changes)
37}
38
39/// Diff two trees with internal iteration, invoking `visitor` for each
40/// [`FileChange`] in traversal order.
41///
42/// This is the streaming counterpart to [`diff_trees`]. The visitor returns a
43/// [`ControlFlow`]: `Continue(())` keeps walking, while `Break(value)` stops
44/// the traversal immediately — no further subtrees are loaded and no further
45/// changes are produced. Early-exit consumers (e.g. "does anything under path
46/// X differ?", first-N, quick-status checks) use this to avoid materializing
47/// the entire change list.
48///
49/// On early exit the carried `B` is returned as `Ok(ControlFlow::Break(b))`;
50/// on full completion it returns `Ok(ControlFlow::Continue(()))`. Changes are
51/// emitted in exactly the same order as [`diff_trees`] collects them, so the
52/// two paths are behavior-identical.
53pub 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
71/// Recursively diff two trees, invoking `visitor` for each change.
72///
73/// Returns `Ok(ControlFlow::Break(b))` as soon as the visitor breaks, so the
74/// caller can propagate the short-circuit up the recursion without walking the
75/// remaining entries or loading further subtrees.
76fn 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    // Symmetric with the delete branch below: if the added entry is itself a
166    // directory, recurse into it so callers see per-leaf `added` entries.
167    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        // Mirror of `test_diff_nested_directories` for the add side.
490        // An added subdirectory should surface each leaf file it
491        // contains — not just the directory name. Previously the add
492        // branch was asymmetric with the delete branch and returned a
493        // single `"subdir"` entry; the root-commit case (empty →
494        // full) hit this every time and broke downstream code that
495        // expected leaf paths.
496        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        // `a/b/c.txt` added to an empty tree should produce one `added`
520        // entry with the full slash-joined path. Exercises multi-level
521        // recursion on the add side.
522        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    /// The visitor variant must emit changes in exactly the same order as
605    /// `diff_trees` collects them. This is the byte-identical guarantee that
606    /// lets `diff_trees` delegate to `diff_trees_visit` without changing
607    /// observable output.
608    #[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    /// Early-exit: breaking from the visitor stops the walk and stops loading
682    /// further subtrees. We assert both the carried `Break` payload and that
683    /// the visitor saw strictly fewer changes than the full diff.
684    #[test]
685    fn test_visit_early_exit_stops_walk() {
686        let store = InMemoryStore::new();
687        // Five distinct top-level files all added → five `added` changes in
688        // sorted order: a, b, c, d, e.
689        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        // Stopped at c.txt — never visited d.txt or e.txt.
714        assert_eq!(seen, vec!["a.txt", "b.txt", "c.txt"]);
715    }
716
717    /// Early-exit must also short-circuit out of nested-subtree recursion, not
718    /// just the top level.
719    #[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        // Broke on the very first leaf inside `dir`; `dir/y.txt` and `z.txt`
755        // were never visited.
756        assert_eq!(seen, vec!["dir/x.txt"]);
757    }
758}