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 head_tree, load_changes, load_object_changes, staged_entries_in, unstaged_entries_in,
12 worktree_entries_in, 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 diff_commits_with_renames_and_policy, sort_entries_by_path, tree_diff_structured_parallel,
19 DiffEntry, DiffOptions, DiffStatus, TreeDiffStructured,
20};
21use crate::error::{Error, PathspecResolveError, Result};
22use crate::index::Index;
23use crate::index::MODE_TREE;
24use crate::objects::FileMode;
25use crate::objects::{parse_commit, 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::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;
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(commit_tree(
355 repo,
356 &resolve_diff_commit(repo, spec)?,
357 )?),
358 None => DiffTarget::WorktreeVsHead,
359 };
360 Ok(DiffArguments { target, pathspecs })
361 }
362 [spec] => {
363 if let Some((base, tip)) = parse_commit_range(repo, spec)? {
364 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_tokens)?;
365 return Ok(trees(base, tip, pathspecs));
366 }
367 match resolve_diff_commit(repo, spec) {
368 Ok(oid) => Ok(DiffArguments {
369 target: DiffTarget::Commit(oid),
370 pathspecs: resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_tokens)?,
371 }),
372 Err(_) if path_tokens.is_empty() => Ok(DiffArguments {
374 target: DiffTarget::WorktreeVsIndex,
375 pathspecs: resolve_worktree_diff_pathspecs(
376 repo,
377 cwd_prefix,
378 std::slice::from_ref(spec),
379 )?,
380 }),
381 Err(err) => Err(err),
382 }
383 }
384 [first, second] => {
385 if let Some((base, tip)) = parse_commit_range(repo, first)? {
386 if !token_is_known_worktree_pathspec(repo, cwd_prefix, second) {
387 resolve_diff_commit(repo, second)?;
388 return Err(ResolveDiffArgumentsError::RangeWithSecondRevision);
389 }
390 let range_paths = [file_paths, std::slice::from_ref(second)].concat();
391 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &range_paths)?;
392 return Ok(trees(base, tip, pathspecs));
393 }
394 let base = resolve_diff_commit(repo, first)?;
395 let tip = resolve_diff_commit(repo, second)?;
396 let pathspecs = resolve_commit_diff_pathspecs(repo, cwd_prefix, &path_tokens)?;
397 Ok(trees(base, tip, pathspecs))
398 }
399 _ => Err(ResolveDiffArgumentsError::TooManyRevisions),
400 }
401}
402
403pub(crate) fn include_file_in_diff(file: &FileDiff, opts: &DiffOptions) -> bool {
404 if !opts.whitespace.is_active() || file.binary {
405 return true;
406 }
407 if matches!(
408 file.status,
409 DiffStatus::Renamed
410 | DiffStatus::Added
411 | DiffStatus::Deleted
412 | DiffStatus::Copied
413 | DiffStatus::TypeChanged
414 ) {
415 return true;
416 }
417 if file.old_mode != file.new_mode {
418 return true;
419 }
420 !file.hunks.is_empty()
421}
422
423pub(crate) fn collect_files_for_target(
424 repo: &Repository,
425 target: &DiffTarget,
426 pathspecs: &[String],
427 opts: DiffOptions,
428) -> Result<Vec<FileDiff>> {
429 let config = repo.config()?;
430 let thread_policy = repo.thread_policy();
431 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
433
434 let files = match target {
435 DiffTarget::WorktreeVsHead => {
436 let head = resolve_head(repo.git_dir())?;
437 let head_tree = match head.oid() {
438 Some(oid) => Some(commit_tree(repo, oid)?),
439 None => None,
440 };
441 let changes = filter_file_changes(
442 worktree_changes(repo, head_tree.as_ref(), rename_threshold)?,
443 repo,
444 pathspecs,
445 )?;
446 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
447 }
448 DiffTarget::WorktreeVsTree(tree) => {
449 let changes = filter_file_changes(
450 worktree_changes(repo, Some(tree), rename_threshold)?,
451 repo,
452 pathspecs,
453 )?;
454 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
455 }
456 DiffTarget::IndexVsHead => {
457 let restrict = pathspec_restrict_set(pathspecs);
458 let changes = filter_file_changes(
459 staged_changes(repo, rename_threshold, restrict)?,
460 repo,
461 pathspecs,
462 )?;
463 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
464 }
465 DiffTarget::WorktreeVsIndex => {
466 let changes =
467 filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
468 file_diffs_from_worktree_changes(changes, &config, &thread_policy, opts.clone())?
469 }
470 DiffTarget::Commit(oid) => {
471 let items = commit_changes(
472 repo,
473 oid,
474 &config,
475 &thread_policy,
476 opts.clone(),
477 rename_threshold,
478 )?;
479 filter_and_map_tree_structured(items, repo, pathspecs, opts)?
480 }
481 DiffTarget::Trees { old, new } => {
482 let items = if let Some(base) = old {
483 tree_commit_changes(
484 repo,
485 base,
486 new,
487 &config,
488 &thread_policy,
489 opts.clone(),
490 rename_threshold,
491 )?
492 } else {
493 commit_changes(
494 repo,
495 new,
496 &config,
497 &thread_policy,
498 opts.clone(),
499 rename_threshold,
500 )?
501 };
502 filter_and_map_tree_structured(items, repo, pathspecs, opts)?
503 }
504 };
505 Ok(files)
506}
507
508fn filter_and_map_tree_structured(
509 items: Vec<TreeDiffStructured>,
510 repo: &Repository,
511 pathspecs: &[String],
512 opts: DiffOptions,
513) -> Result<Vec<FileDiff>> {
514 Ok(filter_tree_diff(items, repo, pathspecs)?
515 .into_iter()
516 .map(file_diff_from_tree_structured)
517 .filter(|f| include_file_in_diff(f, &opts))
518 .collect())
519}
520
521fn file_diff_from_tree_structured(t: TreeDiffStructured) -> FileDiff {
522 let e = t.entry;
523 let path = e
524 .new_path
525 .clone()
526 .or_else(|| e.old_path.clone())
527 .unwrap_or_default();
528 let old_path = e.old_path.filter(|op| *op != path);
529 FileDiff {
530 path,
531 old_path,
532 status: e.status,
533 old_mode: t.structured.old_mode,
534 new_mode: t.structured.new_mode,
535 old_oid: e.old_oid,
536 new_oid: e.new_oid,
537 binary: t.binary,
538 content_changed: e.old_oid != e.new_oid,
539 old_encoding_lossy: t.structured.old_encoding_lossy,
540 new_encoding_lossy: t.structured.new_encoding_lossy,
541 hunks: t.structured.hunks,
542 line_stat: None,
543 }
544}
545
546fn file_diffs_from_worktree_changes(
547 changes: Vec<FileChange>,
548 config: &ConfigSet,
549 thread_policy: &ThreadPolicy,
550 opts: DiffOptions,
551) -> Result<Vec<FileDiff>> {
552 let opts = opts.cap_word_level_inline_for_changed_files(changes.len());
553 let mut par_opts = thread_policy.resolve(ParallelOp::DiffContent, config);
554 par_opts.min_items = 0;
555 let est_bytes = changes.len().saturating_mul(8192);
556 Ok(par_map(&changes, par_opts, est_bytes, |fc| {
557 build_file_diff(fc, opts.clone())
558 })
559 .into_iter()
560 .filter(|f| include_file_in_diff(f, &opts))
561 .collect())
562}
563
564fn build_file_diff(fc: &FileChange, diff_options: DiffOptions) -> FileDiff {
565 let structured = fc.structured(diff_options);
566 FileDiff {
567 path: fc.path.clone(),
568 old_path: fc.old_path.clone(),
569 status: fc.status,
570 old_mode: structured.old_mode,
571 new_mode: structured.new_mode,
572 old_oid: fc.old_oid,
573 new_oid: fc.new_oid,
574 binary: fc.binary,
575 content_changed: fc.content_changed,
576 old_encoding_lossy: structured.old_encoding_lossy,
577 new_encoding_lossy: structured.new_encoding_lossy,
578 hunks: structured.hunks,
579 line_stat: None,
580 }
581}
582
583fn token_is_diff_revision(repo: &Repository, token: &str) -> bool {
584 resolve_diff_commit(repo, token).is_ok()
585 || parse_commit_range(repo, token).ok().flatten().is_some()
586}
587
588fn token_is_known_worktree_pathspec(
589 repo: &Repository,
590 cwd_prefix: Option<&str>,
591 token: &str,
592) -> bool {
593 let Ok(resolved) = repo.resolve_worktree_pathspec(token, cwd_prefix) else {
594 return false;
595 };
596 pathspec_matches_known_in_repository(repo, &resolved).unwrap_or(false)
597}
598
599fn disambiguate_diff_revision_and_paths(
601 repo: &Repository,
602 cwd_prefix: Option<&str>,
603 mut commits: Vec<String>,
604 mut paths: Vec<String>,
605) -> (Vec<String>, Vec<String>) {
606 if !paths.is_empty() {
607 return (commits, paths);
608 }
609 let is_path = |token: &str| token_is_known_worktree_pathspec(repo, cwd_prefix, token);
610 match commits.as_slice() {
611 [first, second]
612 if token_is_diff_revision(repo, first)
613 && !token_is_diff_revision(repo, second)
614 && is_path(second) =>
615 {
616 paths.extend(commits.pop());
617 }
618 [only] if !token_is_diff_revision(repo, only) && is_path(only) => {
619 paths.extend(commits.pop());
620 }
621 _ => {}
622 }
623 if paths.is_empty() {
624 while let [_, .., tail] = commits.as_slice() {
625 if token_is_diff_revision(repo, tail) || !is_path(tail) {
626 break;
627 }
628 paths.extend(commits.pop());
629 }
630 }
631 (commits, paths)
632}
633
634fn pathspec_resolution_root(repo: &Repository) -> &Path {
635 repo.work_tree().unwrap_or_else(|| repo.git_dir())
636}
637
638fn resolve_worktree_diff_pathspecs(
640 repo: &Repository,
641 cwd_prefix: Option<&str>,
642 raw_paths: &[String],
643) -> ResolveResult<Vec<String>> {
644 if raw_paths.is_empty() {
645 return Ok(Vec::new());
646 }
647 if repo.work_tree().is_none() {
648 return Err(ResolveDiffArgumentsError::NoWorkTree);
649 }
650 raw_paths
651 .iter()
652 .map(|pathspec| {
653 let resolved = repo.resolve_worktree_pathspec(pathspec, cwd_prefix)?;
654 ensure_pathspec_matches_known_in_repository(repo, pathspec, &resolved)?;
655 Ok(resolved)
656 })
657 .collect()
658}
659
660pub fn resolve_commit_diff_pathspecs(
678 repo: &Repository,
679 cwd_prefix: Option<&str>,
680 raw_paths: &[String],
681) -> ResolveResult<Vec<String>> {
682 if repo.work_tree().is_none() {
683 let root = pathspec_resolution_root(repo);
684 return Ok(raw_paths
685 .iter()
686 .map(|pathspec| resolve_pathspec(pathspec, root, cwd_prefix))
687 .collect());
688 }
689 raw_paths
690 .iter()
691 .map(|pathspec| Ok(repo.resolve_worktree_pathspec(pathspec, cwd_prefix)?))
692 .collect()
693}
694
695fn filter_file_changes(
696 changes: Vec<FileChange>,
697 repo: &Repository,
698 specs: &[String],
699) -> Result<Vec<FileChange>> {
700 if specs.is_empty() {
701 return Ok(changes);
702 }
703 let matcher = repo.pathspec_matcher(specs)?;
704 Ok(changes
705 .into_iter()
706 .filter(|c| matcher.matches_change(&c.path, c.old_path.as_deref()))
707 .collect())
708}
709
710fn diff_entry_matches(matcher: &PathspecMatcher<'_>, entry: &DiffEntry) -> bool {
711 let path = entry
712 .new_path
713 .as_deref()
714 .or(entry.old_path.as_deref())
715 .unwrap_or("");
716 matcher.matches_change(path, entry.old_path.as_deref())
717}
718
719fn filter_tree_diff(
720 items: Vec<TreeDiffStructured>,
721 repo: &Repository,
722 specs: &[String],
723) -> Result<Vec<TreeDiffStructured>> {
724 if specs.is_empty() {
725 return Ok(items);
726 }
727 let matcher = repo.pathspec_matcher(specs)?;
728 Ok(items
729 .into_iter()
730 .filter(|item| diff_entry_matches(&matcher, &item.entry))
731 .collect())
732}
733
734fn pathspec_restrict_set(pathspecs: &[String]) -> Option<Arc<BTreeSet<String>>> {
735 if pathspecs.is_empty() {
736 None
737 } else {
738 Some(Arc::new(pathspecs.iter().cloned().collect()))
739 }
740}
741
742fn unstaged_changes(repo: &Repository, rename_threshold: u32) -> Result<Vec<FileChange>> {
743 let mut index = repo.index_handle().load()?;
744 unstaged_changes_inner(repo, &mut index, rename_threshold, None)
745}
746
747fn unstaged_changes_inner(
748 repo: &Repository,
749 index: &mut Index,
750 rename_threshold: u32,
751 restrict_to_paths: Option<Arc<BTreeSet<String>>>,
752) -> Result<Vec<FileChange>> {
753 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
754 let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(repo, index)?));
755 let entries = unstaged_entries_in(
756 repo,
757 index,
758 work_tree,
759 &rules,
760 rename_threshold,
761 restrict_to_paths,
762 )?;
763 let side = WorktreeSide {
764 work_tree,
765 index,
766 rules: &rules,
767 };
768 load_changes(repo.odb(), entries, Some(&side))
769}
770
771pub(super) fn diff_index_to_tree_for_paths(
772 repo: &Repository,
773 index: &Index,
774 head_tree: Option<&ObjectId>,
775 paths: &BTreeSet<String>,
776) -> Result<Vec<DiffEntry>> {
777 let odb = repo.odb();
778 let mut result = Vec::new();
779 for path in paths {
780 let index_entry = index.get(path.as_bytes(), 0);
781 if index_entry.is_some_and(|e| e.intent_to_add()) {
782 continue;
783 }
784 let tree_side = match head_tree {
785 Some(t) => odb
786 .tree_entry_at_path(t, path.as_bytes())?
787 .map(|e| (e.oid, e.mode)),
788 None => None,
789 };
790 let (idx_oid, idx_mode) = index_entry
791 .map(|e| (e.oid, e.mode))
792 .unwrap_or((ObjectId::zero(), 0));
793 let (tree_oid, tree_mode) = tree_side.unwrap_or((ObjectId::zero(), 0));
794 if index_entry.is_none() && tree_side.is_none() {
795 continue;
796 }
797 if index_entry.is_some() && tree_side.is_none() {
798 result.push(DiffEntry {
799 status: DiffStatus::Added,
800 old_path: None,
801 new_path: Some(path.clone()),
802 old_mode: FileMode::ABSENT,
803 new_mode: FileMode::new(idx_mode),
804 old_oid: ObjectId::zero(),
805 new_oid: idx_oid,
806 score: None,
807 });
808 continue;
809 }
810 if index_entry.is_none() {
811 result.push(DiffEntry {
812 status: DiffStatus::Deleted,
813 old_path: Some(path.clone()),
814 new_path: None,
815 old_mode: FileMode::new(tree_mode),
816 new_mode: FileMode::ABSENT,
817 old_oid: tree_oid,
818 new_oid: ObjectId::zero(),
819 score: None,
820 });
821 continue;
822 }
823 if idx_oid != tree_oid || idx_mode != tree_mode {
824 result.push(DiffEntry {
825 status: DiffStatus::for_content_change(tree_mode, idx_mode),
826 old_path: Some(path.clone()),
827 new_path: Some(path.clone()),
828 old_mode: FileMode::new(tree_mode),
829 new_mode: FileMode::new(idx_mode),
830 old_oid: tree_oid,
831 new_oid: idx_oid,
832 score: None,
833 });
834 }
835 }
836 Ok(result)
837}
838
839fn staged_changes(
840 repo: &Repository,
841 rename_threshold: u32,
842 restrict_to_paths: Option<Arc<BTreeSet<String>>>,
843) -> Result<Vec<FileChange>> {
844 let index = repo.index_handle().load()?;
845 let entries = staged_entries_in(repo, &index, rename_threshold, restrict_to_paths.as_deref())?;
846 load_object_changes(repo, &entries)
847}
848
849fn worktree_changes(
850 repo: &Repository,
851 base_tree: Option<&ObjectId>,
852 rename_threshold: u32,
853) -> Result<Vec<FileChange>> {
854 let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
855 let mut index = repo.index_handle().load()?;
856 let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(repo, &index)?));
857 let entries = worktree_entries_in(
858 repo,
859 &mut index,
860 work_tree,
861 &rules,
862 base_tree,
863 rename_threshold,
864 )?;
865 let side = WorktreeSide {
866 work_tree,
867 index: &index,
868 rules: &rules,
869 };
870 load_changes(repo.odb(), entries, Some(&side))
871}
872
873fn tree_commit_diff_entries(
874 repo: &Repository,
875 base: &ObjectId,
876 tip: &ObjectId,
877 config: &ConfigSet,
878 thread_policy: &ThreadPolicy,
879 rename_threshold: u32,
880) -> Result<Vec<DiffEntry>> {
881 let mut entries = diff_commits_with_renames_and_policy(
882 repo.odb(),
883 base,
884 tip,
885 rename_threshold,
886 config,
887 thread_policy,
888 )?;
889 sort_entries_by_path(&mut entries);
890 Ok(entries)
891}
892
893fn tree_commit_changes(
894 repo: &Repository,
895 base: &ObjectId,
896 tip: &ObjectId,
897 config: &ConfigSet,
898 thread_policy: &ThreadPolicy,
899 diff_options: DiffOptions,
900 rename_threshold: u32,
901) -> Result<Vec<TreeDiffStructured>> {
902 let entries =
903 tree_commit_diff_entries(repo, base, tip, config, thread_policy, rename_threshold)?;
904 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
905}
906
907fn commit_diff_entries(
908 repo: &Repository,
909 oid: &ObjectId,
910 config: &ConfigSet,
911 thread_policy: &ThreadPolicy,
912 rename_threshold: u32,
913) -> Result<Vec<DiffEntry>> {
914 let commit = read_commit(repo, oid)?;
915 let new_tree = commit.tree;
916 let old_tree = match commit.parents.first() {
917 Some(parent) => Some(commit_tree(repo, parent)?),
918 None => None,
919 };
920
921 let mut entries = diff_trees_with_renames_and_policy(
922 repo.odb(),
923 old_tree.as_ref(),
924 Some(&new_tree),
925 "",
926 rename_threshold,
927 config,
928 thread_policy,
929 )?;
930 sort_entries_by_path(&mut entries);
931 Ok(entries)
932}
933
934fn commit_changes(
935 repo: &Repository,
936 oid: &ObjectId,
937 config: &ConfigSet,
938 thread_policy: &ThreadPolicy,
939 diff_options: DiffOptions,
940 rename_threshold: u32,
941) -> Result<Vec<TreeDiffStructured>> {
942 let entries = commit_diff_entries(repo, oid, config, thread_policy, rename_threshold)?;
943 tree_diff_structured_parallel(repo.odb(), entries, config, thread_policy, diff_options)
944}
945
946fn peel_diff_commit(repo: &Repository, oid: ObjectId) -> ResolveResult<ObjectId> {
947 peel_tags_to_commit(repo, oid)?.map_err(|oid| ResolveDiffArgumentsError::NotACommit { oid })
948}
949
950fn resolve_diff_commit(repo: &Repository, spec: &str) -> ResolveResult<ObjectId> {
952 let oid = resolve_revision(repo, spec).map_err(|source| {
953 ResolveDiffArgumentsError::UnresolvedRevision {
954 spec: spec.to_owned(),
955 source: Box::new(source),
956 }
957 })?;
958 peel_diff_commit(repo, oid)
959}
960
961fn resolve_diff_range_end(repo: &Repository, spec: &str) -> ResolveResult<ObjectId> {
963 let spec = if spec.is_empty() { "HEAD" } else { spec };
964 peel_diff_commit(repo, resolve_revision_for_range_end(repo, spec)?)
965}
966
967fn parse_commit_range(
969 repo: &Repository,
970 spec: &str,
971) -> ResolveResult<Option<(ObjectId, ObjectId)>> {
972 if let Some((left, right)) = split_triple_dot_range(spec) {
973 let left = resolve_diff_range_end(repo, left)?;
974 let right = resolve_diff_range_end(repo, right)?;
975 let base = merge_base_for_diff_two_commits(repo, left, right).map_err(|e| match e {
976 MergeBaseForDiffError::None => ResolveDiffArgumentsError::NoMergeBase,
977 MergeBaseForDiffError::Multiple => ResolveDiffArgumentsError::MultipleMergeBases,
978 MergeBaseForDiffError::Other(msg) => Error::Message(msg).into(),
979 })?;
980 return Ok(Some((base, right)));
981 }
982 let Some((left, right)) = split_double_dot_range(spec) else {
983 return Ok(None);
984 };
985 Ok(Some((
986 resolve_diff_range_end(repo, left)?,
987 resolve_diff_range_end(repo, right)?,
988 )))
989}
990
991fn read_commit(repo: &Repository, oid: &ObjectId) -> Result<CommitData> {
992 let object = repo.odb().read(oid)?;
993 if object.kind != ObjectKind::Commit {
994 return Err(Error::Message(format!(
995 "object {oid} is a {}, not a commit",
996 object.kind
997 )));
998 }
999 parse_commit(&object.data)
1000}
1001
1002pub(super) fn commit_tree(repo: &Repository, commit: &ObjectId) -> Result<ObjectId> {
1003 Ok(read_commit(repo, commit)?.tree)
1004}
1005
1006pub(crate) type UnstagedDiffWithPeers = (
1008 Vec<FileDiff>,
1009 std::collections::HashMap<String, Vec<StructuredHunk>>,
1010);
1011
1012pub(crate) type UnstagedRawWithPeers = (
1013 Vec<FileChange>,
1014 std::collections::HashMap<String, Vec<StructuredHunk>>,
1015);
1016
1017pub(crate) fn unstaged_raw_changes_with_peer_hunks(
1019 repo: &Repository,
1020 pathspecs: &[String],
1021 opts: DiffOptions,
1022) -> Result<UnstagedRawWithPeers> {
1023 let mut index = repo.index_handle().load()?;
1024 unstaged_raw_changes_with_peer_hunks_in(repo, &mut index, pathspecs, opts)
1025}
1026
1027pub(crate) fn unstaged_raw_changes_with_peer_hunks_in(
1031 repo: &Repository,
1032 index: &mut Index,
1033 pathspecs: &[String],
1034 opts: DiffOptions,
1035) -> Result<UnstagedRawWithPeers> {
1036 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1037 let literal_paths = exact_literal_pathspec_set(pathspecs, PathspecMatchContext::default());
1038 let head_tree = match literal_paths {
1039 Some(_) => head_tree(repo)?,
1040 None => None,
1041 };
1042 let peer_hunks = staged_peer_hunks_for_pathspecs(
1043 repo,
1044 index,
1045 head_tree,
1046 literal_paths.as_ref(),
1047 pathspecs,
1048 opts.clone(),
1049 rename_threshold,
1050 )?;
1051 let restrict = literal_paths.filter(|paths| {
1052 paths
1053 .iter()
1054 .all(|p| path_is_exact_tracked_file(repo, index, head_tree, p).unwrap_or(false))
1055 });
1056 let unstaged = if let Some(paths) = restrict {
1057 unstaged_changes_inner(repo, index, rename_threshold, Some(Arc::new(paths)))?
1058 } else {
1059 filter_file_changes(
1060 unstaged_changes_inner(repo, index, rename_threshold, None)?,
1061 repo,
1062 pathspecs,
1063 )?
1064 };
1065 Ok((unstaged, peer_hunks))
1066}
1067
1068fn path_has_staged_delta_vs_head_with_index(
1069 repo: &Repository,
1070 index: &Index,
1071 head_tree: Option<ObjectId>,
1072 path: &str,
1073) -> Result<bool> {
1074 let path_bytes = path.as_bytes();
1075 if index.get(path_bytes, 2).is_some() || index.get(path_bytes, 3).is_some() {
1076 return Ok(true);
1077 }
1078 let head_blob = match head_tree {
1079 Some(tree) => repo
1080 .odb()
1081 .tree_entry_at_path(&tree, path_bytes)?
1082 .map(|e| e.oid),
1083 None => None,
1084 };
1085 let Some(idx_entry) = index.get(path_bytes, 0) else {
1086 return Ok(head_blob.is_some());
1087 };
1088 match head_blob {
1089 None => Ok(true),
1090 Some(h) => Ok(idx_entry.oid != h),
1091 }
1092}
1093
1094fn path_is_exact_tracked_file(
1095 repo: &Repository,
1096 index: &Index,
1097 head_tree: Option<ObjectId>,
1098 path: &str,
1099) -> Result<bool> {
1100 let path_bytes = path.as_bytes();
1101 if let Some(entry) = index.get(path_bytes, 0) {
1102 return Ok(entry.mode != MODE_TREE);
1103 }
1104 match head_tree {
1105 Some(tree) => Ok(repo.odb().tree_entry_at_path(&tree, path_bytes)?.is_some()),
1106 None => Ok(false),
1107 }
1108}
1109
1110fn staged_peer_hunks_for_pathspecs(
1113 repo: &Repository,
1114 index: &Index,
1115 head_tree: Option<ObjectId>,
1116 literal_paths: Option<&BTreeSet<String>>,
1117 pathspecs: &[String],
1118 opts: DiffOptions,
1119 rename_threshold: u32,
1120) -> Result<std::collections::HashMap<String, Vec<StructuredHunk>>> {
1121 if let Some(literal_paths) = literal_paths {
1122 let all_exact_files = literal_paths
1123 .iter()
1124 .all(|p| path_is_exact_tracked_file(repo, index, head_tree, p).unwrap_or(false));
1125 if all_exact_files
1126 && literal_paths.iter().all(|p| {
1127 path_has_staged_delta_vs_head_with_index(repo, index, head_tree, p)
1128 .map(|delta| !delta)
1129 .unwrap_or(false)
1130 })
1131 {
1132 return Ok(std::collections::HashMap::new());
1133 }
1134 }
1135 let staged = filter_file_changes(
1136 staged_changes(repo, rename_threshold, None)?,
1137 repo,
1138 pathspecs,
1139 )?;
1140 Ok(staged
1141 .iter()
1142 .map(|fc| {
1143 let structured = fc.structured(opts.clone());
1144 (fc.path.clone(), structured.hunks)
1145 })
1146 .collect())
1147}
1148
1149pub(crate) fn unstaged_file_diffs_with_peer_hunks(
1151 repo: &Repository,
1152 pathspecs: &[String],
1153 opts: DiffOptions,
1154) -> Result<UnstagedDiffWithPeers> {
1155 let config = repo.config()?;
1156 let thread_policy = repo.thread_policy();
1157 let rename_threshold = opts.renames.threshold_percent().unwrap_or(101);
1158 let staged = filter_file_changes(
1159 staged_changes(repo, rename_threshold, None)?,
1160 repo,
1161 pathspecs,
1162 )?;
1163 let peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>> = staged
1164 .iter()
1165 .map(|fc| {
1166 let structured = fc.structured(opts.clone());
1167 (fc.path.clone(), structured.hunks)
1168 })
1169 .collect();
1170 let unstaged = filter_file_changes(unstaged_changes(repo, rename_threshold)?, repo, pathspecs)?;
1171 let files = file_diffs_from_worktree_changes(unstaged, &config, &thread_policy, opts)?;
1172 Ok((files, peer_hunks))
1173}
1174
1175pub struct HunkDiffBundle {
1177 pub unstaged: Vec<FileDiff>,
1179 pub peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>>,
1181}
1182
1183pub fn hunk_diff_bundle(
1192 repo: &Repository,
1193 pathspecs: &[String],
1194 opts: DiffOptions,
1195) -> Result<HunkDiffBundle> {
1196 let config = repo.config()?;
1197 let thread_policy = repo.thread_policy();
1198 let (unstaged_fc, peer_hunks) =
1199 unstaged_raw_changes_with_peer_hunks(repo, pathspecs, opts.clone())?;
1200 let unstaged = file_diffs_from_worktree_changes(unstaged_fc, &config, &thread_policy, opts)?;
1201 Ok(HunkDiffBundle {
1202 unstaged,
1203 peer_hunks,
1204 })
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210
1211 #[test]
1212 fn include_file_omits_whitespace_only_modified() {
1213 let file = FileDiff {
1214 path: "x.txt".into(),
1215 old_path: None,
1216 status: DiffStatus::Modified,
1217 old_mode: Some("100644".into()),
1218 new_mode: Some("100644".into()),
1219 old_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1220 new_oid: ObjectId::from_hex("0000000000000000000000000000000000000002").unwrap(),
1221 binary: false,
1222 content_changed: true,
1223 old_encoding_lossy: false,
1224 new_encoding_lossy: false,
1225 hunks: Vec::new(),
1226 line_stat: None,
1227 };
1228 let opts =
1229 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1230 assert!(!include_file_in_diff(&file, &opts));
1231 }
1232
1233 #[test]
1234 fn include_file_omits_ws_only_even_when_content_changed_false() {
1235 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
1236 let file = FileDiff {
1237 path: "x.txt".into(),
1238 old_path: None,
1239 status: DiffStatus::Modified,
1240 old_mode: Some("100644".into()),
1241 new_mode: Some("100644".into()),
1242 old_oid: oid,
1243 new_oid: oid,
1244 binary: false,
1245 content_changed: false,
1246 old_encoding_lossy: false,
1247 new_encoding_lossy: false,
1248 hunks: Vec::new(),
1249 line_stat: None,
1250 };
1251 let opts =
1252 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1253 assert!(!include_file_in_diff(&file, &opts));
1254 }
1255
1256 #[test]
1257 fn include_file_keeps_mode_only_under_whitespace() {
1258 let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
1259 let file = FileDiff {
1260 path: "x.txt".into(),
1261 old_path: None,
1262 status: DiffStatus::Modified,
1263 old_mode: Some("100644".into()),
1264 new_mode: Some("100755".into()),
1265 old_oid: oid,
1266 new_oid: oid,
1267 binary: false,
1268 content_changed: false,
1269 old_encoding_lossy: false,
1270 new_encoding_lossy: false,
1271 hunks: Vec::new(),
1272 line_stat: None,
1273 };
1274 let opts =
1275 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1276 assert!(include_file_in_diff(&file, &opts));
1277 }
1278
1279 #[test]
1280 fn include_file_keeps_whitespace_only_rename() {
1281 let file = FileDiff {
1282 path: "new.txt".into(),
1283 old_path: Some("old.txt".into()),
1284 status: DiffStatus::Renamed,
1285 old_mode: Some("100644".into()),
1286 new_mode: Some("100644".into()),
1287 old_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1288 new_oid: ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap(),
1289 binary: false,
1290 content_changed: false,
1291 old_encoding_lossy: false,
1292 new_encoding_lossy: false,
1293 hunks: Vec::new(),
1294 line_stat: None,
1295 };
1296 let opts =
1297 DiffOptions::default().with_whitespace(crate::diff::WhitespaceMode::ignore_all_space());
1298 assert!(include_file_in_diff(&file, &opts));
1299 }
1300
1301 #[test]
1302 fn failed_revision_with_file_paths_keeps_revision_error() {
1303 let dir = tempfile::tempdir().expect("tempdir");
1304 let repo =
1305 Repository::init(dir.path(), &crate::repo::InitOptions::default()).expect("init");
1306 let request = DiffRequest {
1307 positionals: vec!["unknown-revision".into()],
1308 pathspec_file: Some(vec!["selected.txt".into()]),
1309 ..DiffRequest::default()
1310 };
1311 let err = resolve_diff_request(&repo, None, &request).expect_err("unknown revision");
1312 assert!(
1313 matches!(err, ResolveDiffArgumentsError::UnresolvedRevision { spec, .. } if spec == "unknown-revision")
1314 );
1315 }
1316
1317 #[test]
1318 fn resolve_errors_staged_and_unstaged() {
1319 let dir = tempfile::tempdir().expect("tempdir");
1320 let repo = crate::repo::Repository::init(dir.path(), &crate::repo::InitOptions::default())
1321 .expect("init");
1322 let err = resolve_diff_arguments(&repo, None, true, true, None, &[], &[]).unwrap_err();
1323 assert!(matches!(err, ResolveDiffArgumentsError::StagedAndUnstaged));
1324 }
1325}