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