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