Skip to main content

grit_lib/diff/
targets.rs

1//! Diff target selection and [`FileDiff`] assembly for porcelain and the CLI.
2
3use 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/// What to compare when producing a structured file diff list.
43#[non_exhaustive]
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum DiffTarget {
46    /// Working tree and index together vs `HEAD`'s tree (default uncommitted diff).
47    WorktreeVsHead,
48    /// Working tree (and index) vs a specific tree OID.
49    WorktreeVsTree(ObjectId),
50    /// Index vs `HEAD`'s tree (`--staged`).
51    IndexVsHead,
52    /// Working tree vs index (`--unstaged`).
53    WorktreeVsIndex,
54    /// Patch introduced by one commit (parent tree vs commit tree).
55    Commit(ObjectId),
56    /// Tree diff between two commits (`old` may be absent for root commits).
57    Trees {
58        /// Older commit (left side of the range).
59        old: Option<ObjectId>,
60        /// Newer commit (right side).
61        new: ObjectId,
62    },
63}
64
65/// One changed file with structured hunks and metadata.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct FileDiff {
68    /// Display path (new path, or old path for a deletion).
69    pub path: String,
70    /// Pre-rename path when it differs from [`Self::path`].
71    pub old_path: Option<String>,
72    /// Change kind for this path.
73    pub status: DiffStatus,
74    /// Old git mode (octal), when present on this side.
75    pub old_mode: Option<String>,
76    /// New git mode (octal), when present on this side.
77    pub new_mode: Option<String>,
78    /// Old blob OID on this side (zero when absent).
79    pub old_oid: ObjectId,
80    /// New blob OID on this side (zero when absent).
81    pub new_oid: ObjectId,
82    /// True when either side was treated as binary (NUL in the first 8 KiB).
83    pub binary: bool,
84    /// True when blob content changed (not a mode-only touch on the same OID).
85    pub content_changed: bool,
86    /// Old side was decoded with lossy UTF-8.
87    pub old_encoding_lossy: bool,
88    /// New side was decoded with lossy UTF-8.
89    pub new_encoding_lossy: bool,
90    /// Structured line hunks for text files.
91    pub hunks: Vec<StructuredHunk>,
92    /// Precomputed line counts when stat was requested without building hunks.
93    pub line_stat: Option<LineStat>,
94}
95
96/// Resolved diff mode and normalized pathspecs.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct DiffArguments {
99    /// What to diff.
100    pub target: DiffTarget,
101    /// Repository-relative pathspecs (empty = all paths).
102    pub pathspecs: Vec<String>,
103}
104
105/// Unparsed `grit diff` flags and operands, as the user typed them.
106///
107/// [`resolve_diff_request`] turns this into a [`DiffArguments`].
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
109pub struct DiffRequest {
110    /// Compare the index with `HEAD` (`--staged`).
111    pub staged: bool,
112    /// Compare the working tree with the index (`--unstaged`).
113    pub unstaged: bool,
114    /// Compare the working tree with this revision instead of `HEAD` (`--against`).
115    pub against: Option<String>,
116    /// Operands before `--`: revisions, ranges, or paths given without `--`.
117    pub positionals: Vec<String>,
118    /// Paths after `--`.
119    pub paths: Vec<String>,
120    /// Pathspecs read from a file (`--pathspec-from-file`), placed before the path operands.
121    ///
122    /// `Some` means a file was supplied, even when it held no entries: the combined pathspec
123    /// list must then be non-empty.
124    pub pathspec_file: Option<Vec<String>>,
125}
126
127/// Invalid `grit diff` argument combinations or revision parsing failures.
128#[non_exhaustive]
129#[derive(Debug, Error)]
130pub enum ResolveDiffArgumentsError {
131    /// `--staged` and `--unstaged` together.
132    #[error("--staged and --unstaged cannot be combined")]
133    StagedAndUnstaged,
134
135    /// `--staged` with commit arguments or `--against`.
136    #[error("--staged cannot be combined with revision arguments or --against")]
137    StagedWithRevisions,
138
139    /// `--unstaged` with commit arguments or `--against`.
140    #[error("--unstaged cannot be combined with revision arguments or --against")]
141    UnstagedWithRevisions,
142
143    /// More than two commit-ish positionals (excluding range syntax in one token).
144    #[error("at most two commits, or one two-dot range such as A..B")]
145    TooManyRevisions,
146
147    /// `--against` with commit positionals.
148    #[error("--against compares the worktree to a ref and takes no commit arguments")]
149    AgainstWithCommits,
150
151    /// Second positional after a range token should be passed after `--`.
152    #[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    /// A working-tree comparison, or path limits for one, in a repository without a work tree.
158    #[error("grit diff needs a working tree")]
159    NoWorkTree,
160
161    /// A revision operand does not name an object.
162    #[error("could not resolve '{spec}'")]
163    UnresolvedRevision {
164        /// The operand as given.
165        spec: String,
166        /// Why resolution failed.
167        #[source]
168        source: Box<Error>,
169    },
170
171    /// A revision (after peeling tags) names a tree or blob instead of a commit.
172    #[error("object {oid} does not name a commit")]
173    NotACommit {
174        /// The tree or blob the revision led to.
175        oid: ObjectId,
176    },
177
178    /// A three-dot range whose sides share no history.
179    #[error("no merge base for that revision range")]
180    NoMergeBase,
181
182    /// A three-dot range whose sides have more than one merge base (criss-cross history).
183    #[error("multiple merge bases for that revision range")]
184    MultipleMergeBases,
185
186    /// A pathspec could not be parsed or normalized.
187    #[error(transparent)]
188    Pathspec(#[from] PathspecResolveError),
189
190    /// Range endpoint resolution, object reads, or a working-tree pathspec matching nothing
191    /// ([`Error::PathspecNoMatch`]).
192    #[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
204/// Parse `grit diff` flags and positionals into a [`DiffTarget`] and pathspecs.
205///
206/// Shorthand for [`resolve_diff_request`] without a pathspec file.
207///
208/// # Parameters
209///
210/// - `repo` - repository the revisions and paths belong to.
211/// - `cwd_prefix` - path from the work tree root to the process cwd (from
212///   [`pathdiff`](crate::worktree::pathspec::pathdiff)); `None` at the root, when unknown, or in
213///   a bare repository.
214/// - `staged`, `unstaged`, `against` - see [`DiffRequest`].
215/// - `positionals` - operands before `--`.
216/// - `explicit_paths` - paths after `--`.
217///
218/// # Errors
219///
220/// Returns [`ResolveDiffArgumentsError`] for flag conflicts, unresolvable revisions, or
221/// pathspecs that cannot be resolved.
222pub 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
242/// Decide what `grit diff` compares and which paths it is limited to.
243///
244/// Operands before `--` that are not revisions but name known paths (in the index, `HEAD`, or
245/// the working tree) are treated as paths, so `grit diff file.txt` and `grit diff HEAD~1
246/// file.txt` work without `--`. One operand selects a commit's own patch, or a tree diff for
247/// `A..B` / `A...B` (merge base of `A` and `B` against `B`); two operands compare two commits.
248/// With no operands the working tree is compared with `HEAD`, or with
249/// [`DiffRequest::against`].
250///
251/// Working-tree pathspecs must match a known path; commit and range pathspecs only need to
252/// parse, so paths deleted on either side can still be named.
253/// An unresolved operand falls back to a working-tree path only when no path operands or
254/// pathspec-file entries were supplied; otherwise its revision error is preserved.
255///
256/// # Parameters
257///
258/// - `repo` - repository the revisions and paths belong to.
259/// - `cwd_prefix` - path from the work tree root to the process cwd (from
260///   [`pathdiff`](crate::worktree::pathspec::pathdiff)); `None` at the root, when unknown, or in
261///   a bare repository.
262/// - `request` - flags and operands as given.
263///
264/// # Errors
265///
266/// - Flag conflicts: [`ResolveDiffArgumentsError::StagedAndUnstaged`],
267///   [`StagedWithRevisions`](ResolveDiffArgumentsError::StagedWithRevisions),
268///   [`UnstagedWithRevisions`](ResolveDiffArgumentsError::UnstagedWithRevisions),
269///   [`TooManyRevisions`](ResolveDiffArgumentsError::TooManyRevisions),
270///   [`AgainstWithCommits`](ResolveDiffArgumentsError::AgainstWithCommits),
271///   [`RangeWithSecondRevision`](ResolveDiffArgumentsError::RangeWithSecondRevision).
272/// - Revisions: [`UnresolvedRevision`](ResolveDiffArgumentsError::UnresolvedRevision),
273///   [`NotACommit`](ResolveDiffArgumentsError::NotACommit),
274///   [`NoMergeBase`](ResolveDiffArgumentsError::NoMergeBase),
275///   [`MultipleMergeBases`](ResolveDiffArgumentsError::MultipleMergeBases).
276/// - Working tree: [`NoWorkTree`](ResolveDiffArgumentsError::NoWorkTree) when the target reads
277///   the working tree in a bare repository.
278/// - Paths: [`Pathspec`](ResolveDiffArgumentsError::Pathspec), or
279///   [`Library`](ResolveDiffArgumentsError::Library) wrapping [`Error::PathspecNoMatch`].
280pub 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                // Neither a revision nor (per disambiguation) a known path: report it as a path.
373                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    // Git uses similarity 50 by default; when renames are off, skip detection (101 = never).
432    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
599/// Move operands that clap placed before `--` but that name paths rather than revisions.
600fn 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
638/// Working-tree diffs: every pathspec must match a path in the index, `HEAD`, or on disk.
639fn 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
660/// Normalize pathspecs for a commit or range diff to repository-relative form.
661///
662/// Unlike working-tree diffs, the paths need not exist in the current checkout (a path deleted
663/// on the tip, or exclude magic, is fine), and a bare repository resolves them against the git
664/// directory.
665///
666/// # Parameters
667///
668/// - `repo` - repository the paths belong to.
669/// - `cwd_prefix` - path from the work tree root to the process cwd; `None` at the root, when
670///   unknown, or in a bare repository.
671/// - `raw_paths` - pathspecs as given.
672///
673/// # Errors
674///
675/// Returns [`ResolveDiffArgumentsError::Pathspec`] when a pathspec cannot be parsed or lies
676/// outside the work tree.
677pub 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
950/// Resolve a revision operand to a commit, peeling annotated tags.
951fn 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
961/// Resolve one side of `A..B` / `A...B`; an empty side means `HEAD`.
962fn 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
967/// `(old, new)` commits for a two-dot or three-dot range, or `None` when `spec` is not a range.
968fn 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
1006/// Unstaged file diffs with staged peer hunks per path (for stable hunk ids).
1007pub(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
1017/// Raw unstaged [`FileChange`] rows plus staged peer hunks (no structured diff built yet).
1018pub(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
1027/// Like [`unstaged_raw_changes_with_peer_hunks`], diffing against the caller's already loaded
1028/// `index` (which stays the repository's on-disk index; the diff may update in-memory stat
1029/// bookkeeping on it).
1030pub(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
1110/// Staged hunks per path for peer-aware hunk ids. `head_tree` must be `HEAD`'s tree whenever
1111/// `literal_paths` is set (it is only consulted then).
1112fn 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
1149/// Unstaged [`FileDiff`] rows plus staged hunks on the same path (for stable hunk ids).
1150pub(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
1175/// Staged and unstaged file diffs for hunk id assignment (one staged scan per call).
1176pub struct HunkDiffBundle {
1177    /// Unstaged side file rows.
1178    pub unstaged: Vec<FileDiff>,
1179    /// Staged hunks on the same path (for peer-aware ids).
1180    pub peer_hunks: std::collections::HashMap<String, Vec<StructuredHunk>>,
1181}
1182
1183/// Load unstaged diffs and the staged peer hunks on the same paths.
1184///
1185/// Literal file pathspecs only scan those paths (and skip the staged side when it matches
1186/// `HEAD`); any other pathspec scans the whole index and filters.
1187///
1188/// # Errors
1189///
1190/// Propagates index, object database, and working tree read errors.
1191pub 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}