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