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::changes::{
11 load_changes, load_object_changes, staged_entries_in, unstaged_entries_in, worktree_entries_in,
12 FileChange, WorktreeSide,
13};
14use crate::diff::detail::LineStat;
15use crate::diff::diff_trees_with_renames_and_policy;
16use crate::diff::structured::StructuredHunk;
17use crate::diff::{
18 sort_entries_by_path, tree_diff_structured_parallel, DiffEntry, DiffOptions, DiffStatus,
19 TreeDiffStructured,
20};
21use crate::error::{Error, PathspecResolveError, Result};
22use crate::index::Index;
23use crate::index::MODE_TREE;
24use crate::objects::FileMode;
25use crate::objects::ObjectId;
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::rev_parse::peel_tags_to_commit;
33use crate::revwalk::merge_base::{merge_base_for_diff_two_commits, MergeBaseForDiffError};
34use crate::revwalk::rev_parse::{
35 resolve_revision, resolve_revision_for_range_end, split_double_dot_range,
36 split_triple_dot_range,
37};
38use crate::state::resolve_head_for_repository;
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 pub line_stat: Option<LineStat>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct DiffArguments {
99 pub target: DiffTarget,
101 pub pathspecs: Vec<String>,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
109pub struct DiffRequest {
110 pub staged: bool,
112 pub unstaged: bool,
114 pub against: Option<String>,
116 pub positionals: Vec<String>,
118 pub paths: Vec<String>,
120 pub pathspec_file: Option<Vec<String>>,
125}
126
127#[non_exhaustive]
129#[derive(Debug, Error)]
130pub enum ResolveDiffArgumentsError {
131 #[error("--staged and --unstaged cannot be combined")]
133 StagedAndUnstaged,
134
135 #[error("--staged cannot be combined with revision arguments or --against")]
137 StagedWithRevisions,
138
139 #[error("--unstaged cannot be combined with revision arguments or --against")]
141 UnstagedWithRevisions,
142
143 #[error("at most two commits, or one two-dot range such as A..B")]
145 TooManyRevisions,
146
147 #[error("--against compares the worktree to a ref and takes no commit arguments")]
149 AgainstWithCommits,
150
151 #[error(
153 "a two-dot or three-dot range uses the other positional as a path limit; pass paths after `--`"
154 )]
155 RangeWithSecondRevision,
156
157 #[error("grit diff needs a working tree")]
159 NoWorkTree,
160
161 #[error("could not resolve '{spec}'")]
163 UnresolvedRevision {
164 spec: String,
166 #[source]
168 source: Box<Error>,
169 },
170
171 #[error("object {oid} does not name a commit")]
173 NotACommit {
174 oid: ObjectId,
176 },
177
178 #[error("no merge base for that revision range")]
180 NoMergeBase,
181
182 #[error("multiple merge bases for that revision range")]
184 MultipleMergeBases,
185
186 #[error(transparent)]
188 Pathspec(#[from] PathspecResolveError),
189
190 #[error(transparent)]
193 Library(Box<Error>),
194}
195
196impl From<Error> for ResolveDiffArgumentsError {
197 fn from(err: Error) -> Self {
198 Self::Library(Box::new(err))
199 }
200}
201
202type ResolveResult<T> = std::result::Result<T, ResolveDiffArgumentsError>;
203
204pub fn resolve_diff_arguments(
223 repo: &Repository,
224 cwd_prefix: Option<&str>,
225 staged: bool,
226 unstaged: bool,
227 against: Option<&str>,
228 positionals: &[String],
229 explicit_paths: &[String],
230) -> ResolveResult<DiffArguments> {
231 let request = DiffRequest {
232 staged,
233 unstaged,
234 against: against.map(str::to_owned),
235 positionals: positionals.to_vec(),
236 paths: explicit_paths.to_vec(),
237 pathspec_file: None,
238 };
239 resolve_diff_request(repo, cwd_prefix, &request)
240}
241
242pub fn resolve_diff_request(
281 repo: &Repository,
282 cwd_prefix: Option<&str>,
283 request: &DiffRequest,
284) -> ResolveResult<DiffArguments> {
285 let arguments = resolve_diff_request_in(repo, cwd_prefix, request)?;
286 let reads_worktree = matches!(
287 arguments.target,
288 DiffTarget::WorktreeVsHead | DiffTarget::WorktreeVsTree(_) | DiffTarget::WorktreeVsIndex
289 );
290 if reads_worktree && repo.work_tree().is_none() {
291 return Err(ResolveDiffArgumentsError::NoWorkTree);
292 }
293 Ok(arguments)
294}
295
296fn resolve_diff_request_in(
297 repo: &Repository,
298 cwd_prefix: Option<&str>,
299 request: &DiffRequest,
300) -> ResolveResult<DiffArguments> {
301 let (commits, path_args) = disambiguate_diff_revision_and_paths(
302 repo,
303 cwd_prefix,
304 request.positionals.clone(),
305 request.paths.clone(),
306 );
307 let file_paths = request.pathspec_file.as_deref().unwrap_or_default();
308 let path_tokens = [file_paths, path_args.as_slice()].concat();
309 if (request.pathspec_file.is_some() || !request.paths.is_empty())
310 && (path_tokens.is_empty() || path_tokens.iter().any(String::is_empty))
311 {
312 return Err(PathspecResolveError::EmptyPathspec.into());
313 }
314 let against = request.against.as_deref();
315
316 if request.staged && request.unstaged {
317 return Err(ResolveDiffArgumentsError::StagedAndUnstaged);
318 }
319 if request.staged || request.unstaged {
320 if !commits.is_empty() || against.is_some() {
321 return Err(if request.staged {
322 ResolveDiffArgumentsError::StagedWithRevisions
323 } else {
324 ResolveDiffArgumentsError::UnstagedWithRevisions
325 });
326 }
327 return Ok(DiffArguments {
328 target: if request.staged {
329 DiffTarget::IndexVsHead
330 } else {
331 DiffTarget::WorktreeVsIndex
332 },
333 pathspecs: resolve_worktree_diff_pathspecs(repo, cwd_prefix, &path_tokens)?,
334 });
335 }
336 if commits.len() > 2 {
337 return Err(ResolveDiffArgumentsError::TooManyRevisions);
338 }
339 if against.is_some() && !commits.is_empty() {
340 return Err(ResolveDiffArgumentsError::AgainstWithCommits);
341 }
342
343 let trees = |old: ObjectId, new: ObjectId, pathspecs: Vec<String>| DiffArguments {
344 target: DiffTarget::Trees {
345 old: Some(old),
346 new,
347 },
348 pathspecs,
349 };
350 match commits.as_slice() {
351 [] => {
352 let pathspecs = resolve_worktree_diff_pathspecs(repo, cwd_prefix, &path_tokens)?;
353 let target = match against {
354 Some(spec) => DiffTarget::WorktreeVsTree(
355 repo.odb().commit_tree(&resolve_diff_commit(repo, spec)?)?,
356 ),
357 None => DiffTarget::WorktreeVsHead,
358 };
359 Ok(DiffArguments { target, pathspecs })
360 }
361 [spec] => {
362 if let Some((base, tip)) = parse_commit_range(repo, spec)? {
363 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_tokens)?;
364 return Ok(trees(base, tip, pathspecs));
365 }
366 match resolve_diff_commit(repo, spec) {
367 Ok(oid) => Ok(DiffArguments {
368 target: DiffTarget::Commit(oid),
369 pathspecs: resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_tokens)?,
370 }),
371 Err(_) if path_tokens.is_empty() => Ok(DiffArguments {
373 target: DiffTarget::WorktreeVsIndex,
374 pathspecs: resolve_worktree_diff_pathspecs(
375 repo,
376 cwd_prefix,
377 std::slice::from_ref(spec),
378 )?,
379 }),
380 Err(err) => Err(err),
381 }
382 }
383 [first, second] => {
384 if let Some((base, tip)) = parse_commit_range(repo, first)? {
385 if !token_is_known_worktree_pathspec(repo, cwd_prefix, second) {
386 resolve_diff_commit(repo, second)?;
387 return Err(ResolveDiffArgumentsError::RangeWithSecondRevision);
388 }
389 let range_paths = [file_paths, std::slice::from_ref(second)].concat();
390 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &range_paths)?;
391 return Ok(trees(base, tip, pathspecs));
392 }
393 let base = resolve_diff_commit(repo, first)?;
394 let tip = resolve_diff_commit(repo, second)?;
395 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_tokens)?;
396 Ok(trees(base, tip, pathspecs))
397 }
398 _ => Err(ResolveDiffArgumentsError::TooManyRevisions),
399 }
400}
401
402pub(crate) fn include_file_in_diff(file: &FileDiff, opts: &DiffOptions) -> bool {
403 if !opts.whitespace.is_active() || file.binary {
404 return true;
405 }
406 if matches!(
407 file.status,
408 DiffStatus::Renamed
409 | DiffStatus::Added
410 | DiffStatus::Deleted
411 | DiffStatus::Copied
412 | DiffStatus::TypeChanged
413 ) {
414 return true;
415 }
416 if file.old_mode != file.new_mode {
417 return true;
418 }
419 !file.hunks.is_empty()
420}
421
422pub(crate) fn collect_files_for_target(
423 repo: &Repository,
424 target: &DiffTarget,
425 pathspecs: &[String],
426 opts: DiffOptions,
427) -> Result<Vec<FileDiff>> {
428 let config = repo.config()?;
429 let thread_policy = repo.thread_policy();
430 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
432
433 let files = match target {
434 DiffTarget::WorktreeVsHead => {
435 let head_tree = resolve_head_for_repository(repo)?.tree(repo.odb())?;
436 let changes = filter_file_changes(
437 worktree_changes(repo, head_tree.as_ref(), rename_threshold)?,
438 repo,
439 pathspecs,
440 )?;
441 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
442 }
443 DiffTarget::WorktreeVsTree(tree) => {
444 let changes = filter_file_changes(
445 worktree_changes(repo, Some(tree), rename_threshold)?,
446 repo,
447 pathspecs,
448 )?;
449 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
450 }
451 DiffTarget::IndexVsHead => {
452 let restrict = pathspec_restrict_set(pathspecs);
453 let changes = filter_file_changes(
454 staged_changes(repo, rename_threshold, restrict)?,
455 repo,
456 pathspecs,
457 )?;
458 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
459 }
460 DiffTarget::WorktreeVsIndex => {
461 let changes =
462 filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
463 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
464 }
465 DiffTarget::Commit(oid) => {
466 let items = commit_changes(
467 repo,
468 oid,
469 &config,
470 &thread_policy,
471 opts.clone(),
472 rename_threshold,
473 )?;
474 filter_and_map_tree_structured(items, repo, pathspecs, opts)?
475 }
476 DiffTarget::Trees { old, new } => {
477 let items = if let Some(base) = old {
478 tree_commit_changes(
479 repo,
480 base,
481 new,
482 &config,
483 &thread_policy,
484 opts.clone(),
485 rename_threshold,
486 )?
487 } else {
488 empty_tree_to_side_changes(
489 repo,
490 new,
491 &config,
492 &thread_policy,
493 opts.clone(),
494 rename_threshold,
495 )?
496 };
497 filter_and_map_tree_structured(items, repo, pathspecs, opts)?
498 }
499 };
500 Ok(files)
501}
502
503fn filter_and_map_tree_structured(
504 items: Vec<TreeDiffStructured>,
505 repo: &Repository,
506 pathspecs: &[String],
507 opts: DiffOptions,
508) -> Result<Vec<FileDiff>> {
509 Ok(filter_tree_diff(items, repo, pathspecs)?
510 .into_iter()
511 .map(file_diff_from_tree_structured)
512 .filter(|f| include_file_in_diff(f, &opts))
513 .collect())
514}
515
516fn file_diff_from_tree_structured(t: TreeDiffStructured) -> FileDiff {
517 let e = t.entry;
518 let path = e
519 .new_path
520 .clone()
521 .or_else(|| e.old_path.clone())
522 .unwrap_or_default();
523 let old_path = e.old_path.filter(|op| *op != path);
524 FileDiff {
525 path,
526 old_path,
527 status: e.status,
528 old_mode: t.structured.old_mode,
529 new_mode: t.structured.new_mode,
530 old_oid: e.old_oid,
531 new_oid: e.new_oid,
532 binary: t.binary,
533 content_changed: e.old_oid != e.new_oid,
534 old_encoding_lossy: t.structured.old_encoding_lossy,
535 new_encoding_lossy: t.structured.new_encoding_lossy,
536 hunks: t.structured.hunks,
537 line_stat: None,
538 }
539}
540
541fn file_diffs_from_worktree_changes(
542 changes: Vec<FileChange>,
543 config: &ConfigSet,
544 thread_policy: &ThreadPolicy,
545 opts: DiffOptions,
546) -> Result<Vec<FileDiff>> {
547 let opts = opts.cap_word_level_inline_for_changed_files(changes.len());
548 let mut par_opts = thread_policy.resolve(ParallelOp::DiffContent, config);
549 par_opts.min_items = 0;
550 let est_bytes = changes.len().saturating_mul(8192);
551 Ok(par_map(&changes, par_opts, est_bytes, |fc| {
552 build_file_diff(fc, opts.clone())
553 })
554 .into_iter()
555 .filter(|f| include_file_in_diff(f, &opts))
556 .collect())
557}
558
559fn build_file_diff(fc: &FileChange, diff_options: DiffOptions) -> FileDiff {
560 let structured = fc.structured(diff_options);
561 FileDiff {
562 path: fc.path.clone(),
563 old_path: fc.old_path.clone(),
564 status: fc.status,
565 old_mode: structured.old_mode,
566 new_mode: structured.new_mode,
567 old_oid: fc.old_oid,
568 new_oid: fc.new_oid,
569 binary: fc.binary,
570 content_changed: fc.content_changed,
571 old_encoding_lossy: structured.old_encoding_lossy,
572 new_encoding_lossy: structured.new_encoding_lossy,
573 hunks: structured.hunks,
574 line_stat: None,
575 }
576}
577
578fn token_is_diff_revision(repo: &Repository, token: &str) -> bool {
579 resolve_diff_commit(repo, token).is_ok()
580 || parse_commit_range(repo, token).ok().flatten().is_some()
581}
582
583fn token_is_known_worktree_pathspec(
584 repo: &Repository,
585 cwd_prefix: Option<&str>,
586 token: &str,
587) -> bool {
588 let Ok(resolved) = repo.resolve_worktree_pathspec(token, cwd_prefix) else {
589 return false;
590 };
591 pathspec_matches_known_in_repository(repo, &resolved).unwrap_or(false)
592}
593
594fn disambiguate_diff_revision_and_paths(
596 repo: &Repository,
597 cwd_prefix: Option<&str>,
598 mut commits: Vec<String>,
599 mut paths: Vec<String>,
600) -> (Vec<String>, Vec<String>) {
601 if !paths.is_empty() {
602 return (commits, paths);
603 }
604 let is_path = |token: &str| token_is_known_worktree_pathspec(repo, cwd_prefix, token);
605 match commits.as_slice() {
606 [first, second]
607 if token_is_diff_revision(repo, first)
608 && !token_is_diff_revision(repo, second)
609 && is_path(second) =>
610 {
611 paths.extend(commits.pop());
612 }
613 [only] if !token_is_diff_revision(repo, only) && is_path(only) => {
614 paths.extend(commits.pop());
615 }
616 _ => {}
617 }
618 if paths.is_empty() {
619 while let [_, .., tail] = commits.as_slice() {
620 if token_is_diff_revision(repo, tail) || !is_path(tail) {
621 break;
622 }
623 paths.extend(commits.pop());
624 }
625 }
626 (commits, paths)
627}
628
629fn pathspec_resolution_root(repo: &Repository) -> &Path {
630 repo.work_tree().unwrap_or_else(|| repo.git_dir())
631}
632
633fn resolve_worktree_diff_pathspecs(
635 repo: &Repository,
636 cwd_prefix: Option<&str>,
637 raw_paths: &[String],
638) -> ResolveResult<Vec<String>> {
639 if raw_paths.is_empty() {
640 return Ok(Vec::new());
641 }
642 if repo.work_tree().is_none() {
643 return Err(ResolveDiffArgumentsError::NoWorkTree);
644 }
645 raw_paths
646 .iter()
647 .map(|pathspec| {
648 let resolved = repo.resolve_worktree_pathspec(pathspec, cwd_prefix)?;
649 ensure_pathspec_matches_known_in_repository(repo, pathspec, &resolved)?;
650 Ok(resolved)
651 })
652 .collect()
653}
654
655pub fn resolve_commit_diff_pathspecs(
673 repo: &Repository,
674 cwd_prefix: Option<&str>,
675 raw_paths: &[String],
676) -> ResolveResult<Vec<String>> {
677 if repo.work_tree().is_none() {
678 let root = pathspec_resolution_root(repo);
679 return Ok(raw_paths
680 .iter()
681 .map(|pathspec| resolve_pathspec(pathspec, root, cwd_prefix))
682 .collect());
683 }
684 raw_paths
685 .iter()
686 .map(|pathspec| Ok(repo.resolve_worktree_pathspec(pathspec, cwd_prefix)?))
687 .collect()
688}
689
690fn filter_file_changes(
691 changes: Vec<FileChange>,
692 repo: &Repository,
693 specs: &[String],
694) -> Result<Vec<FileChange>> {
695 if specs.is_empty() {
696 return Ok(changes);
697 }
698 let matcher = repo.pathspec_matcher(specs)?;
699 Ok(changes
700 .into_iter()
701 .filter(|c| matcher.matches_change(&c.path, c.old_path.as_deref()))
702 .collect())
703}
704
705fn diff_entry_matches(matcher: &PathspecMatcher<'_>, entry: &DiffEntry) -> bool {
706 let path = entry
707 .new_path
708 .as_deref()
709 .or(entry.old_path.as_deref())
710 .unwrap_or("");
711 matcher.matches_change(path, entry.old_path.as_deref())
712}
713
714fn filter_tree_diff(
715 items: Vec<TreeDiffStructured>,
716 repo: &Repository,
717 specs: &[String],
718) -> Result<Vec<TreeDiffStructured>> {
719 if specs.is_empty() {
720 return Ok(items);
721 }
722 let matcher = repo.pathspec_matcher(specs)?;
723 Ok(items
724 .into_iter()
725 .filter(|item| diff_entry_matches(&matcher, &item.entry))
726 .collect())
727}
728
729fn pathspec_restrict_set(pathspecs: &[String]) -> Option<Arc<BTreeSet<String>>> {
730 if pathspecs.is_empty() {
731 None
732 } else {
733 Some(Arc::new(pathspecs.iter().cloned().collect()))
734 }
735}
736
737fn unstaged_changes(repo: &Repository, rename_threshold: u32) -> Result<Vec<FileChange>> {
738 let mut index = repo.index_handle().load()?;
739 unstaged_changes_inner(repo, &mut index, rename_threshold, None)
740}
741
742fn unstaged_changes_inner(
743 repo: &Repository,
744 index: &mut Index,
745 rename_threshold: u32,
746 restrict_to_paths: Option<Arc<BTreeSet<String>>>,
747) -> Result<Vec<FileChange>> {
748 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
749 let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(repo, index)?));
750 let entries = unstaged_entries_in(
751 repo,
752 index,
753 work_tree,
754 &rules,
755 rename_threshold,
756 restrict_to_paths,
757 )?;
758 let side = WorktreeSide {
759 work_tree,
760 index,
761 rules: &rules,
762 };
763 load_changes(repo.odb(), entries, Some(&side))
764}
765
766pub(super) fn diff_index_to_tree_for_paths(
767 repo: &Repository,
768 index: &Index,
769 head_tree: Option<&ObjectId>,
770 paths: &BTreeSet<String>,
771) -> Result<Vec<DiffEntry>> {
772 let odb = repo.odb();
773 let mut result = Vec::new();
774 for path in paths {
775 let index_entry = index.get(path.as_bytes(), 0);
776 if index_entry.is_some_and(|e| e.intent_to_add()) {
777 continue;
778 }
779 let tree_side = match head_tree {
780 Some(t) => odb
781 .tree_entry_at_path(t, path.as_bytes())?
782 .map(|e| (e.oid, e.mode)),
783 None => None,
784 };
785 let (idx_oid, idx_mode) = index_entry
786 .map(|e| (e.oid, e.mode))
787 .unwrap_or((ObjectId::zero(), 0));
788 let (tree_oid, tree_mode) = tree_side.unwrap_or((ObjectId::zero(), 0));
789 if index_entry.is_none() && tree_side.is_none() {
790 continue;
791 }
792 if index_entry.is_some() && tree_side.is_none() {
793 result.push(DiffEntry {
794 status: DiffStatus::Added,
795 old_path: None,
796 new_path: Some(path.clone()),
797 old_mode: FileMode::ABSENT,
798 new_mode: FileMode::new(idx_mode),
799 old_oid: ObjectId::zero(),
800 new_oid: idx_oid,
801 score: None,
802 });
803 continue;
804 }
805 if index_entry.is_none() {
806 result.push(DiffEntry {
807 status: DiffStatus::Deleted,
808 old_path: Some(path.clone()),
809 new_path: None,
810 old_mode: FileMode::new(tree_mode),
811 new_mode: FileMode::ABSENT,
812 old_oid: tree_oid,
813 new_oid: ObjectId::zero(),
814 score: None,
815 });
816 continue;
817 }
818 if idx_oid != tree_oid || idx_mode != tree_mode {
819 result.push(DiffEntry {
820 status: DiffStatus::for_content_change(tree_mode, idx_mode),
821 old_path: Some(path.clone()),
822 new_path: Some(path.clone()),
823 old_mode: FileMode::new(tree_mode),
824 new_mode: FileMode::new(idx_mode),
825 old_oid: tree_oid,
826 new_oid: idx_oid,
827 score: None,
828 });
829 }
830 }
831 Ok(result)
832}
833
834fn staged_changes(
835 repo: &Repository,
836 rename_threshold: u32,
837 restrict_to_paths: Option<Arc<BTreeSet<String>>>,
838) -> Result<Vec<FileChange>> {
839 let index = repo.index_handle().load()?;
840 let entries = staged_entries_in(repo, &index, rename_threshold, restrict_to_paths.as_deref())?;
841 load_object_changes(repo, &entries)
842}
843
844fn worktree_changes(
845 repo: &Repository,
846 base_tree: Option<&ObjectId>,
847 rename_threshold: u32,
848) -> Result<Vec<FileChange>> {
849 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
850 let mut index = repo.index_handle().load()?;
851 let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(repo, &index)?));
852 let entries = worktree_entries_in(
853 repo,
854 &mut index,
855 work_tree,
856 &rules,
857 base_tree,
858 rename_threshold,
859 )?;
860 let side = WorktreeSide {
861 work_tree,
862 index: &index,
863 rules: &rules,
864 };
865 load_changes(repo.odb(), entries, Some(&side))
866}
867
868fn tree_commit_diff_entries(
869 repo: &Repository,
870 base: &ObjectId,
871 tip: &ObjectId,
872 config: &ConfigSet,
873 thread_policy: &ThreadPolicy,
874 rename_threshold: u32,
875) -> Result<Vec<DiffEntry>> {
876 let odb = repo.odb();
877 let base_tree = super::core::tree_oid_for_diff_side(odb, base)?;
878 let tip_tree = super::core::tree_oid_for_diff_side(odb, tip)?;
879 let mut entries = diff_trees_with_renames_and_policy(
880 odb,
881 Some(&base_tree),
882 Some(&tip_tree),
883 "",
884 rename_threshold,
885 config,
886 thread_policy,
887 )?;
888 sort_entries_by_path(&mut entries);
889 Ok(entries)
890}
891
892fn tree_commit_changes(
893 repo: &Repository,
894 base: &ObjectId,
895 tip: &ObjectId,
896 config: &ConfigSet,
897 thread_policy: &ThreadPolicy,
898 diff_options: DiffOptions,
899 rename_threshold: u32,
900) -> Result<Vec<TreeDiffStructured>> {
901 let entries =
902 tree_commit_diff_entries(repo, base, tip, config, thread_policy, rename_threshold)?;
903 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
904}
905
906fn commit_diff_entries(
907 repo: &Repository,
908 oid: &ObjectId,
909 config: &ConfigSet,
910 thread_policy: &ThreadPolicy,
911 rename_threshold: u32,
912) -> Result<Vec<DiffEntry>> {
913 let commit = repo.odb().read_commit(oid)?;
914 let new_tree = commit.tree;
915 let old_tree = match commit.parents.first() {
916 Some(parent) => Some(repo.odb().commit_tree(parent)?),
917 None => None,
918 };
919
920 let mut entries = diff_trees_with_renames_and_policy(
921 repo.odb(),
922 old_tree.as_ref(),
923 Some(&new_tree),
924 "",
925 rename_threshold,
926 config,
927 thread_policy,
928 )?;
929 sort_entries_by_path(&mut entries);
930 Ok(entries)
931}
932
933fn commit_changes(
934 repo: &Repository,
935 oid: &ObjectId,
936 config: &ConfigSet,
937 thread_policy: &ThreadPolicy,
938 diff_options: DiffOptions,
939 rename_threshold: u32,
940) -> Result<Vec<TreeDiffStructured>> {
941 let entries = commit_diff_entries(repo, oid, config, thread_policy, rename_threshold)?;
942 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
943}
944
945fn empty_tree_to_side_diff_entries(
946 repo: &Repository,
947 side: &ObjectId,
948 config: &ConfigSet,
949 thread_policy: &ThreadPolicy,
950 rename_threshold: u32,
951) -> Result<Vec<DiffEntry>> {
952 let tip_tree = super::core::tree_oid_for_diff_side(repo.odb(), side)?;
953 let mut entries = diff_trees_with_renames_and_policy(
954 repo.odb(),
955 None,
956 Some(&tip_tree),
957 "",
958 rename_threshold,
959 config,
960 thread_policy,
961 )?;
962 sort_entries_by_path(&mut entries);
963 Ok(entries)
964}
965
966fn empty_tree_to_side_changes(
967 repo: &Repository,
968 side: &ObjectId,
969 config: &ConfigSet,
970 thread_policy: &ThreadPolicy,
971 diff_options: DiffOptions,
972 rename_threshold: u32,
973) -> Result<Vec<TreeDiffStructured>> {
974 let entries =
975 empty_tree_to_side_diff_entries(repo, side, config, thread_policy, rename_threshold)?;
976 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
977}
978
979fn peel_diff_commit(repo: &Repository, oid: ObjectId) -> ResolveResult<ObjectId> {
980 peel_tags_to_commit(repo, oid)?.map_err(|oid| ResolveDiffArgumentsError::NotACommit { oid })
981}
982
983fn resolve_diff_commit(repo: &Repository, spec: &str) -> ResolveResult<ObjectId> {
985 let oid = resolve_revision(repo, spec).map_err(|source| {
986 ResolveDiffArgumentsError::UnresolvedRevision {
987 spec: spec.to_owned(),
988 source: Box::new(source),
989 }
990 })?;
991 peel_diff_commit(repo, oid)
992}
993
994fn resolve_diff_range_end(repo: &Repository, spec: &str) -> ResolveResult<ObjectId> {
996 let spec = if spec.is_empty() { "HEAD" } else { spec };
997 peel_diff_commit(repo, resolve_revision_for_range_end(repo, spec)?)
998}
999
1000fn parse_commit_range(
1002 repo: &Repository,
1003 spec: &str,
1004) -> ResolveResult<Option<(ObjectId, ObjectId)>> {
1005 if let Some((left, right)) = split_triple_dot_range(spec) {
1006 let left = resolve_diff_range_end(repo, left)?;
1007 let right = resolve_diff_range_end(repo, right)?;
1008 let base = merge_base_for_diff_two_commits(repo, left, right).map_err(|e| match e {
1009 MergeBaseForDiffError::None => ResolveDiffArgumentsError::NoMergeBase,
1010 MergeBaseForDiffError::Multiple => ResolveDiffArgumentsError::MultipleMergeBases,
1011 MergeBaseForDiffError::Other(msg) => Error::Message(msg).into(),
1012 })?;
1013 return Ok(Some((base, right)));
1014 }
1015 let Some((left, right)) = split_double_dot_range(spec) else {
1016 return Ok(None);
1017 };
1018 Ok(Some((
1019 resolve_diff_range_end(repo, left)?,
1020 resolve_diff_range_end(repo, right)?,
1021 )))
1022}
1023
1024pub(crate) type UnstagedDiffWithPeers = (
1026 Vec<FileDiff>,
1027 std::collections::HashMap<String, Vec<StructuredHunk>>,
1028);
1029
1030pub(crate) type UnstagedRawWithPeers = (
1031 Vec<FileChange>,
1032 std::collections::HashMap<String, Vec<StructuredHunk>>,
1033);
1034
1035pub(crate) fn unstaged_raw_changes_with_peer_hunks(
1037 repo: &Repository,
1038 pathspecs: &[String],
1039 opts: DiffOptions,
1040) -> Result<UnstagedRawWithPeers> {
1041 let mut index = repo.index_handle().load()?;
1042 unstaged_raw_changes_with_peer_hunks_in(repo, &mut index, pathspecs, opts)
1043}
1044
1045pub(crate) fn unstaged_raw_changes_with_peer_hunks_in(
1049 repo: &Repository,
1050 index: &mut Index,
1051 pathspecs: &[String],
1052 opts: DiffOptions,
1053) -> Result<UnstagedRawWithPeers> {
1054 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1055 let literal_paths = exact_literal_pathspec_set(pathspecs, PathspecMatchContext::default());
1056 let head_tree = match literal_paths {
1057 Some(_) => resolve_head_for_repository(repo)?.tree(repo.odb())?,
1058 None => None,
1059 };
1060 let peer_hunks = staged_peer_hunks_for_pathspecs(
1061 repo,
1062 index,
1063 head_tree,
1064 literal_paths.as_ref(),
1065 pathspecs,
1066 opts.clone(),
1067 rename_threshold,
1068 )?;
1069 let restrict = literal_paths.filter(|paths| {
1070 paths
1071 .iter()
1072 .all(|p| path_is_exact_tracked_file(repo, index, head_tree, p).unwrap_or(false))
1073 });
1074 let unstaged = if let Some(paths) = restrict {
1075 unstaged_changes_inner(repo, index, rename_threshold, Some(Arc::new(paths)))?
1076 } else {
1077 filter_file_changes(
1078 unstaged_changes_inner(repo, index, rename_threshold, None)?,
1079 repo,
1080 pathspecs,
1081 )?
1082 };
1083 Ok((unstaged, peer_hunks))
1084}
1085
1086fn path_has_staged_delta_vs_head_with_index(
1087 repo: &Repository,
1088 index: &Index,
1089 head_tree: Option<ObjectId>,
1090 path: &str,
1091) -> Result<bool> {
1092 let path_bytes = path.as_bytes();
1093 if index.get(path_bytes, 2).is_some() || index.get(path_bytes, 3).is_some() {
1094 return Ok(true);
1095 }
1096 let head_blob = match head_tree {
1097 Some(tree) => repo
1098 .odb()
1099 .tree_entry_at_path(&tree, path_bytes)?
1100 .map(|e| e.oid),
1101 None => None,
1102 };
1103 let Some(idx_entry) = index.get(path_bytes, 0) else {
1104 return Ok(head_blob.is_some());
1105 };
1106 match head_blob {
1107 None => Ok(true),
1108 Some(h) => Ok(idx_entry.oid != h),
1109 }
1110}
1111
1112fn path_is_exact_tracked_file(
1113 repo: &Repository,
1114 index: &Index,
1115 head_tree: Option<ObjectId>,
1116 path: &str,
1117) -> Result<bool> {
1118 let path_bytes = path.as_bytes();
1119 if let Some(entry) = index.get(path_bytes, 0) {
1120 return Ok(entry.mode != MODE_TREE);
1121 }
1122 match head_tree {
1123 Some(tree) => Ok(repo.odb().tree_entry_at_path(&tree, path_bytes)?.is_some()),
1124 None => Ok(false),
1125 }
1126}
1127
1128fn staged_peer_hunks_for_pathspecs(
1131 repo: &Repository,
1132 index: &Index,
1133 head_tree: Option<ObjectId>,
1134 literal_paths: Option<&BTreeSet<String>>,
1135 pathspecs: &[String],
1136 opts: DiffOptions,
1137 rename_threshold: u32,
1138) -> Result<std::collections::HashMap<String, Vec<StructuredHunk>>> {
1139 if let Some(literal_paths) = literal_paths {
1140 let all_exact_files = literal_paths
1141 .iter()
1142 .all(|p| path_is_exact_tracked_file(repo, index, head_tree, p).unwrap_or(false));
1143 if all_exact_files
1144 && literal_paths.iter().all(|p| {
1145 path_has_staged_delta_vs_head_with_index(repo, index, head_tree, p)
1146 .map(|delta| !delta)
1147 .unwrap_or(false)
1148 })
1149 {
1150 return Ok(std::collections::HashMap::new());
1151 }
1152 }
1153 let staged = filter_file_changes(
1154 staged_changes(repo, rename_threshold, None)?,
1155 repo,
1156 pathspecs,
1157 )?;
1158 Ok(staged
1159 .iter()
1160 .map(|fc| {
1161 let structured = fc.structured(opts.clone());
1162 (fc.path.clone(), structured.hunks)
1163 })
1164 .collect())
1165}
1166
1167pub(crate) fn unstaged_file_diffs_with_peer_hunks(
1169 repo: &Repository,
1170 pathspecs: &[String],
1171 opts: DiffOptions,
1172) -> Result<UnstagedDiffWithPeers> {
1173 let config = repo.config()?;
1174 let thread_policy = repo.thread_policy();
1175 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1176 let staged = filter_file_changes(
1177 staged_changes(repo, rename_threshold, None)?,
1178 repo,
1179 pathspecs,
1180 )?;
1181 let peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>> = staged
1182 .iter()
1183 .map(|fc| {
1184 let structured = fc.structured(opts.clone());
1185 (fc.path.clone(), structured.hunks)
1186 })
1187 .collect();
1188 let unstaged = filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
1189 let files = file_diffs_from_worktree_changes(unstaged, &config, &thread_policy, opts)?;
1190 Ok((files, peer_hunks))
1191}
1192
1193pub struct HunkDiffBundle {
1195 pub unstaged: Vec<FileDiff>,
1197 pub peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>>,
1199}
1200
1201pub fn hunk_diff_bundle(
1210 repo: &Repository,
1211 pathspecs: &[String],
1212 opts: DiffOptions,
1213) -> Result<HunkDiffBundle> {
1214 let config = repo.config()?;
1215 let thread_policy = repo.thread_policy();
1216 let (unstaged_fc, peer_hunks) =
1217 unstaged_raw_changes_with_peer_hunks(repo, pathspecs, opts.clone())?;
1218 let unstaged = file_diffs_from_worktree_changes(unstaged_fc, &config, &thread_policy, opts)?;
1219 Ok(HunkDiffBundle {
1220 unstaged,
1221 peer_hunks,
1222 })
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[test]
1230 fn include_file_omits_whitespace_only_modified() {
1231 let file = FileDiff {
1232 path: "x.txt".into(),
1233 old_path: None,
1234 status: DiffStatus::Modified,
1235 old_mode: Some("100644".into()),
1236 new_mode: Some("100644".into()),
1237 old_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1238 new_oid: ObjectId::from_hex("0000000000000000000000000000000000000002").unwrap(),
1239 binary: false,
1240 content_changed: true,
1241 old_encoding_lossy: false,
1242 new_encoding_lossy: false,
1243 hunks: Vec::new(),
1244 line_stat: None,
1245 };
1246 let opts =
1247 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1248 assert!(!include_file_in_diff(&file, &opts));
1249 }
1250
1251 #[test]
1252 fn include_file_omits_ws_only_even_when_content_changed_false() {
1253 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
1254 let file = FileDiff {
1255 path: "x.txt".into(),
1256 old_path: None,
1257 status: DiffStatus::Modified,
1258 old_mode: Some("100644".into()),
1259 new_mode: Some("100644".into()),
1260 old_oid: oid,
1261 new_oid: oid,
1262 binary: false,
1263 content_changed: false,
1264 old_encoding_lossy: false,
1265 new_encoding_lossy: false,
1266 hunks: Vec::new(),
1267 line_stat: None,
1268 };
1269 let opts =
1270 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1271 assert!(!include_file_in_diff(&file, &opts));
1272 }
1273
1274 #[test]
1275 fn include_file_keeps_mode_only_under_whitespace() {
1276 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
1277 let file = FileDiff {
1278 path: "x.txt".into(),
1279 old_path: None,
1280 status: DiffStatus::Modified,
1281 old_mode: Some("100644".into()),
1282 new_mode: Some("100755".into()),
1283 old_oid: oid,
1284 new_oid: oid,
1285 binary: false,
1286 content_changed: false,
1287 old_encoding_lossy: false,
1288 new_encoding_lossy: false,
1289 hunks: Vec::new(),
1290 line_stat: None,
1291 };
1292 let opts =
1293 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1294 assert!(include_file_in_diff(&file, &opts));
1295 }
1296
1297 #[test]
1298 fn include_file_keeps_whitespace_only_rename() {
1299 let file = FileDiff {
1300 path: "new.txt".into(),
1301 old_path: Some("old.txt".into()),
1302 status: DiffStatus::Renamed,
1303 old_mode: Some("100644".into()),
1304 new_mode: Some("100644".into()),
1305 old_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1306 new_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1307 binary: false,
1308 content_changed: false,
1309 old_encoding_lossy: false,
1310 new_encoding_lossy: false,
1311 hunks: Vec::new(),
1312 line_stat: None,
1313 };
1314 let opts =
1315 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1316 assert!(include_file_in_diff(&file, &opts));
1317 }
1318
1319 #[test]
1320 fn failed_revision_with_file_paths_keeps_revision_error() {
1321 let dir = tempfile::tempdir().expect("tempdir");
1322 let repo =
1323 Repository::init(dir.path(), &crate::repo::InitOptions::default()).expect("init");
1324 let request = DiffRequest {
1325 positionals: vec!["unknown-revision".into()],
1326 pathspec_file: Some(vec!["selected.txt".into()]),
1327 ..DiffRequest::default()
1328 };
1329 let err = resolve_diff_request(&repo, None, &request).expect_err("unknown revision");
1330 assert!(
1331 matches!(err, ResolveDiffArgumentsError::UnresolvedRevision { spec, .. } if spec == "unknown-revision")
1332 );
1333 }
1334
1335 #[test]
1336 fn resolve_errors_staged_and_unstaged() {
1337 let dir = tempfile::tempdir().expect("tempdir");
1338 let repo = crate::repo::Repository::init(dir.path(), &crate::repo::InitOptions::default())
1339 .expect("init");
1340 let err = resolve_diff_arguments(&repo, None, true, true, None, &[], &[]).unwrap_err();
1341 assert!(matches!(err, ResolveDiffArgumentsError::StagedAndUnstaged));
1342 }
1343}