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