Skip to main content

grit_lib/
combined_tree_diff.rs

1//! Multi-parent combined tree diff (Git `diff_tree_paths` / `find_paths_multitree`).
2//!
3//! Implements the `D(T,P1...Pn)` walk from `tree-diff.c` for merge commits, producing
4//! per-path parent status letters used by `git diff-tree -c --name-status` and
5//! `--find-object` filtering (`combined_objfind` in `combine-diff.c`).
6
7use crate::diff::zero_oid;
8use crate::error::Result;
9use crate::objects::{parse_commit, parse_tree, tree_entry_cmp, ObjectId, ObjectKind, TreeEntry};
10use crate::odb::Odb;
11
12/// One character per parent for raw / name-status combined output (`A`/`M`/`D`).
13#[must_use]
14pub fn combined_parent_status_char(s: CombinedParentStatus) -> char {
15    match s {
16        CombinedParentStatus::Added => 'A',
17        CombinedParentStatus::Modified => 'M',
18        CombinedParentStatus::Deleted => 'D',
19    }
20}
21
22/// Git raw combined line (`::::modes... oids... MM\tpath`).
23#[must_use]
24pub fn format_combined_raw_line(p: &CombinedDiffPath, abbrev_len: Option<usize>) -> String {
25    let n = p.parents.len();
26    let mut colons = String::with_capacity(n);
27    for _ in 0..n {
28        colons.push(':');
29    }
30    let mut modes = String::new();
31    for side in &p.parents {
32        modes.push_str(&format!("{:06o} ", side.mode));
33    }
34    modes.push_str(&format!("{:06o}", p.merge_mode));
35    let mut oids = String::new();
36    for side in &p.parents {
37        let h = format!("{}", side.oid);
38        let disp = if let Some(len) = abbrev_len {
39            &h[..len.min(h.len())]
40        } else {
41            h.as_str()
42        };
43        oids.push(' ');
44        oids.push_str(disp);
45    }
46    let rh = format!("{}", p.merge_oid);
47    let rdisp = if let Some(len) = abbrev_len {
48        &rh[..len.min(rh.len())]
49    } else {
50        rh.as_str()
51    };
52    oids.push(' ');
53    oids.push_str(rdisp);
54    oids.push(' ');
55    let status: String = p
56        .parents
57        .iter()
58        .map(|s| combined_parent_status_char(s.status))
59        .collect();
60    format!("{colons}{modes}{oids}{status}\t{}", p.path)
61}
62
63/// Per-parent coarse status in a combined diff (`A` / `M` / `D`).
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum CombinedParentStatus {
66    /// Parent lacks the path; merge tree added it.
67    Added,
68    /// Parent has the path at the same name as the merge result.
69    Modified,
70    /// Path removed from merge (only in parents that had it at the min name).
71    Deleted,
72}
73
74/// One path in a combined diff: merge result tree vs each parent tree.
75#[derive(Debug, Clone)]
76pub struct CombinedDiffPath {
77    /// Path relative to repository root.
78    pub path: String,
79    /// Mode and OID on the merge result side (`0` / zero OID when deleted from merge).
80    pub merge_mode: u32,
81    pub merge_oid: ObjectId,
82    /// One slot per parent commit (same order as `parents` passed to [`combined_diff_paths_filtered`]).
83    pub parents: Vec<CombinedParentSide>,
84}
85
86/// One parent's contribution at a combined-diff path.
87#[derive(Debug, Clone)]
88pub struct CombinedParentSide {
89    pub mode: u32,
90    pub oid: ObjectId,
91    pub status: CombinedParentStatus,
92}
93
94/// Options for the multitree walk.
95#[derive(Debug, Clone, Default)]
96pub struct CombinedTreeDiffOptions {
97    /// Recurse into sub-trees (`diff_options.flags.recursive`).
98    pub recursive: bool,
99    /// Emit tree directory lines when recursing (`tree_in_recursive`, e.g. `--find-object`).
100    pub tree_in_recursive: bool,
101}
102
103fn is_tree_mode(mode: u32) -> bool {
104    (mode & 0o170000) == 0o040000
105}
106
107fn read_tree_entries(odb: &Odb, oid: Option<&ObjectId>) -> Result<Vec<TreeEntry>> {
108    let Some(oid) = oid else {
109        return Ok(Vec::new());
110    };
111    let obj = odb.read(oid)?;
112    if obj.kind != ObjectKind::Tree {
113        return Ok(Vec::new());
114    }
115    parse_tree(&obj.data)
116}
117
118fn tree_entry_pathcmp(a: Option<&TreeEntry>, b: Option<&TreeEntry>) -> std::cmp::Ordering {
119    match (a, b) {
120        (None, None) => std::cmp::Ordering::Equal,
121        (None, Some(_)) => std::cmp::Ordering::Greater,
122        (Some(_), None) => std::cmp::Ordering::Less,
123        (Some(e1), Some(e2)) => tree_entry_cmp(
124            &e1.name,
125            is_tree_mode(e1.mode),
126            &e2.name,
127            is_tree_mode(e2.mode),
128        ),
129    }
130}
131
132fn combined_matches_find_object(path: &CombinedDiffPath, find: Option<&ObjectId>) -> bool {
133    let Some(target) = find else {
134        return true;
135    };
136    if path.merge_oid == *target {
137        return true;
138    }
139    path.parents.iter().any(|p| p.oid == *target)
140}
141
142fn emit_combined_path(
143    path: String,
144    merge_entry: Option<&TreeEntry>,
145    tp: &[Vec<TreeEntry>],
146    tp_idx: &[usize],
147    parent_neq: &[bool],
148    find_object: Option<&ObjectId>,
149    out: &mut Vec<CombinedDiffPath>,
150) {
151    let nparent = tp.len();
152    let mut parents = Vec::with_capacity(nparent);
153
154    if let Some(te) = merge_entry {
155        for i in 0..nparent {
156            let tpi_valid = !parent_neq[i];
157            let (mode_i, oid_i, status) = if tpi_valid {
158                let e = &tp[i][tp_idx[i]];
159                (e.mode, e.oid, CombinedParentStatus::Modified)
160            } else {
161                (0u32, zero_oid(), CombinedParentStatus::Added)
162            };
163            parents.push(CombinedParentSide {
164                mode: mode_i,
165                oid: oid_i,
166                status,
167            });
168        }
169        let p = CombinedDiffPath {
170            path,
171            merge_mode: te.mode,
172            merge_oid: te.oid,
173            parents,
174        };
175        if combined_matches_find_object(&p, find_object) {
176            out.push(p);
177        }
178    } else {
179        for i in 0..nparent {
180            let tpi_valid = !parent_neq[i];
181            let (mode_i, oid_i, status) = if tpi_valid {
182                let e = &tp[i][tp_idx[i]];
183                (e.mode, e.oid, CombinedParentStatus::Deleted)
184            } else {
185                (0u32, zero_oid(), CombinedParentStatus::Added)
186            };
187            parents.push(CombinedParentSide {
188                mode: mode_i,
189                oid: oid_i,
190                status,
191            });
192        }
193        let p = CombinedDiffPath {
194            path,
195            merge_mode: 0,
196            merge_oid: zero_oid(),
197            parents,
198        };
199        if combined_matches_find_object(&p, find_object) {
200            out.push(p);
201        }
202    }
203}
204
205fn should_recurse_dir(_path: &str, _opt: &CombinedTreeDiffOptions) -> bool {
206    // Git's `diff_tree_paths` / combined merge diff always recurses into subtrees
207    // (see `diff_tree_combined`: `diffopts.flags.recursive = 1`).
208    true
209}
210
211fn ll_diff_tree_paths(
212    odb: &Odb,
213    out: &mut Vec<CombinedDiffPath>,
214    base_path: &str,
215    opt: &CombinedTreeDiffOptions,
216    merge_oid: Option<&ObjectId>,
217    parents_oid: &[Option<ObjectId>],
218    find_object: Option<&ObjectId>,
219) -> Result<()> {
220    let nparent = parents_oid.len();
221    let t_entries = read_tree_entries(odb, merge_oid)?;
222    let mut tp_entries: Vec<Vec<TreeEntry>> = Vec::with_capacity(nparent);
223    for po in parents_oid {
224        tp_entries.push(read_tree_entries(odb, po.as_ref())?);
225    }
226
227    let mut ti = 0usize;
228    let mut tp_idx = vec![0usize; nparent];
229
230    loop {
231        let t_cur = t_entries.get(ti);
232
233        if t_cur.is_none() && (0..nparent).all(|i| tp_idx[i] >= tp_entries[i].len()) {
234            break;
235        }
236
237        let mut imin = 0usize;
238        if nparent > 0 {
239            for i in 1..nparent {
240                let e_imin = tp_entries[imin].get(tp_idx[imin]);
241                let e_i = tp_entries[i].get(tp_idx[i]);
242                if tree_entry_pathcmp(e_i, e_imin) == std::cmp::Ordering::Less {
243                    imin = i;
244                }
245            }
246        }
247
248        let mut parent_neq = vec![false; nparent];
249        for i in 0..nparent {
250            let e_imin = tp_entries[imin].get(tp_idx[imin]);
251            let e_i = tp_entries[i].get(tp_idx[i]);
252            parent_neq[i] = tree_entry_pathcmp(e_i, e_imin) != std::cmp::Ordering::Equal;
253        }
254        for ne in parent_neq.iter_mut().take(imin) {
255            *ne = true;
256        }
257
258        let p_min = tp_entries[imin].get(tp_idx[imin]);
259
260        let cmp = tree_entry_pathcmp(t_cur, p_min);
261
262        if cmp == std::cmp::Ordering::Equal {
263            if let Some(te) = t_cur {
264                let mut skip_emit = true;
265                for i in 0..nparent {
266                    if parent_neq[i] {
267                        continue;
268                    }
269                    let Some(pe) = tp_entries[i].get(tp_idx[i]) else {
270                        skip_emit = false;
271                        break;
272                    };
273                    if pe.oid != te.oid || pe.mode != te.mode {
274                        skip_emit = false;
275                        break;
276                    }
277                }
278
279                if !skip_emit {
280                    let name = std::str::from_utf8(&te.name).unwrap_or("");
281                    let full_path = if base_path.is_empty() {
282                        name.to_string()
283                    } else {
284                        format!("{base_path}/{name}")
285                    };
286
287                    let isdir = is_tree_mode(te.mode);
288                    let mut do_emit = true;
289                    if isdir && should_recurse_dir(&full_path, opt) {
290                        do_emit = opt.tree_in_recursive;
291                    }
292
293                    if do_emit {
294                        emit_combined_path(
295                            full_path.clone(),
296                            Some(te),
297                            &tp_entries,
298                            &tp_idx,
299                            &parent_neq,
300                            find_object,
301                            out,
302                        );
303                    }
304
305                    if isdir && should_recurse_dir(&full_path, opt) {
306                        let merge_child = te.oid;
307                        let mut child_parent_opts = vec![None; nparent];
308                        for i in 0..nparent {
309                            if parent_neq[i] {
310                                continue;
311                            }
312                            if let Some(pe) = tp_entries[i].get(tp_idx[i]) {
313                                if tree_entry_cmp(
314                                    &pe.name,
315                                    is_tree_mode(pe.mode),
316                                    &te.name,
317                                    is_tree_mode(te.mode),
318                                ) == std::cmp::Ordering::Equal
319                                {
320                                    child_parent_opts[i] = Some(pe.oid);
321                                }
322                            }
323                        }
324                        ll_diff_tree_paths(
325                            odb,
326                            out,
327                            &full_path,
328                            opt,
329                            Some(&merge_child),
330                            &child_parent_opts,
331                            find_object,
332                        )?;
333                    }
334                }
335            }
336
337            ti += 1;
338            for i in 0..nparent {
339                if !parent_neq[i] {
340                    tp_idx[i] += 1;
341                }
342            }
343        } else if cmp == std::cmp::Ordering::Less {
344            let Some(te) = t_cur else {
345                ti += 1;
346                continue;
347            };
348            let name = std::str::from_utf8(&te.name).unwrap_or("");
349            let full_path = if base_path.is_empty() {
350                name.to_string()
351            } else {
352                format!("{base_path}/{name}")
353            };
354            let isdir = is_tree_mode(te.mode);
355            let mut do_emit = true;
356            if isdir && should_recurse_dir(&full_path, opt) {
357                do_emit = opt.tree_in_recursive;
358            }
359            if do_emit {
360                let all_parents_absent: Vec<bool> = vec![true; nparent];
361                emit_combined_path(
362                    full_path.clone(),
363                    Some(te),
364                    &tp_entries,
365                    &tp_idx,
366                    &all_parents_absent,
367                    find_object,
368                    out,
369                );
370            }
371            if isdir && should_recurse_dir(&full_path, opt) {
372                let merge_child = te.oid;
373                let child_parent_opts = vec![None; nparent];
374                ll_diff_tree_paths(
375                    odb,
376                    out,
377                    &full_path,
378                    opt,
379                    Some(&merge_child),
380                    &child_parent_opts,
381                    find_object,
382                )?;
383            }
384            ti += 1;
385        } else {
386            let skip_emit_tp = (0..nparent).all(|i| parent_neq[i]);
387            if !skip_emit_tp {
388                if let Some(pe) = p_min {
389                    let name = std::str::from_utf8(&pe.name).unwrap_or("");
390                    let full_path = if base_path.is_empty() {
391                        name.to_string()
392                    } else {
393                        format!("{base_path}/{name}")
394                    };
395                    let isdir = is_tree_mode(pe.mode);
396                    let mut do_emit = true;
397                    if isdir && should_recurse_dir(&full_path, opt) {
398                        do_emit = opt.tree_in_recursive;
399                    }
400                    if do_emit {
401                        emit_combined_path(
402                            full_path.clone(),
403                            None,
404                            &tp_entries,
405                            &tp_idx,
406                            &parent_neq,
407                            find_object,
408                            out,
409                        );
410                    }
411                    if isdir && should_recurse_dir(&full_path, opt) {
412                        let mut child_parent_opts = vec![None; nparent];
413                        for i in 0..nparent {
414                            if !parent_neq[i] {
415                                if let Some(e) = tp_entries[i].get(tp_idx[i]) {
416                                    child_parent_opts[i] = Some(e.oid);
417                                }
418                            }
419                        }
420                        ll_diff_tree_paths(
421                            odb,
422                            out,
423                            &full_path,
424                            opt,
425                            None,
426                            &child_parent_opts,
427                            find_object,
428                        )?;
429                    }
430                }
431            }
432            for i in 0..nparent {
433                if !parent_neq[i] {
434                    tp_idx[i] += 1;
435                }
436            }
437        }
438    }
439
440    Ok(())
441}
442
443/// Build combined-diff paths for a merge commit's tree against each parent's tree.
444pub fn combined_diff_paths_filtered(
445    odb: &Odb,
446    commit_tree: &ObjectId,
447    parents: &[ObjectId],
448    walk: &CombinedTreeDiffOptions,
449    find_object: Option<&ObjectId>,
450) -> Result<Vec<CombinedDiffPath>> {
451    if parents.is_empty() {
452        return Ok(Vec::new());
453    }
454    let mut parent_trees = Vec::with_capacity(parents.len());
455    for p in parents {
456        let obj = odb.read(p)?;
457        if obj.kind != ObjectKind::Commit {
458            return Ok(Vec::new());
459        }
460        let c = parse_commit(&obj.data)?;
461        parent_trees.push(Some(c.tree));
462    }
463    let parent_opts: Vec<Option<ObjectId>> = parent_trees;
464    combined_diff_paths_trees(odb, commit_tree, &parent_opts, walk, find_object)
465}
466
467/// Combined diff paths when `parents` are already tree OIDs (plumbing `git diff --cc` with N+1 trees).
468pub fn combined_diff_paths_trees(
469    odb: &Odb,
470    merge_tree: &ObjectId,
471    parent_trees: &[Option<ObjectId>],
472    walk: &CombinedTreeDiffOptions,
473    find_object: Option<&ObjectId>,
474) -> Result<Vec<CombinedDiffPath>> {
475    if parent_trees.is_empty() {
476        return Ok(Vec::new());
477    }
478    let mut out = Vec::new();
479    ll_diff_tree_paths(
480        odb,
481        &mut out,
482        "",
483        walk,
484        Some(merge_tree),
485        parent_trees,
486        find_object,
487    )?;
488    Ok(out)
489}