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