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