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