1use 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#[non_exhaustive]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum DiffMerges {
33 FirstParent,
35 #[default]
37 Combined,
38}
39
40impl DiffMerges {
41 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#[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
176pub(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#[must_use]
451pub fn combined_structured_patch_body(diff: &CombinedStructuredTextDiff) -> String {
452 format_combined_structured_body(diff)
453}