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