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