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