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, 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.
146///
147/// This is the streaming counterpart to [`diff_trees`]. The visitor returns a
148/// [`ControlFlow`]: `Continue(())` keeps walking, while `Break(value)` stops
149/// the traversal immediately — no further subtrees are loaded and no further
150/// changes are produced. Early-exit consumers (e.g. "does anything under path
151/// X differ?", first-N, quick-status checks) use this to avoid materializing
152/// the entire change list.
153///
154/// On early exit the carried `B` is returned as `Ok(ControlFlow::Break(b))`;
155/// on full completion it returns `Ok(ControlFlow::Continue(()))`. Changes are
156/// emitted in exactly the same order as [`diff_trees`] collects them, so the
157/// two paths are behavior-identical.
158pub fn diff_trees_visit<S, V, B>(
159    store: &S,
160    from: &crate::object::ContentHash,
161    to: &crate::object::ContentHash,
162    mut visitor: V,
163) -> Result<ControlFlow<B>, anyhow::Error>
164where
165    S: ObjectSource + ?Sized,
166    V: FnMut(FileChange) -> ControlFlow<B>,
167{
168    if from == to {
169        return Ok(ControlFlow::Continue(()));
170    }
171    let from_tree = store.get_tree(from)?;
172    let to_tree = store.get_tree(to)?;
173    let mut stack = vec![DiffFrame {
174        from: from_tree,
175        to: to_tree,
176        prefix: String::new(),
177        from_index: 0,
178        to_index: 0,
179    }];
180
181    while !stack.is_empty() {
182        match advance_merge(stack.last_mut().expect("stack is not empty")) {
183            DiffStep::Emit(change) => {
184                if let ControlFlow::Break(b) = visitor(change) {
185                    return Ok(ControlFlow::Break(b));
186                }
187            }
188            DiffStep::Descend {
189                from_hash,
190                to_hash,
191                name,
192            } => {
193                let from_subtree = from_hash
194                    .map(|hash| store.get_tree(&hash))
195                    .transpose()?
196                    .flatten();
197                let to_subtree = to_hash
198                    .map(|hash| store.get_tree(&hash))
199                    .transpose()?
200                    .flatten();
201                let prefix = child_path(
202                    &stack.last().expect("parent frame remains on stack").prefix,
203                    &name,
204                );
205                stack.push(DiffFrame {
206                    from: from_subtree,
207                    to: to_subtree,
208                    prefix,
209                    from_index: 0,
210                    to_index: 0,
211                });
212            }
213            DiffStep::Done => {
214                stack.pop();
215            }
216        }
217    }
218
219    Ok(ControlFlow::Continue(()))
220}
221
222#[cfg(feature = "async-source")]
223pub async fn diff_trees_visit_async<S, V, B>(
224    store: &S,
225    from: &crate::object::ContentHash,
226    to: &crate::object::ContentHash,
227    mut visitor: V,
228) -> Result<ControlFlow<B>, anyhow::Error>
229where
230    S: AsyncObjectSource + Sync + ?Sized,
231    V: FnMut(FileChange) -> ControlFlow<B> + Send,
232    B: Send,
233{
234    if from == to {
235        return Ok(ControlFlow::Continue(()));
236    }
237    let from_tree = store.get_tree(from).await?;
238    let to_tree = store.get_tree(to).await?;
239    let mut stack = vec![DiffFrame {
240        from: from_tree,
241        to: to_tree,
242        prefix: String::new(),
243        from_index: 0,
244        to_index: 0,
245    }];
246
247    while !stack.is_empty() {
248        match advance_merge(stack.last_mut().expect("stack is not empty")) {
249            DiffStep::Emit(change) => {
250                if let ControlFlow::Break(b) = visitor(change) {
251                    return Ok(ControlFlow::Break(b));
252                }
253            }
254            DiffStep::Descend {
255                from_hash,
256                to_hash,
257                name,
258            } => {
259                let from_subtree = match from_hash {
260                    Some(hash) => store.get_tree(&hash).await?,
261                    None => None,
262                };
263                let to_subtree = match to_hash {
264                    Some(hash) => store.get_tree(&hash).await?,
265                    None => None,
266                };
267                let prefix = child_path(
268                    &stack.last().expect("parent frame remains on stack").prefix,
269                    &name,
270                );
271                stack.push(DiffFrame {
272                    from: from_subtree,
273                    to: to_subtree,
274                    prefix,
275                    from_index: 0,
276                    to_index: 0,
277                });
278            }
279            DiffStep::Done => {
280                stack.pop();
281            }
282        }
283    }
284
285    Ok(ControlFlow::Continue(()))
286}
287
288fn child_path(prefix: &str, name: &str) -> String {
289    if prefix.is_empty() {
290        name.to_owned()
291    } else {
292        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
293        path.push_str(prefix);
294        path.push('/');
295        path.push_str(name);
296        path
297    }
298}