Skip to main content

heddle_object_model/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
9#[cfg(feature = "async-source")]
10use super::AsyncObjectSource;
11use super::{ContentHash, DiffKind, FileChange, FileChangeSet, ObjectSource, Tree};
12
13struct DiffFrame {
14    from: Option<Tree>,
15    to: Option<Tree>,
16    prefix: String,
17    from_index: usize,
18    to_index: usize,
19}
20
21enum DiffStep {
22    Emit(FileChange),
23    Descend {
24        from_hash: Option<ContentHash>,
25        to_hash: Option<ContentHash>,
26        name: String,
27    },
28    Done,
29}
30
31fn advance_merge(frame: &mut DiffFrame) -> DiffStep {
32    let from_entries = frame.from.as_ref().map_or(&[][..], Tree::entries);
33    let to_entries = frame.to.as_ref().map_or(&[][..], Tree::entries);
34
35    loop {
36        match (
37            from_entries.get(frame.from_index),
38            to_entries.get(frame.to_index),
39        ) {
40            (Some(from_entry), Some(to_entry)) => match from_entry.name().cmp(to_entry.name()) {
41                std::cmp::Ordering::Less => {
42                    frame.from_index += 1;
43                    if let Some(from_hash) = from_entry.tree_hash() {
44                        return DiffStep::Descend {
45                            from_hash: Some(from_hash),
46                            to_hash: None,
47                            name: from_entry.name().to_owned(),
48                        };
49                    }
50                    return DiffStep::Emit(FileChange::new(
51                        child_path(&frame.prefix, from_entry.name()),
52                        DiffKind::Deleted,
53                    ));
54                }
55                std::cmp::Ordering::Greater => {
56                    frame.to_index += 1;
57                    if let Some(to_hash) = to_entry.tree_hash() {
58                        return DiffStep::Descend {
59                            from_hash: None,
60                            to_hash: Some(to_hash),
61                            name: to_entry.name().to_owned(),
62                        };
63                    }
64                    return DiffStep::Emit(FileChange::new(
65                        child_path(&frame.prefix, to_entry.name()),
66                        DiffKind::Added,
67                    ));
68                }
69                std::cmp::Ordering::Equal => {
70                    frame.from_index += 1;
71                    frame.to_index += 1;
72                    if from_entry.target() == to_entry.target() {
73                        continue;
74                    }
75                    if let (Some(from_hash), Some(to_hash)) =
76                        (from_entry.tree_hash(), to_entry.tree_hash())
77                    {
78                        return DiffStep::Descend {
79                            from_hash: Some(from_hash),
80                            to_hash: Some(to_hash),
81                            name: to_entry.name().to_owned(),
82                        };
83                    }
84                    return DiffStep::Emit(FileChange::new(
85                        child_path(&frame.prefix, to_entry.name()),
86                        DiffKind::Modified,
87                    ));
88                }
89            },
90            (Some(from_entry), None) => {
91                frame.from_index += 1;
92                if let Some(from_hash) = from_entry.tree_hash() {
93                    return DiffStep::Descend {
94                        from_hash: Some(from_hash),
95                        to_hash: None,
96                        name: from_entry.name().to_owned(),
97                    };
98                }
99                return DiffStep::Emit(FileChange::new(
100                    child_path(&frame.prefix, from_entry.name()),
101                    DiffKind::Deleted,
102                ));
103            }
104            (None, Some(to_entry)) => {
105                frame.to_index += 1;
106                if let Some(to_hash) = to_entry.tree_hash() {
107                    return DiffStep::Descend {
108                        from_hash: None,
109                        to_hash: Some(to_hash),
110                        name: to_entry.name().to_owned(),
111                    };
112                }
113                return DiffStep::Emit(FileChange::new(
114                    child_path(&frame.prefix, to_entry.name()),
115                    DiffKind::Added,
116                ));
117            }
118            (None, None) => return DiffStep::Done,
119        }
120    }
121}
122
123/// Collect all file changes between two trees.
124///
125/// This is the materializing variant: it walks the trees via
126/// [`diff_trees_visit`] and collects every [`FileChange`] into a
127/// [`FileChangeSet`]. Streaming or early-exit consumers should prefer
128/// [`diff_trees_visit`], which avoids allocating the full change list.
129pub fn diff_trees<S: ObjectSource + ?Sized>(
130    store: &S,
131    from: &crate::object::ContentHash,
132    to: &crate::object::ContentHash,
133) -> Result<FileChangeSet, anyhow::Error> {
134    let mut changes = FileChangeSet::new();
135    // The visitor never short-circuits here, so the `ControlFlow` result is
136    // always `Continue(())`; we ignore it and return the collected set.
137    let _ = diff_trees_visit(store, Some(from), to, |change| {
138        changes.push(change);
139        ControlFlow::<()>::Continue(())
140    })?;
141    Ok(changes)
142}
143
144/// Diff two trees with internal iteration, invoking `visitor` for each
145/// [`FileChange`] in traversal order. `from: None` explicitly represents a
146/// parentless state's absent baseline; every supplied hash must resolve.
147///
148/// This is the streaming counterpart to [`diff_trees`]. The visitor returns a
149/// [`ControlFlow`]: `Continue(())` keeps walking, while `Break(value)` stops
150/// the traversal immediately — no further subtrees are loaded and no further
151/// changes are produced. Early-exit consumers (e.g. "does anything under path
152/// X differ?", first-N, quick-status checks) use this to avoid materializing
153/// the entire change list.
154///
155/// On early exit the carried `B` is returned as `Ok(ControlFlow::Break(b))`;
156/// on full completion it returns `Ok(ControlFlow::Continue(()))`. Changes are
157/// emitted in exactly the same order as [`diff_trees`] collects them, so the
158/// two paths are behavior-identical.
159pub fn diff_trees_visit<S, V, B>(
160    store: &S,
161    from: Option<&crate::object::ContentHash>,
162    to: &crate::object::ContentHash,
163    mut visitor: V,
164) -> Result<ControlFlow<B>, anyhow::Error>
165where
166    S: ObjectSource + ?Sized,
167    V: FnMut(FileChange) -> ControlFlow<B>,
168{
169    let from_tree = from.map(|hash| store.require_tree(hash)).transpose()?;
170    if from == Some(to) {
171        return Ok(ControlFlow::Continue(()));
172    }
173    let to_tree = Some(store.require_tree(to)?);
174    let mut stack = vec![DiffFrame {
175        from: from_tree,
176        to: to_tree,
177        prefix: String::new(),
178        from_index: 0,
179        to_index: 0,
180    }];
181
182    while let Some(frame) = stack.last_mut() {
183        match advance_merge(frame) {
184            DiffStep::Emit(change) => {
185                if let ControlFlow::Break(b) = visitor(change) {
186                    return Ok(ControlFlow::Break(b));
187                }
188            }
189            DiffStep::Descend {
190                from_hash,
191                to_hash,
192                name,
193            } => {
194                let from_subtree = from_hash
195                    .map(|hash| store.require_tree(&hash))
196                    .transpose()?;
197                let to_subtree = to_hash.map(|hash| store.require_tree(&hash)).transpose()?;
198                let prefix = child_path(&frame.prefix, &name);
199                stack.push(DiffFrame {
200                    from: from_subtree,
201                    to: to_subtree,
202                    prefix,
203                    from_index: 0,
204                    to_index: 0,
205                });
206            }
207            DiffStep::Done => {
208                stack.pop();
209            }
210        }
211    }
212
213    Ok(ControlFlow::Continue(()))
214}
215
216#[cfg(feature = "async-source")]
217pub async fn diff_trees_visit_async<S, V, B>(
218    store: &S,
219    from: Option<&crate::object::ContentHash>,
220    to: &crate::object::ContentHash,
221    mut visitor: 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_tree = match from {
229        Some(hash) => Some(store.require_tree(hash).await?),
230        None => None,
231    };
232    if from == Some(to) {
233        return Ok(ControlFlow::Continue(()));
234    }
235    let to_tree = Some(store.require_tree(to).await?);
236    let mut stack = vec![DiffFrame {
237        from: from_tree,
238        to: to_tree,
239        prefix: String::new(),
240        from_index: 0,
241        to_index: 0,
242    }];
243
244    while let Some(frame) = stack.last_mut() {
245        match advance_merge(frame) {
246            DiffStep::Emit(change) => {
247                if let ControlFlow::Break(b) = visitor(change) {
248                    return Ok(ControlFlow::Break(b));
249                }
250            }
251            DiffStep::Descend {
252                from_hash,
253                to_hash,
254                name,
255            } => {
256                let from_subtree = match from_hash {
257                    Some(hash) => Some(store.require_tree(&hash).await?),
258                    None => None,
259                };
260                let to_subtree = match to_hash {
261                    Some(hash) => Some(store.require_tree(&hash).await?),
262                    None => None,
263                };
264                let prefix = child_path(&frame.prefix, &name);
265                stack.push(DiffFrame {
266                    from: from_subtree,
267                    to: to_subtree,
268                    prefix,
269                    from_index: 0,
270                    to_index: 0,
271                });
272            }
273            DiffStep::Done => {
274                stack.pop();
275            }
276        }
277    }
278
279    Ok(ControlFlow::Continue(()))
280}
281
282fn child_path(prefix: &str, name: &str) -> String {
283    if prefix.is_empty() {
284        name.to_owned()
285    } else {
286        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
287        path.push_str(prefix);
288        path.push('/');
289        path.push_str(name);
290        path
291    }
292}