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