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::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#[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 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#[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
179pub(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#[must_use]
463pub fn combined_structured_patch_body(diff: &CombinedStructuredTextDiff) -> String {
464 format_combined_structured_body(diff)
465}