1use std::collections::BTreeSet;
4use std::path::Path;
5use std::sync::{Arc, Mutex};
6
7use thiserror::Error;
8
9use crate::config::ConfigSet;
10use crate::diff::diff_trees_with_renames_and_policy;
11use crate::diff::structured::{
12 structured_file_diff, SideBytes, StructuredFileDiff, StructuredHunk,
13};
14use crate::diff::{
15 apply_rename_detection_to_diff_entries, diff_commits_with_renames_and_policy,
16 diff_index_to_tree_with_renames, diff_index_to_worktree_with_renames,
17 diff_tree_to_worktree_with_git_dir_and_rules, index_mtime_for_diff, read_submodule_head_oid,
18 read_worktree_side_bytes_for_diff, submodule_porcelain_flags, tree_diff_structured_parallel,
19 DiffEntry, DiffIndexToWorktreeOptions, DiffOptions, DiffStatus, TreeDiffStructured,
20};
21use crate::error::{Error, Result};
22use crate::index::Index;
23use crate::index::MODE_TREE;
24use crate::objects::odb::Odb;
25use crate::objects::{parse_commit, parse_tree, CommitData, ObjectId, ObjectKind};
26use crate::parallel::{par_map, ParallelOp, ThreadPolicy};
27use crate::pathspec::{exact_literal_pathspec_set, PathspecMatchContext, PathspecMatcher};
28use crate::porcelain::pathspec_match::{
29 ensure_pathspec_matches_known_in_repository, pathspec_matches_known_in_repository,
30};
31use crate::repo::Repository;
32use crate::revwalk::merge_base::merge_base_for_diff_two_commits;
33use crate::revwalk::rev_parse::{
34 peel_to_commit_for_merge_base, resolve_revision, resolve_revision_for_range_end,
35 split_double_dot_range, split_triple_dot_range,
36};
37use crate::state::resolve_head;
38use crate::worktree::fsmonitor::fsmonitor_enabled;
39use crate::worktree::pathspec::resolve_pathspec;
40use crate::worktree::worktree_rules::WorktreeRules;
41
42#[non_exhaustive]
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum DiffTarget {
46 WorktreeVsHead,
48 WorktreeVsTree(ObjectId),
50 IndexVsHead,
52 WorktreeVsIndex,
54 Commit(ObjectId),
56 Trees {
58 old: Option<ObjectId>,
60 new: ObjectId,
62 },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct FileDiff {
68 pub path: String,
70 pub old_path: Option<String>,
72 pub status: DiffStatus,
74 pub old_mode: Option<String>,
76 pub new_mode: Option<String>,
78 pub old_oid: ObjectId,
80 pub new_oid: ObjectId,
82 pub binary: bool,
84 pub content_changed: bool,
86 pub old_encoding_lossy: bool,
88 pub new_encoding_lossy: bool,
90 pub hunks: Vec<StructuredHunk>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct DiffArguments {
97 pub target: DiffTarget,
99 pub pathspecs: Vec<String>,
101}
102
103#[non_exhaustive]
105#[derive(Debug, Error, Clone, PartialEq, Eq)]
106pub enum ResolveDiffArgumentsError {
107 #[error("--staged and --unstaged cannot be combined")]
109 StagedAndUnstaged,
110
111 #[error("--staged cannot be combined with revision arguments or --against")]
113 StagedWithRevisions,
114
115 #[error("--unstaged cannot be combined with revision arguments or --against")]
117 UnstagedWithRevisions,
118
119 #[error("at most two commits, or one two-dot range such as A..B")]
121 TooManyRevisions,
122
123 #[error("--against compares the worktree to a ref and takes no commit arguments")]
125 AgainstWithCommits,
126
127 #[error(
129 "a two-dot or three-dot range uses the other positional as a path limit; pass paths after `--`"
130 )]
131 RangeWithSecondRevision,
132
133 #[error("{0}")]
135 Message(String),
136}
137
138impl From<Error> for ResolveDiffArgumentsError {
139 fn from(err: Error) -> Self {
140 match err {
141 Error::InvalidRef(msg) | Error::Message(msg) => Self::Message(msg),
142 other => Self::Message(other.to_string()),
143 }
144 }
145}
146
147pub fn resolve_diff_arguments(
155 repo: &Repository,
156 cwd_prefix: Option<&str>,
157 staged: bool,
158 unstaged: bool,
159 against: Option<&str>,
160 positionals: &[String],
161 explicit_paths: &[String],
162) -> std::result::Result<DiffArguments, ResolveDiffArgumentsError> {
163 let (commits, path_args) = disambiguate_diff_revision_and_paths(
164 repo,
165 cwd_prefix,
166 positionals.to_vec(),
167 explicit_paths.to_vec(),
168 );
169
170 if staged && unstaged {
171 return Err(ResolveDiffArgumentsError::StagedAndUnstaged);
172 }
173 if staged {
174 if !commits.is_empty() || against.is_some() {
175 return Err(ResolveDiffArgumentsError::StagedWithRevisions);
176 }
177 let pathspecs = resolve_worktree_diff_pathspecs(repo, cwd_prefix, &path_args)?;
178 return Ok(DiffArguments {
179 target: DiffTarget::IndexVsHead,
180 pathspecs,
181 });
182 }
183 if unstaged {
184 if !commits.is_empty() || against.is_some() {
185 return Err(ResolveDiffArgumentsError::UnstagedWithRevisions);
186 }
187 let pathspecs = resolve_worktree_diff_pathspecs(repo, cwd_prefix, &path_args)?;
188 return Ok(DiffArguments {
189 target: DiffTarget::WorktreeVsIndex,
190 pathspecs,
191 });
192 }
193 if commits.len() > 2 {
194 return Err(ResolveDiffArgumentsError::TooManyRevisions);
195 }
196 if against.is_some() && !commits.is_empty() {
197 return Err(ResolveDiffArgumentsError::AgainstWithCommits);
198 }
199
200 match commits.len() {
201 0 => {
202 let pathspecs = resolve_worktree_diff_pathspecs(repo, cwd_prefix, &path_args)?;
203 let target = if let Some(spec) = against {
204 let commit = resolve_diff_commit(repo, spec)?;
205 let tree = commit_tree(repo, &commit)?;
206 DiffTarget::WorktreeVsTree(tree)
207 } else {
208 DiffTarget::WorktreeVsHead
209 };
210 Ok(DiffArguments { target, pathspecs })
211 }
212 1 => {
213 let spec = &commits[0];
214 if let Some((base, tip)) = parse_commit_range(repo, spec)? {
215 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_args)?;
216 return Ok(DiffArguments {
217 target: DiffTarget::Trees {
218 old: Some(base),
219 new: tip,
220 },
221 pathspecs,
222 });
223 }
224 if let Ok(oid) = resolve_diff_commit(repo, spec) {
225 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_args)?;
226 return Ok(DiffArguments {
227 target: DiffTarget::Commit(oid),
228 pathspecs,
229 });
230 }
231 if path_args.is_empty() {
232 let pathspecs =
233 resolve_worktree_diff_pathspecs(repo, cwd_prefix, std::slice::from_ref(spec))?;
234 Ok(DiffArguments {
235 target: DiffTarget::WorktreeVsIndex,
236 pathspecs,
237 })
238 } else if let Err(e) = resolve_diff_commit(repo, spec) {
239 Err(e.into())
240 } else {
241 Err(ResolveDiffArgumentsError::Message(
242 "revision unresolved".into(),
243 ))
244 }
245 }
246 2 => {
247 if let Some((base, tip)) = parse_commit_range(repo, &commits[0])? {
248 if token_is_known_worktree_pathspec(repo, cwd_prefix, &commits[1]) {
249 let pathspecs = resolve_commit_diff_pathspecs(
250 repo,
251 cwd_prefix,
252 std::slice::from_ref(&commits[1]),
253 )?;
254 return Ok(DiffArguments {
255 target: DiffTarget::Trees {
256 old: Some(base),
257 new: tip,
258 },
259 pathspecs,
260 });
261 }
262 if resolve_diff_commit(repo, &commits[1]).is_ok() {
263 return Err(ResolveDiffArgumentsError::RangeWithSecondRevision);
264 }
265 if let Err(e) = resolve_diff_commit(repo, &commits[1]) {
266 return Err(e.into());
267 }
268 return Err(ResolveDiffArgumentsError::Message(
269 "could not resolve second revision".into(),
270 ));
271 }
272 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_args)?;
273 let base = resolve_diff_commit(repo, &commits[0])?;
274 let tip = resolve_diff_commit(repo, &commits[1])?;
275 Ok(DiffArguments {
276 target: DiffTarget::Trees {
277 old: Some(base),
278 new: tip,
279 },
280 pathspecs,
281 })
282 }
283 _ => unreachable!("checked above"),
284 }
285}
286
287fn include_file_in_diff(file: &FileDiff, opts: &DiffOptions) -> bool {
288 if !opts.whitespace.is_active() || file.binary {
289 return true;
290 }
291 if matches!(
292 file.status,
293 DiffStatus::Renamed
294 | DiffStatus::Added
295 | DiffStatus::Deleted
296 | DiffStatus::Copied
297 | DiffStatus::TypeChanged
298 ) {
299 return true;
300 }
301 if file.old_mode != file.new_mode {
302 return true;
303 }
304 !file.hunks.is_empty()
305}
306
307pub(crate) fn collect_files_for_target(
308 repo: &Repository,
309 target: &DiffTarget,
310 pathspecs: &[String],
311 opts: DiffOptions,
312) -> Result<Vec<FileDiff>> {
313 let config = repo.config()?;
314 let thread_policy = repo.thread_policy();
315 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
317
318 let files = match target {
319 DiffTarget::WorktreeVsHead => {
320 let head = resolve_head(repo.git_dir())?;
321 let head_tree = match head.oid() {
322 Some(oid) => Some(commit_tree(repo, oid)?),
323 None => None,
324 };
325 let changes = filter_file_changes(
326 worktree_changes(repo, head_tree.as_ref(), rename_threshold)?,
327 repo,
328 pathspecs,
329 )?;
330 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
331 }
332 DiffTarget::WorktreeVsTree(tree) => {
333 let changes = filter_file_changes(
334 worktree_changes(repo, Some(tree), rename_threshold)?,
335 repo,
336 pathspecs,
337 )?;
338 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
339 }
340 DiffTarget::IndexVsHead => {
341 let restrict = pathspec_restrict_set(pathspecs);
342 let changes = filter_file_changes(
343 staged_changes(repo, rename_threshold, restrict)?,
344 repo,
345 pathspecs,
346 )?;
347 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
348 }
349 DiffTarget::WorktreeVsIndex => {
350 let changes =
351 filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
352 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
353 }
354 DiffTarget::Commit(oid) => {
355 let items = commit_changes(
356 repo,
357 oid,
358 &config,
359 &thread_policy,
360 opts.clone(),
361 rename_threshold,
362 )?;
363 filter_and_map_tree_structured(items, repo, pathspecs, opts)?
364 }
365 DiffTarget::Trees { old, new } => {
366 let items = if let Some(base) = old {
367 tree_commit_changes(
368 repo,
369 base,
370 new,
371 &config,
372 &thread_policy,
373 opts.clone(),
374 rename_threshold,
375 )?
376 } else {
377 commit_changes(
378 repo,
379 new,
380 &config,
381 &thread_policy,
382 opts.clone(),
383 rename_threshold,
384 )?
385 };
386 filter_and_map_tree_structured(items, repo, pathspecs, opts)?
387 }
388 };
389 Ok(files)
390}
391
392fn filter_and_map_tree_structured(
393 items: Vec<TreeDiffStructured>,
394 repo: &Repository,
395 pathspecs: &[String],
396 opts: DiffOptions,
397) -> Result<Vec<FileDiff>> {
398 Ok(filter_tree_diff(items, repo, pathspecs)?
399 .into_iter()
400 .map(file_diff_from_tree_structured)
401 .filter(|f| include_file_in_diff(f, &opts))
402 .collect())
403}
404
405fn file_diff_from_tree_structured(t: TreeDiffStructured) -> FileDiff {
406 let e = t.entry;
407 let path = e
408 .new_path
409 .clone()
410 .or_else(|| e.old_path.clone())
411 .unwrap_or_default();
412 let old_path = e.old_path.filter(|op| *op != path);
413 FileDiff {
414 path,
415 old_path,
416 status: e.status,
417 old_mode: t.structured.old_mode,
418 new_mode: t.structured.new_mode,
419 old_oid: e.old_oid,
420 new_oid: e.new_oid,
421 binary: t.binary,
422 content_changed: e.old_oid != e.new_oid,
423 old_encoding_lossy: t.structured.old_encoding_lossy,
424 new_encoding_lossy: t.structured.new_encoding_lossy,
425 hunks: t.structured.hunks,
426 }
427}
428
429struct WorktreeDiffSide<'a> {
430 work_tree: &'a Path,
431 index: &'a Index,
432 rules: Arc<Mutex<WorktreeRules>>,
433}
434
435#[derive(Clone)]
436pub(crate) struct FileChange {
437 pub(crate) path: String,
438 pub(crate) old_path: Option<String>,
439 pub(crate) status: DiffStatus,
440 pub(crate) old_mode: String,
441 pub(crate) new_mode: String,
442 pub(crate) old_oid: ObjectId,
443 pub(crate) new_oid: ObjectId,
444 pub(crate) old_bytes: SideBytes,
445 pub(crate) new_bytes: SideBytes,
446 pub(crate) binary: bool,
447 pub(crate) content_changed: bool,
448}
449
450fn file_diffs_from_worktree_changes(
451 changes: Vec<FileChange>,
452 config: &ConfigSet,
453 thread_policy: &ThreadPolicy,
454 opts: DiffOptions,
455) -> Result<Vec<FileDiff>> {
456 let opts = opts.cap_word_level_inline_for_changed_files(changes.len());
457 let mut par_opts = thread_policy.resolve(ParallelOp::DiffContent, config);
458 par_opts.min_items = 0;
459 let est_bytes = changes.len().saturating_mul(8192);
460 Ok(par_map(&changes, par_opts, est_bytes, |fc| {
461 build_file_diff(fc, opts.clone())
462 })
463 .into_iter()
464 .filter(|f| include_file_in_diff(f, &opts))
465 .collect())
466}
467
468fn build_file_diff(fc: &FileChange, diff_options: DiffOptions) -> FileDiff {
469 let structured = structured_for_file_change(fc, diff_options);
470 FileDiff {
471 path: fc.path.clone(),
472 old_path: fc.old_path.clone(),
473 status: fc.status,
474 old_mode: structured.old_mode,
475 new_mode: structured.new_mode,
476 old_oid: fc.old_oid,
477 new_oid: fc.new_oid,
478 binary: fc.binary,
479 content_changed: fc.content_changed,
480 old_encoding_lossy: structured.old_encoding_lossy,
481 new_encoding_lossy: structured.new_encoding_lossy,
482 hunks: structured.hunks,
483 }
484}
485
486fn structured_for_file_change(fc: &FileChange, diff_options: DiffOptions) -> StructuredFileDiff {
487 if fc.binary {
488 StructuredFileDiff {
489 old_mode: diff_mode_display(&fc.old_mode),
490 new_mode: diff_mode_display(&fc.new_mode),
491 old_encoding_lossy: fc.old_bytes.encoding_lossy,
492 new_encoding_lossy: fc.new_bytes.encoding_lossy,
493 hunks: Vec::new(),
494 }
495 } else {
496 structured_file_diff(
497 &fc.old_mode,
498 &fc.new_mode,
499 &fc.old_bytes,
500 &fc.new_bytes,
501 diff_options,
502 )
503 }
504}
505
506fn diff_mode_display(mode: &str) -> Option<String> {
507 if mode == "000000" {
508 None
509 } else {
510 Some(mode.to_owned())
511 }
512}
513
514fn token_is_diff_revision(repo: &Repository, token: &str) -> bool {
515 resolve_diff_commit(repo, token).is_ok()
516 || parse_commit_range(repo, token).ok().flatten().is_some()
517}
518
519fn token_is_known_worktree_pathspec(
520 repo: &Repository,
521 cwd_prefix: Option<&str>,
522 token: &str,
523) -> bool {
524 let Ok(resolved) = repo.resolve_worktree_pathspec(token, cwd_prefix) else {
525 return false;
526 };
527 pathspec_matches_known_in_repository(repo, &resolved).unwrap_or(false)
528}
529
530fn disambiguate_diff_revision_and_paths(
531 repo: &Repository,
532 cwd_prefix: Option<&str>,
533 mut commits: Vec<String>,
534 mut paths: Vec<String>,
535) -> (Vec<String>, Vec<String>) {
536 if paths.is_empty() && commits.len() == 2 {
537 let first_rev = token_is_diff_revision(repo, &commits[0]);
538 let second_rev = token_is_diff_revision(repo, &commits[1]);
539 if first_rev
540 && !second_rev
541 && token_is_known_worktree_pathspec(repo, cwd_prefix, &commits[1])
542 {
543 if let Some(path) = commits.pop() {
544 paths.push(path);
545 }
546 }
547 } else if paths.is_empty() {
548 while commits.len() >= 2 {
549 let Some(tail) = commits.last() else {
550 break;
551 };
552 if token_is_diff_revision(repo, tail) {
553 break;
554 }
555 let Some(path) = commits.pop() else {
556 break;
557 };
558 paths.push(path);
559 }
560 }
561 (commits, paths)
562}
563
564fn pathspec_resolution_root(repo: &Repository) -> &Path {
565 repo.work_tree().unwrap_or_else(|| repo.git_dir())
566}
567
568fn resolve_worktree_diff_pathspecs(
569 repo: &Repository,
570 cwd_prefix: Option<&str>,
571 raw_paths: &[String],
572) -> std::result::Result<Vec<String>, ResolveDiffArgumentsError> {
573 if raw_paths.is_empty() {
574 return Ok(Vec::new());
575 }
576 if repo.work_tree().is_none() {
577 return Err(ResolveDiffArgumentsError::Message(
578 "grit diff needs a working tree".into(),
579 ));
580 }
581 let mut specs = Vec::with_capacity(raw_paths.len());
582 for pathspec in raw_paths {
583 let resolved = repo
584 .resolve_worktree_pathspec(pathspec, cwd_prefix)
585 .map_err(|e| ResolveDiffArgumentsError::Message(e.to_string()))?;
586 ensure_pathspec_matches_known_in_repository(repo, pathspec, &resolved)
587 .map_err(|e| ResolveDiffArgumentsError::Message(e.to_string()))?;
588 specs.push(resolved);
589 }
590 Ok(specs)
591}
592
593fn resolve_commit_diff_pathspecs(
594 repo: &Repository,
595 cwd_prefix: Option<&str>,
596 raw_paths: &[String],
597) -> std::result::Result<Vec<String>, ResolveDiffArgumentsError> {
598 if raw_paths.is_empty() {
599 return Ok(Vec::new());
600 }
601 let root = pathspec_resolution_root(repo);
602 let mut specs = Vec::with_capacity(raw_paths.len());
603 for pathspec in raw_paths {
604 if repo.work_tree().is_some() {
605 let resolved = repo
606 .resolve_worktree_pathspec(pathspec, cwd_prefix)
607 .map_err(|e| ResolveDiffArgumentsError::Message(e.to_string()))?;
608 specs.push(resolved);
609 } else {
610 specs.push(resolve_pathspec(pathspec, root, cwd_prefix));
611 }
612 }
613 Ok(specs)
614}
615
616fn filter_file_changes(
617 changes: Vec<FileChange>,
618 repo: &Repository,
619 specs: &[String],
620) -> Result<Vec<FileChange>> {
621 if specs.is_empty() {
622 return Ok(changes);
623 }
624 let matcher = repo.pathspec_matcher(specs)?;
625 Ok(changes
626 .into_iter()
627 .filter(|c| matcher.matches_change(&c.path, c.old_path.as_deref()))
628 .collect())
629}
630
631fn diff_entry_matches(matcher: &PathspecMatcher<'_>, entry: &DiffEntry) -> bool {
632 let path = entry
633 .new_path
634 .as_deref()
635 .or(entry.old_path.as_deref())
636 .unwrap_or("");
637 matcher.matches_change(path, entry.old_path.as_deref())
638}
639
640fn filter_tree_diff(
641 items: Vec<TreeDiffStructured>,
642 repo: &Repository,
643 specs: &[String],
644) -> Result<Vec<TreeDiffStructured>> {
645 if specs.is_empty() {
646 return Ok(items);
647 }
648 let matcher = repo.pathspec_matcher(specs)?;
649 Ok(items
650 .into_iter()
651 .filter(|item| diff_entry_matches(&matcher, &item.entry))
652 .collect())
653}
654
655fn pathspec_restrict_set(pathspecs: &[String]) -> Option<Arc<BTreeSet<String>>> {
656 if pathspecs.is_empty() {
657 None
658 } else {
659 Some(Arc::new(pathspecs.iter().cloned().collect()))
660 }
661}
662
663fn unstaged_changes(repo: &Repository, rename_threshold: u32) -> Result<Vec<FileChange>> {
664 unstaged_changes_inner(repo, rename_threshold, None)
665}
666
667fn unstaged_changes_inner(
668 repo: &Repository,
669 rename_threshold: u32,
670 restrict_to_paths: Option<Arc<BTreeSet<String>>>,
671) -> Result<Vec<FileChange>> {
672 let work_tree = repo
673 .work_tree()
674 .ok_or_else(|| Error::Message("grit diff needs a working tree".into()))?;
675 let mut index = repo.index_handle().load()?;
676 let rules_arc = Arc::new(Mutex::new(WorktreeRules::from_repository(repo, &index)?));
677 let config_arc = rules_arc
678 .lock()
679 .unwrap_or_else(|e| e.into_inner())
680 .config_arc();
681 let diff_index_mtime = index_mtime_for_diff(&index, None);
682 let narrow = restrict_to_paths.is_some();
683 let diff_opts = DiffIndexToWorktreeOptions {
684 repository_git_dir: Some(repo.git_dir().to_path_buf()),
685 index_mtime: diff_index_mtime,
686 config: Some(config_arc.clone()),
687 thread_policy: Some(repo.thread_policy()),
688 refresh_index_stat_in_pass: false,
689 for_status: !narrow,
690 refresh_fsmonitor: if narrow {
691 false
692 } else {
693 fsmonitor_enabled(config_arc.as_ref())
694 },
695 worktree_rules: Some(Arc::clone(&rules_arc)),
696 command_runner: Some(repo.command_runner()),
697 restrict_to_paths,
698 ..DiffIndexToWorktreeOptions::default()
699 };
700 let (mut entries, _) = diff_index_to_worktree_with_renames(
701 repo,
702 &mut index,
703 work_tree,
704 diff_opts,
705 rename_threshold,
706 )?;
707 sort_entries(&mut entries);
708 let side = WorktreeDiffSide {
709 work_tree,
710 index: &index,
711 rules: Arc::clone(&rules_arc),
712 };
713 entries_to_file_changes(repo, entries, Some(&side))
714}
715
716fn tree_blob_at_path(odb: &Odb, tree: &ObjectId, path: &str) -> Result<Option<(ObjectId, u32)>> {
717 let mut current = *tree;
718 let mut components = path.split('/').peekable();
719 while let Some(name) = components.next() {
720 let raw = odb.read(¤t)?;
721 let entries = parse_tree(&raw.data)?;
722 let Some(entry) = entries
723 .iter()
724 .find(|e| e.name.as_slice() == name.as_bytes())
725 else {
726 return Ok(None);
727 };
728 if components.peek().is_some() {
729 if entry.mode != MODE_TREE {
730 return Ok(None);
731 }
732 current = entry.oid;
733 } else {
734 return Ok(Some((entry.oid, entry.mode)));
735 }
736 }
737 Ok(None)
738}
739
740fn diff_index_to_tree_for_paths(
741 repo: &Repository,
742 head_tree: Option<&ObjectId>,
743 paths: &std::collections::BTreeSet<String>,
744) -> Result<Vec<DiffEntry>> {
745 let odb = repo.odb();
746 let index = repo.index_handle().load()?;
747 let mut result = Vec::new();
748 for path in paths {
749 let index_entry = index.get(path.as_bytes(), 0);
750 if index_entry.is_some_and(|e| e.intent_to_add()) {
751 continue;
752 }
753 let tree_side = match head_tree {
754 Some(t) => tree_blob_at_path(odb, t, path)?,
755 None => None,
756 };
757 let (idx_oid, idx_mode) = index_entry
758 .map(|e| (e.oid, e.mode))
759 .unwrap_or((ObjectId::zero(), 0));
760 let (tree_oid, tree_mode) = tree_side.unwrap_or((ObjectId::zero(), 0));
761 if index_entry.is_none() && tree_side.is_none() {
762 continue;
763 }
764 if index_entry.is_some() && tree_side.is_none() {
765 result.push(DiffEntry {
766 status: DiffStatus::Added,
767 old_path: None,
768 new_path: Some(path.clone()),
769 old_mode: "000000".to_owned(),
770 new_mode: crate::diff::format_mode(idx_mode),
771 old_oid: ObjectId::zero(),
772 new_oid: idx_oid,
773 score: None,
774 });
775 continue;
776 }
777 if index_entry.is_none() {
778 result.push(DiffEntry {
779 status: DiffStatus::Deleted,
780 old_path: Some(path.clone()),
781 new_path: None,
782 old_mode: crate::diff::format_mode(tree_mode),
783 new_mode: "000000".to_owned(),
784 old_oid: tree_oid,
785 new_oid: ObjectId::zero(),
786 score: None,
787 });
788 continue;
789 }
790 if idx_oid != tree_oid || idx_mode != tree_mode {
791 result.push(DiffEntry {
792 status: DiffStatus::for_content_change(tree_mode, idx_mode),
793 old_path: Some(path.clone()),
794 new_path: Some(path.clone()),
795 old_mode: crate::diff::format_mode(tree_mode),
796 new_mode: crate::diff::format_mode(idx_mode),
797 old_oid: tree_oid,
798 new_oid: idx_oid,
799 score: None,
800 });
801 }
802 }
803 Ok(result)
804}
805
806fn staged_changes(
807 repo: &Repository,
808 rename_threshold: u32,
809 restrict_to_paths: Option<Arc<std::collections::BTreeSet<String>>>,
810) -> Result<Vec<FileChange>> {
811 let head = resolve_head(repo.git_dir())?;
812 let head_tree = match head.oid() {
813 Some(oid) => Some(commit_tree(repo, oid)?),
814 None => None,
815 };
816 let index = repo.index_handle().load()?;
817 let config = repo.config()?;
818 let thread_policy = repo.thread_policy();
819 let mut entries = if let Some(paths) = restrict_to_paths.as_ref().filter(|p| !p.is_empty()) {
820 diff_index_to_tree_for_paths(repo, head_tree.as_ref(), paths)?
821 } else {
822 diff_index_to_tree_with_renames(
823 repo.odb(),
824 &index,
825 head_tree.as_ref(),
826 false,
827 rename_threshold,
828 config.as_ref(),
829 &thread_policy,
830 )?
831 };
832 sort_entries(&mut entries);
833 entries_to_file_changes(repo, entries, None)
834}
835
836fn worktree_changes(
837 repo: &Repository,
838 base_tree: Option<&ObjectId>,
839 rename_threshold: u32,
840) -> Result<Vec<FileChange>> {
841 let work_tree = repo
842 .work_tree()
843 .ok_or_else(|| Error::Message("grit diff needs a working tree".into()))?;
844 let mut index = repo.index_handle().load()?;
845 let config = repo.config()?;
846
847 let cache_valid =
848 crate::index::write_tree::cache_tree_extension_valid(index.cache_tree.as_ref());
849 let index_backed = base_tree
850 .zip(index.cache_tree_root.as_ref())
851 .map(|(left, right)| left == right)
852 .unwrap_or(base_tree.is_none() && index.cache_tree_root.is_none())
853 && cache_valid;
854 let diff_index_mtime = index_mtime_for_diff(&index, None);
855 let rules_arc = Arc::new(Mutex::new(WorktreeRules::from_repository(repo, &index)?));
856 let index_to_worktree_opts = DiffIndexToWorktreeOptions {
857 repository_git_dir: Some(repo.git_dir().to_path_buf()),
858 config: Some(config.clone()),
859 index_mtime: diff_index_mtime,
860 thread_policy: Some(repo.thread_policy()),
861 refresh_index_stat_in_pass: false,
862 for_status: false,
863 refresh_fsmonitor: fsmonitor_enabled(config.as_ref()),
864 worktree_rules: Some(Arc::clone(&rules_arc)),
865 command_runner: Some(repo.command_runner()),
866 ..DiffIndexToWorktreeOptions::default()
867 };
868 let mut entries = if index_backed {
869 let (entries, _) = diff_index_to_worktree_with_renames(
870 repo,
871 &mut index,
872 work_tree,
873 index_to_worktree_opts,
874 rename_threshold,
875 )?;
876 entries
877 } else {
878 let worktree_rules = WorktreeRules::from_repository(repo, &index)?;
879 let raw = diff_tree_to_worktree_with_git_dir_and_rules(
880 repo.odb(),
881 base_tree,
882 work_tree,
883 repo.git_dir(),
884 &index,
885 Some(config.as_ref()),
886 Some(&worktree_rules),
887 )?;
888 apply_rename_detection_to_diff_entries(
889 repo.odb(),
890 raw,
891 Some(work_tree),
892 rename_threshold,
893 config.as_ref(),
894 &repo.thread_policy(),
895 )?
896 };
897 sort_entries(&mut entries);
898 let side = WorktreeDiffSide {
899 work_tree,
900 index: &index,
901 rules: rules_arc,
902 };
903 entries_to_file_changes(repo, entries, Some(&side))
904}
905
906fn entries_to_file_changes(
907 repo: &Repository,
908 entries: Vec<DiffEntry>,
909 side: Option<&WorktreeDiffSide<'_>>,
910) -> Result<Vec<FileChange>> {
911 entries
912 .into_iter()
913 .map(|e| {
914 let (old_bytes, old_bin) = old_side_bytes(repo.odb(), &e)?;
915 let (new_bytes, new_bin) = new_side_bytes(repo.odb(), &e, side)?;
916 Ok(file_change(e, old_bytes, new_bytes, old_bin || new_bin))
917 })
918 .collect()
919}
920
921fn tree_commit_diff_entries(
922 repo: &Repository,
923 base: &ObjectId,
924 tip: &ObjectId,
925 config: &ConfigSet,
926 thread_policy: &ThreadPolicy,
927 rename_threshold: u32,
928) -> Result<Vec<DiffEntry>> {
929 let mut entries = diff_commits_with_renames_and_policy(
930 repo.odb(),
931 base,
932 tip,
933 rename_threshold,
934 config,
935 thread_policy,
936 )?;
937 sort_entries(&mut entries);
938 Ok(entries)
939}
940
941fn tree_commit_changes(
942 repo: &Repository,
943 base: &ObjectId,
944 tip: &ObjectId,
945 config: &ConfigSet,
946 thread_policy: &ThreadPolicy,
947 diff_options: DiffOptions,
948 rename_threshold: u32,
949) -> Result<Vec<TreeDiffStructured>> {
950 let entries =
951 tree_commit_diff_entries(repo, base, tip, config, thread_policy, rename_threshold)?;
952 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
953}
954
955fn commit_diff_entries(
956 repo: &Repository,
957 oid: &ObjectId,
958 config: &ConfigSet,
959 thread_policy: &ThreadPolicy,
960 rename_threshold: u32,
961) -> Result<Vec<DiffEntry>> {
962 let commit = read_commit(repo, oid)?;
963 let new_tree = commit.tree;
964 let old_tree = match commit.parents.first() {
965 Some(parent) => Some(commit_tree(repo, parent)?),
966 None => None,
967 };
968
969 let mut entries = diff_trees_with_renames_and_policy(
970 repo.odb(),
971 old_tree.as_ref(),
972 Some(&new_tree),
973 "",
974 rename_threshold,
975 config,
976 thread_policy,
977 )?;
978 sort_entries(&mut entries);
979 Ok(entries)
980}
981
982fn commit_changes(
983 repo: &Repository,
984 oid: &ObjectId,
985 config: &ConfigSet,
986 thread_policy: &ThreadPolicy,
987 diff_options: DiffOptions,
988 rename_threshold: u32,
989) -> Result<Vec<TreeDiffStructured>> {
990 let entries = commit_diff_entries(repo, oid, config, thread_policy, rename_threshold)?;
991 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
992}
993
994fn resolve_diff_commit(repo: &Repository, spec: &str) -> Result<ObjectId> {
995 let oid = resolve_revision(repo, spec)
996 .map_err(|_| Error::Message(format!("could not resolve '{spec}'")))?;
997 peel_to_commit_for_merge_base(repo, oid)
998}
999
1000fn resolve_diff_range_end(repo: &Repository, spec: &str) -> Result<ObjectId> {
1001 let oid = resolve_revision_for_range_end(repo, spec)?;
1002 peel_to_commit_for_merge_base(repo, oid)
1003}
1004
1005fn parse_commit_range(repo: &Repository, spec: &str) -> Result<Option<(ObjectId, ObjectId)>> {
1006 if let Some((left, right)) = split_triple_dot_range(spec) {
1007 let left_c = if left.is_empty() {
1008 resolve_diff_range_end(repo, "HEAD")?
1009 } else {
1010 resolve_diff_range_end(repo, left)?
1011 };
1012 let right_c = if right.is_empty() {
1013 resolve_diff_range_end(repo, "HEAD")?
1014 } else {
1015 resolve_diff_range_end(repo, right)?
1016 };
1017 let base = merge_base_for_diff_two_commits(repo, left_c, right_c).map_err(|e| {
1018 Error::Message(match e {
1019 crate::revwalk::merge_base::MergeBaseForDiffError::None => {
1020 "no merge base for that revision range".to_owned()
1021 }
1022 crate::revwalk::merge_base::MergeBaseForDiffError::Multiple => {
1023 "multiple merge bases for that revision range".to_owned()
1024 }
1025 crate::revwalk::merge_base::MergeBaseForDiffError::Other(msg) => msg,
1026 })
1027 })?;
1028 return Ok(Some((base, right_c)));
1029 }
1030 let Some((left, right)) = split_double_dot_range(spec) else {
1031 return Ok(None);
1032 };
1033 let base = if left.is_empty() {
1034 resolve_diff_range_end(repo, "HEAD")?
1035 } else {
1036 resolve_diff_range_end(repo, left)?
1037 };
1038 let tip = if right.is_empty() {
1039 resolve_diff_range_end(repo, "HEAD")?
1040 } else {
1041 resolve_diff_range_end(repo, right)?
1042 };
1043 Ok(Some((base, tip)))
1044}
1045
1046fn read_commit(repo: &Repository, oid: &ObjectId) -> Result<CommitData> {
1047 let object = repo.odb().read(oid)?;
1048 if object.kind != ObjectKind::Commit {
1049 return Err(Error::Message(format!(
1050 "object {oid} is a {}, not a commit",
1051 object.kind
1052 )));
1053 }
1054 parse_commit(&object.data)
1055}
1056
1057fn commit_tree(repo: &Repository, commit: &ObjectId) -> Result<ObjectId> {
1058 Ok(read_commit(repo, commit)?.tree)
1059}
1060
1061fn sort_entries(entries: &mut [DiffEntry]) {
1062 entries.sort_by(|a, b| a.path().cmp(b.path()));
1063}
1064
1065fn file_change(
1066 e: DiffEntry,
1067 old_bytes: SideBytes,
1068 new_bytes: SideBytes,
1069 binary: bool,
1070) -> FileChange {
1071 let path = e
1072 .new_path
1073 .clone()
1074 .or_else(|| e.old_path.clone())
1075 .unwrap_or_default();
1076 let old_path = e.old_path.filter(|op| *op != path);
1077 FileChange {
1078 path,
1079 old_path,
1080 status: e.status,
1081 old_mode: e.old_mode,
1082 new_mode: e.new_mode,
1083 old_oid: e.old_oid,
1084 new_oid: e.new_oid,
1085 old_bytes,
1086 new_bytes,
1087 binary,
1088 content_changed: e.old_oid != e.new_oid,
1089 }
1090}
1091
1092fn old_side_bytes(odb: &Odb, e: &DiffEntry) -> Result<(SideBytes, bool)> {
1093 if e.old_mode == "160000" {
1094 return Ok((
1095 SideBytes::from_bytes(gitlink_line(&e.old_oid, None).into_bytes()),
1096 false,
1097 ));
1098 }
1099 if e.old_mode == "000000" {
1100 return Ok((SideBytes::from_bytes(Vec::new()), false));
1101 }
1102 blob_bytes(odb, &e.old_oid)
1103}
1104
1105fn new_side_bytes(
1106 odb: &Odb,
1107 e: &DiffEntry,
1108 work_tree: Option<&WorktreeDiffSide<'_>>,
1109) -> Result<(SideBytes, bool)> {
1110 if e.new_mode == "160000" {
1111 let wt = work_tree.map(|s| s.work_tree);
1112 return Ok((
1113 SideBytes::from_bytes(gitlink_new_line(e, wt).into_bytes()),
1114 false,
1115 ));
1116 }
1117 if e.new_mode == "000000" {
1118 return Ok((SideBytes::from_bytes(Vec::new()), false));
1119 }
1120 if let (Some(ctx), Some(p)) = (work_tree, e.new_path.as_deref().or(e.old_path.as_deref())) {
1121 let mode = u32::from_str_radix(&e.new_mode, 8).unwrap_or(0);
1122 let index_entry = ctx.index.get(p.as_bytes(), 0);
1123 let rules = ctx.rules.lock().unwrap_or_else(|e| e.into_inner());
1124 let data = read_worktree_side_bytes_for_diff(
1125 odb,
1126 ctx.work_tree,
1127 p,
1128 mode,
1129 rules.config(),
1130 index_entry,
1131 Some(&rules),
1132 )?;
1133 Ok(bytes_for_diff(&data))
1134 } else {
1135 blob_bytes(odb, &e.new_oid)
1136 }
1137}
1138
1139fn gitlink_line(oid: &ObjectId, dirty_suffix: Option<&str>) -> String {
1140 if *oid == ObjectId::zero() {
1141 return String::new();
1142 }
1143 let suffix = dirty_suffix.unwrap_or("");
1144 format!("Subproject commit {}{suffix}\n", oid.to_hex())
1145}
1146
1147fn gitlink_new_line(e: &DiffEntry, work_tree: Option<&Path>) -> String {
1148 let path = e.new_path.as_deref().or(e.old_path.as_deref());
1149 let resolved = if e.new_oid != ObjectId::zero() {
1150 e.new_oid
1151 } else if let (Some(wt), Some(p)) = (work_tree, path) {
1152 read_submodule_head_oid(&wt.join(p)).unwrap_or(ObjectId::zero())
1153 } else {
1154 ObjectId::zero()
1155 };
1156 if resolved == ObjectId::zero() {
1157 return String::new();
1158 }
1159 let dirty = e.old_mode == "160000"
1160 && e.new_mode == "160000"
1161 && work_tree.is_some_and(|wt| {
1162 path.is_some_and(|p| submodule_porcelain_flags(wt, p, e.old_oid).modified)
1163 });
1164 gitlink_line(&resolved, if dirty { Some("-dirty") } else { None })
1165}
1166
1167fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<(SideBytes, bool)> {
1168 if *oid == ObjectId::zero() {
1169 return Ok((SideBytes::from_bytes(Vec::new()), false));
1170 }
1171 let data = odb.read(oid)?.data;
1172 Ok(bytes_for_diff(&data))
1173}
1174
1175fn bytes_for_diff(data: &[u8]) -> (SideBytes, bool) {
1176 if data.iter().take(8000).any(|&b| b == 0) {
1177 (SideBytes::from_bytes(Vec::new()), true)
1178 } else {
1179 (SideBytes::from_bytes(data.to_vec()), false)
1180 }
1181}
1182
1183pub(crate) type UnstagedDiffWithPeers = (
1185 Vec<FileDiff>,
1186 std::collections::HashMap<String, Vec<StructuredHunk>>,
1187);
1188
1189pub(crate) type UnstagedRawWithPeers = (
1190 Vec<FileChange>,
1191 std::collections::HashMap<String, Vec<StructuredHunk>>,
1192);
1193
1194pub(crate) fn unstaged_raw_changes_with_peer_hunks(
1196 repo: &Repository,
1197 pathspecs: &[String],
1198 opts: DiffOptions,
1199) -> Result<UnstagedRawWithPeers> {
1200 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1201 let peer_hunks =
1202 staged_peer_hunks_for_pathspecs(repo, pathspecs, opts.clone(), rename_threshold)?;
1203 let restrict = exact_file_literal_pathspec_arc(repo, pathspecs)?;
1204 let unstaged = if let Some(paths) = restrict {
1205 unstaged_changes_inner(repo, rename_threshold, Some(paths))?
1206 } else {
1207 filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?
1208 };
1209 Ok((unstaged, peer_hunks))
1210}
1211
1212fn blob_oid_in_tree(repo: &Repository, tree: ObjectId, path: &str) -> Result<Option<ObjectId>> {
1213 use crate::objects::parse_tree;
1214 let mut cur = tree;
1215 let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1216 for (i, name) in components.iter().enumerate() {
1217 let raw = repo.odb().read(&cur)?;
1218 let entries = parse_tree(&raw.data)?;
1219 let name_bytes = name.as_bytes();
1220 let Some(entry) = entries.iter().find(|e| e.name.as_slice() == name_bytes) else {
1221 return Ok(None);
1222 };
1223 if i + 1 == components.len() {
1224 return Ok(Some(entry.oid));
1225 }
1226 if entry.mode != crate::index::MODE_TREE {
1227 return Ok(None);
1228 }
1229 cur = entry.oid;
1230 }
1231 Ok(None)
1232}
1233
1234fn path_has_staged_delta_vs_head_with_index(
1235 repo: &Repository,
1236 index: &Index,
1237 head_tree: Option<ObjectId>,
1238 path: &str,
1239) -> Result<bool> {
1240 let path_bytes = path.as_bytes();
1241 if index.get(path_bytes, 2).is_some() || index.get(path_bytes, 3).is_some() {
1242 return Ok(true);
1243 }
1244 let head_blob = match head_tree {
1245 Some(tree) => blob_oid_in_tree(repo, tree, path)?,
1246 None => None,
1247 };
1248 let Some(idx_entry) = index.get(path_bytes, 0) else {
1249 return Ok(head_blob.is_some());
1250 };
1251 match head_blob {
1252 None => Ok(true),
1253 Some(h) => Ok(idx_entry.oid != h),
1254 }
1255}
1256
1257fn exact_file_literal_pathspec_arc(
1258 repo: &Repository,
1259 pathspecs: &[String],
1260) -> Result<Option<Arc<std::collections::BTreeSet<String>>>> {
1261 let Some(literal_paths) =
1262 exact_literal_pathspec_set(pathspecs, PathspecMatchContext::default())
1263 else {
1264 return Ok(None);
1265 };
1266 let index = repo.index_handle().load()?;
1267 let head = resolve_head(repo.git_dir())?;
1268 let head_tree = match head.oid() {
1269 Some(oid) => Some(commit_tree(repo, oid)?),
1270 None => None,
1271 };
1272 if literal_paths
1273 .iter()
1274 .all(|p| path_is_exact_tracked_file(repo, &index, head_tree, p).unwrap_or(false))
1275 {
1276 Ok(Some(Arc::new(literal_paths)))
1277 } else {
1278 Ok(None)
1279 }
1280}
1281
1282fn path_is_exact_tracked_file(
1283 repo: &Repository,
1284 index: &Index,
1285 head_tree: Option<ObjectId>,
1286 path: &str,
1287) -> Result<bool> {
1288 let path_bytes = path.as_bytes();
1289 if let Some(entry) = index.get(path_bytes, 0) {
1290 return Ok(entry.mode != MODE_TREE);
1291 }
1292 match head_tree {
1293 Some(tree) => Ok(blob_oid_in_tree(repo, tree, path)?.is_some()),
1294 None => Ok(false),
1295 }
1296}
1297
1298fn staged_peer_hunks_for_pathspecs(
1299 repo: &Repository,
1300 pathspecs: &[String],
1301 opts: DiffOptions,
1302 rename_threshold: u32,
1303) -> Result<std::collections::HashMap<String, Vec<StructuredHunk>>> {
1304 if let Some(literal_paths) =
1305 exact_literal_pathspec_set(pathspecs, PathspecMatchContext::default())
1306 {
1307 let index = repo.index_handle().load()?;
1308 let head = resolve_head(repo.git_dir())?;
1309 let head_tree = match head.oid() {
1310 Some(oid) => Some(commit_tree(repo, oid)?),
1311 None => None,
1312 };
1313 let all_exact_files = literal_paths
1314 .iter()
1315 .all(|p| path_is_exact_tracked_file(repo, &index, head_tree, p).unwrap_or(false));
1316 if all_exact_files
1317 && literal_paths.iter().all(|p| {
1318 path_has_staged_delta_vs_head_with_index(repo, &index, head_tree, p)
1319 .map(|delta| !delta)
1320 .unwrap_or(false)
1321 })
1322 {
1323 return Ok(std::collections::HashMap::new());
1324 }
1325 }
1326 let staged = filter_file_changes(
1327 staged_changes(repo, rename_threshold, None)?,
1328 repo,
1329 pathspecs,
1330 )?;
1331 Ok(staged
1332 .iter()
1333 .map(|fc| {
1334 let structured = structured_for_file_change(fc, opts.clone());
1335 (fc.path.clone(), structured.hunks)
1336 })
1337 .collect())
1338}
1339
1340pub(crate) fn unstaged_file_diffs_with_peer_hunks(
1342 repo: &Repository,
1343 pathspecs: &[String],
1344 opts: DiffOptions,
1345) -> Result<UnstagedDiffWithPeers> {
1346 let config = repo.config()?;
1347 let thread_policy = repo.thread_policy();
1348 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1349 let staged = filter_file_changes(
1350 staged_changes(repo, rename_threshold, None)?,
1351 repo,
1352 pathspecs,
1353 )?;
1354 let peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>> = staged
1355 .iter()
1356 .map(|fc| {
1357 let structured = structured_for_file_change(fc, opts.clone());
1358 (fc.path.clone(), structured.hunks)
1359 })
1360 .collect();
1361 let unstaged = filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
1362 let files = file_diffs_from_worktree_changes(unstaged, &config, &thread_policy, opts)?;
1363 Ok((files, peer_hunks))
1364}
1365
1366pub struct HunkDiffBundle {
1368 pub unstaged: Vec<FileDiff>,
1370 pub peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>>,
1372}
1373
1374pub fn hunk_diff_bundle(
1376 repo: &Repository,
1377 pathspecs: &[String],
1378 opts: DiffOptions,
1379) -> Result<HunkDiffBundle> {
1380 let config = repo.config()?;
1381 let thread_policy = repo.thread_policy();
1382 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1383 let staged_fc = filter_file_changes(
1386 staged_changes(repo, rename_threshold, None)?,
1387 repo,
1388 pathspecs,
1389 )?;
1390 let staged_files = if staged_fc.is_empty() {
1391 Vec::new()
1392 } else {
1393 file_diffs_from_worktree_changes(staged_fc, &config, &thread_policy, opts.clone())?
1394 };
1395 let peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>> = staged_files
1396 .iter()
1397 .map(|f| (f.path.clone(), f.hunks.clone()))
1398 .collect();
1399 let unstaged_fc =
1400 filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
1401 let unstaged = file_diffs_from_worktree_changes(unstaged_fc, &config, &thread_policy, opts)?;
1402 Ok(HunkDiffBundle {
1403 unstaged,
1404 peer_hunks,
1405 })
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn include_file_omits_whitespace_only_modified() {
1414 let file = FileDiff {
1415 path: "x.txt".into(),
1416 old_path: None,
1417 status: DiffStatus::Modified,
1418 old_mode: Some("100644".into()),
1419 new_mode: Some("100644".into()),
1420 old_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1421 new_oid: ObjectId::from_hex("0000000000000000000000000000000000000002").unwrap(),
1422 binary: false,
1423 content_changed: true,
1424 old_encoding_lossy: false,
1425 new_encoding_lossy: false,
1426 hunks: Vec::new(),
1427 };
1428 let opts =
1429 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1430 assert!(!include_file_in_diff(&file, &opts));
1431 }
1432
1433 #[test]
1434 fn include_file_omits_ws_only_even_when_content_changed_false() {
1435 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
1436 let file = FileDiff {
1437 path: "x.txt".into(),
1438 old_path: None,
1439 status: DiffStatus::Modified,
1440 old_mode: Some("100644".into()),
1441 new_mode: Some("100644".into()),
1442 old_oid: oid,
1443 new_oid: oid,
1444 binary: false,
1445 content_changed: false,
1446 old_encoding_lossy: false,
1447 new_encoding_lossy: false,
1448 hunks: Vec::new(),
1449 };
1450 let opts =
1451 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1452 assert!(!include_file_in_diff(&file, &opts));
1453 }
1454
1455 #[test]
1456 fn include_file_keeps_mode_only_under_whitespace() {
1457 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
1458 let file = FileDiff {
1459 path: "x.txt".into(),
1460 old_path: None,
1461 status: DiffStatus::Modified,
1462 old_mode: Some("100644".into()),
1463 new_mode: Some("100755".into()),
1464 old_oid: oid,
1465 new_oid: oid,
1466 binary: false,
1467 content_changed: false,
1468 old_encoding_lossy: false,
1469 new_encoding_lossy: false,
1470 hunks: Vec::new(),
1471 };
1472 let opts =
1473 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1474 assert!(include_file_in_diff(&file, &opts));
1475 }
1476
1477 #[test]
1478 fn include_file_keeps_whitespace_only_rename() {
1479 let file = FileDiff {
1480 path: "new.txt".into(),
1481 old_path: Some("old.txt".into()),
1482 status: DiffStatus::Renamed,
1483 old_mode: Some("100644".into()),
1484 new_mode: Some("100644".into()),
1485 old_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1486 new_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1487 binary: false,
1488 content_changed: false,
1489 old_encoding_lossy: false,
1490 new_encoding_lossy: false,
1491 hunks: Vec::new(),
1492 };
1493 let opts =
1494 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1495 assert!(include_file_in_diff(&file, &opts));
1496 }
1497
1498 #[test]
1499 fn resolve_errors_staged_and_unstaged() {
1500 let dir = tempfile::tempdir().expect("tempdir");
1501 let repo = crate::repo::Repository::init(dir.path(), &crate::repo::InitOptions::default())
1502 .expect("init");
1503 let err = resolve_diff_arguments(&repo, None, true, true, None, &[], &[]).unwrap_err();
1504 assert_eq!(err, ResolveDiffArgumentsError::StagedAndUnstaged);
1505 }
1506}