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