Skip to main content

grit_lib/diff/
merge_commit.rs

1//! Diff the change one merge commit introduced (first-parent or combined `--cc`).
2
3use std::path::Path;
4
5use crate::config::ConfigSet;
6use crate::diff::combined_diff_patch::{
7    format_combined_structured_body, structured_combined_diff_body, CombinedDiffWsOptions,
8    CombinedStructuredTextDiff,
9};
10use crate::diff::combined_tree_diff::{
11    combined_diff_paths_trees, combined_parent_status_char, CombinedDiffPath,
12    CombinedTreeDiffOptions,
13};
14use crate::diff::detail::{DiffDetail, DiffTotals, FileDiff, LineStat};
15use crate::diff::files::{build_file_diffs, DiffFilesOutput};
16use crate::diff::merge_diff::{
17    blob_text_for_diff, enrich_combined_path_renames, format_combined_binary, is_binary_for_diff,
18    read_blob_at_path,
19};
20use crate::diff::{DiffEntry, DiffOptions, DiffStatus};
21use crate::error::Result;
22use crate::objects::pack_store::PackReadContextGuard;
23use crate::objects::{parse_commit, ObjectId};
24use crate::odb::Odb;
25use crate::parallel::{try_par_map_with_local, ParallelHashError, ParallelOp, ThreadPolicy};
26use crate::repo::Repository;
27use std::sync::Arc;
28
29/// How to diff a merge commit's tree against its parents.
30#[non_exhaustive]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum DiffMerges {
33    /// Diff merge result against the first parent only (non-merge commits always use this).
34    FirstParent,
35    /// Dense combined diff against every parent (`git show --cc`).
36    #[default]
37    Combined,
38}
39
40impl DiffMerges {
41    /// Parse `first-parent` or `combined` (Git `--diff-merges` spelling).
42    ///
43    /// # Errors
44    ///
45    /// Returns an error when `value` is not a known mode name.
46    pub fn parse(value: &str) -> std::result::Result<Self, String> {
47        match value {
48            "first-parent" | "first_parent" => Ok(Self::FirstParent),
49            "combined" | "dense-combined" | "dense_combined" => Ok(Self::Combined),
50            other => Err(format!(
51                "unknown --diff-merges mode '{other}' (expected first-parent or combined)"
52            )),
53        }
54    }
55}
56
57fn combined_ws(_options: &DiffOptions) -> CombinedDiffWsOptions {
58    CombinedDiffWsOptions::default()
59}
60
61fn combined_status_letters(path: &CombinedDiffPath) -> String {
62    path.parents
63        .iter()
64        .map(|s| combined_parent_status_char(s.status))
65        .collect()
66}
67
68fn file_diff_from_combined_path(path: CombinedDiffPath) -> FileDiff {
69    let combined_status = combined_status_letters(&path);
70    FileDiff {
71        path: path.path,
72        old_path: None,
73        status: DiffStatus::Modified,
74        old_mode: None,
75        new_mode: None,
76        binary: false,
77        similarity: None,
78        stat: None,
79        structured: None,
80        combined: None,
81        combined_status: Some(combined_status),
82    }
83}
84
85fn filter_combined_paths(paths: Vec<String>, pathspecs: &[String]) -> Vec<String> {
86    if pathspecs.is_empty() {
87        return paths;
88    }
89    paths
90        .into_iter()
91        .filter(|p| crate::pathspec::path_allowed_by_pathspec_list(pathspecs, p))
92        .collect()
93}
94
95/// Build per-file diffs for one commit, using combined merge diff when requested.
96///
97/// # Errors
98///
99/// Propagates object read, tree walk, and diff errors.
100#[allow(clippy::too_many_arguments)]
101pub fn commit_diff_files(
102    repo: &Repository,
103    commit_oid: &ObjectId,
104    merges: DiffMerges,
105    detail: DiffDetail,
106    options: &DiffOptions,
107    pathspecs: &[String],
108    config: &ConfigSet,
109    thread_policy: &ThreadPolicy,
110) -> Result<DiffFilesOutput> {
111    let data = repo.odb().read(commit_oid)?.data;
112    let commit = parse_commit(&data)?;
113    if commit.parents.len() < 2 || merges == DiffMerges::FirstParent {
114        let old_tree = match commit.parents.first() {
115            Some(parent) => Some(repo.odb().commit_tree(parent)?),
116            None => None,
117        };
118        let entries = tree_diff_entries(
119            repo,
120            old_tree.as_ref(),
121            Some(&commit.tree),
122            "",
123            pathspecs,
124            config,
125            thread_policy,
126        )?;
127        let filtered = filter_entries(repo, entries, pathspecs)?;
128        let mut out = build_file_diffs(
129            repo.odb(),
130            filtered,
131            detail,
132            options
133                .clone()
134                .with_renames(crate::diff::RenameDetection::Off),
135            config,
136            thread_policy,
137        )?;
138        out.files.sort_by(|a, b| a.path.cmp(&b.path));
139        return Ok(out);
140    }
141
142    combined_commit_diff_files(
143        repo,
144        &commit.tree,
145        &commit.parents,
146        detail,
147        options,
148        pathspecs,
149        config,
150        thread_policy,
151    )
152}
153
154fn filter_entries(
155    repo: &Repository,
156    entries: Vec<DiffEntry>,
157    pathspecs: &[String],
158) -> Result<Vec<DiffEntry>> {
159    if pathspecs.is_empty() {
160        return Ok(entries);
161    }
162    let matcher = repo.pathspec_matcher(pathspecs)?;
163    Ok(entries
164        .into_iter()
165        .filter(|entry| {
166            let path = entry
167                .new_path
168                .as_deref()
169                .or(entry.old_path.as_deref())
170                .unwrap_or("");
171            matcher.matches_change(path, entry.old_path.as_deref())
172        })
173        .collect())
174}
175
176/// Tree diff entries for one commit vs its first parent (rename-aware).
177///
178/// # Errors
179///
180/// Propagates ODB and diff errors.
181pub(crate) fn commit_first_parent_diff_entries(
182    repo: &Repository,
183    commit: &crate::objects::CommitData,
184    pathspecs: &[String],
185    config: &ConfigSet,
186    thread_policy: &ThreadPolicy,
187) -> Result<Vec<DiffEntry>> {
188    let odb = repo.odb();
189    let old_tree = match commit.parents.first() {
190        Some(parent) => Some(odb.commit_tree(parent)?),
191        None => None,
192    };
193    let entries = tree_diff_entries(
194        repo,
195        old_tree.as_ref(),
196        Some(&commit.tree),
197        "",
198        pathspecs,
199        config,
200        thread_policy,
201    )?;
202    filter_entries(repo, entries, pathspecs)
203}
204
205fn tree_diff_entries(
206    repo: &Repository,
207    old_tree: Option<&ObjectId>,
208    new_tree: Option<&ObjectId>,
209    prefix: &str,
210    pathspecs: &[String],
211    config: &ConfigSet,
212    thread_policy: &ThreadPolicy,
213) -> Result<Vec<DiffEntry>> {
214    let threshold = crate::diff::renames_from_config(config)
215        .threshold_percent()
216        .unwrap_or(crate::diff::RENAME_DETECTION_DISABLED);
217    let pathspec_matcher = if pathspecs.is_empty() {
218        None
219    } else {
220        Some(repo.pathspec_matcher(pathspecs)?)
221    };
222    crate::diff::core::diff_trees_with_renames_for_pathspecs_and_policy(
223        repo.odb(),
224        old_tree,
225        new_tree,
226        prefix,
227        pathspecs,
228        pathspec_matcher.as_ref(),
229        threshold,
230        config,
231        thread_policy,
232    )
233}
234
235#[allow(clippy::too_many_arguments)]
236fn combined_commit_diff_files(
237    repo: &Repository,
238    result_tree: &ObjectId,
239    parent_commits: &[ObjectId],
240    detail: DiffDetail,
241    options: &DiffOptions,
242    pathspecs: &[String],
243    config: &ConfigSet,
244    thread_policy: &ThreadPolicy,
245) -> Result<DiffFilesOutput> {
246    let odb = repo.odb();
247    let mut parent_trees = Vec::with_capacity(parent_commits.len());
248    for p in parent_commits {
249        parent_trees.push(odb.commit_tree(p)?);
250    }
251
252    let walk = CombinedTreeDiffOptions::default();
253    let parent_tree_opts: Vec<Option<ObjectId>> = parent_trees.iter().copied().map(Some).collect();
254    let mut paths = combined_diff_paths_trees(odb, result_tree, &parent_tree_opts, &walk, None)?;
255    let path_names: Vec<String> = paths.iter().map(|p| p.path.clone()).collect();
256    let filtered_names = filter_combined_paths(path_names, pathspecs);
257    paths.retain(|p| filtered_names.iter().any(|n| n == &p.path));
258
259    for path in &mut paths {
260        enrich_combined_path_renames(odb, path, &parent_trees, result_tree, 50);
261    }
262
263    if detail == DiffDetail::NameOnly {
264        let files: Vec<FileDiff> = paths
265            .into_iter()
266            .map(|path| FileDiff {
267                path: path.path,
268                old_path: None,
269                status: DiffStatus::Modified,
270                old_mode: None,
271                new_mode: None,
272                binary: false,
273                similarity: None,
274                stat: None,
275                structured: None,
276                combined: None,
277                combined_status: None,
278            })
279            .collect();
280        return Ok(DiffFilesOutput {
281            files,
282            totals: None,
283            inexact_rename_skipped: None,
284        });
285    }
286    if detail == DiffDetail::NameStatus {
287        let files: Vec<FileDiff> = paths
288            .into_iter()
289            .map(file_diff_from_combined_path)
290            .collect();
291        return Ok(DiffFilesOutput {
292            files,
293            totals: None,
294            inexact_rename_skipped: None,
295        });
296    }
297
298    let git_dir = repo.git_dir();
299    let ws = combined_ws(options);
300    let context = options.context_lines;
301    let par_opts = thread_policy.resolve(ParallelOp::DiffContent, config);
302    let store = odb.pack_store_for(odb.objects_dir());
303    let result_tree = *result_tree;
304    let rows = try_par_map_with_local(
305        &paths,
306        par_opts,
307        0,
308        || PackReadContextGuard::install(Arc::clone(&store)),
309        |_ctx, path| {
310            combined_path_file_diff(git_dir, config, odb, path, result_tree, detail, context, ws)
311        },
312    );
313    let rows = match rows {
314        Ok(rows) => rows,
315        Err(ParallelHashError::Task(err)) => return Err(err),
316    };
317
318    let mut files = Vec::with_capacity(rows.len());
319    let mut insertions = 0usize;
320    let mut deletions = 0usize;
321    for (file, stat) in rows {
322        if let Some(s) = stat {
323            insertions += s.insertions;
324            deletions += s.deletions;
325        }
326        files.push(file);
327    }
328    files.sort_by(|a, b| a.path.cmp(&b.path));
329
330    let totals = if detail == DiffDetail::Stat {
331        Some(DiffTotals {
332            files_changed: files.len(),
333            insertions,
334            deletions,
335        })
336    } else {
337        None
338    };
339    Ok(DiffFilesOutput {
340        files,
341        totals,
342        inexact_rename_skipped: None,
343    })
344}
345
346#[allow(clippy::too_many_arguments)]
347fn combined_path_file_diff(
348    git_dir: &Path,
349    config: &ConfigSet,
350    odb: &Odb,
351    path: &CombinedDiffPath,
352    result_tree: ObjectId,
353    detail: DiffDetail,
354    context: usize,
355    ws: CombinedDiffWsOptions,
356) -> Result<(FileDiff, Option<LineStat>)> {
357    let (parent_blobs, result_blob) = load_combined_path_blobs(odb, path, &result_tree)?;
358    let binary =
359        combined_path_is_binary_from_blobs(git_dir, &path.path, &parent_blobs, &result_blob);
360    if binary {
361        let parent_oids: Vec<ObjectId> = path.parents.iter().map(|s| s.oid).collect();
362        let _patch = format_combined_binary(&path.path, &parent_oids, &path.merge_oid, 7, true);
363        return Ok((
364            FileDiff {
365                path: path.path.clone(),
366                old_path: None,
367                status: DiffStatus::Modified,
368                old_mode: None,
369                new_mode: None,
370                binary: true,
371                similarity: None,
372                stat: if detail == DiffDetail::Stat {
373                    Some(LineStat::default())
374                } else {
375                    None
376                },
377                structured: None,
378                combined: None,
379                combined_status: Some(combined_status_letters(path)),
380            },
381            None,
382        ));
383    }
384
385    let mut parent_texts = Vec::with_capacity(path.parents.len());
386    for (i, side) in path.parents.iter().enumerate() {
387        let text = if side.oid.is_zero() {
388            String::new()
389        } else {
390            blob_text_for_diff(git_dir, config, &path.path, &parent_blobs[i], false)
391        };
392        parent_texts.push(text);
393    }
394    let result_text = blob_text_for_diff(git_dir, config, &path.path, &result_blob, false);
395    let combined = structured_combined_diff_body(&parent_texts, &result_text, context, true, ws);
396    let stat = match detail {
397        DiffDetail::Stat => Some(combined.line_stat()),
398        _ => None,
399    };
400    Ok((
401        FileDiff {
402            path: path.path.clone(),
403            old_path: None,
404            status: DiffStatus::Modified,
405            old_mode: None,
406            new_mode: None,
407            binary: false,
408            similarity: None,
409            stat,
410            structured: None,
411            combined: Some(combined),
412            combined_status: Some(combined_status_letters(path)),
413        },
414        stat,
415    ))
416}
417
418fn load_combined_path_blobs(
419    odb: &Odb,
420    path: &CombinedDiffPath,
421    result_tree: &ObjectId,
422) -> Result<(Vec<Vec<u8>>, Vec<u8>)> {
423    let mut parent_blobs = Vec::with_capacity(path.parents.len());
424    for side in &path.parents {
425        if side.oid.is_zero() {
426            parent_blobs.push(Vec::new());
427        } else {
428            parent_blobs.push(odb.read(&side.oid)?.data);
429        }
430    }
431    let result_blob = read_blob_at_path(odb, result_tree, &path.path).unwrap_or_default();
432    Ok((parent_blobs, result_blob))
433}
434
435fn combined_path_is_binary_from_blobs(
436    git_dir: &Path,
437    path: &str,
438    parent_blobs: &[Vec<u8>],
439    result_blob: &[u8],
440) -> bool {
441    if is_binary_for_diff(git_dir, path, result_blob) {
442        return true;
443    }
444    parent_blobs
445        .iter()
446        .any(|blob| !blob.is_empty() && is_binary_for_diff(git_dir, path, blob))
447}
448
449/// Format a [`CombinedStructuredTextDiff`] as Git combined patch body bytes.
450#[must_use]
451pub fn combined_structured_patch_body(diff: &CombinedStructuredTextDiff) -> String {
452    format_combined_structured_body(diff)
453}