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 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#[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
194pub(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#[must_use]
454pub fn combined_structured_patch_body(diff: &CombinedStructuredTextDiff) -> String {
455 format_combined_structured_body(diff)
456}