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