Skip to main content

grit_lib/porcelain/
status.rs

1//! `git status` as a structured operation.
2//!
3//! The library computes a [`StatusModel`] — every fact the user-facing output
4//! needs, with **no presentation applied** — and the `grit` binary renders it
5//! into porcelain v1/v2, short, or long format (applying colour, column layout,
6//! path quoting, and the comment prefix). This is the reference example of the
7//! library/CLI split described on [`crate::porcelain`].
8//!
9//! # Status of the extraction
10//!
11//! This module currently defines the data contract ([`StatusOptions`] in,
12//! [`StatusModel`] out). The computation that produces the model is being moved
13//! out of `grit/src/commands/status.rs::run` in stages; once it lands here as
14//! [`status`], the three CLI formatters (`format_porcelain_v2`, `format_short`,
15//! `format_long`) consume a `&StatusModel` instead of a dozen loose arguments.
16//!
17//! The model's shape is taken directly from the inputs those three formatters
18//! share today: HEAD + its tree, the staged (index-vs-HEAD) and unstaged
19//! (index-vs-worktree) diffs, the untracked and ignored path lists, the
20//! in-progress operation [`state`](crate::state::WtStatusState), the loaded and
21//! sparse-expanded index, and the stash count.
22
23use std::collections::BTreeSet;
24use std::fs;
25use std::path::Path;
26
27use crate::diff::DiffEntry;
28use crate::error::Result;
29use crate::ignore::IgnoreMatcher;
30use crate::index::{Index, MODE_GITLINK, MODE_TREE};
31use crate::objects::ObjectId;
32use crate::repo::Repository;
33use crate::state::{HeadState, WtStatusState};
34
35/// How untracked files are reported (`git status --untracked-files=<mode>`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum UntrackedMode {
38    /// `no` — do not list untracked files.
39    No,
40    /// `normal` — list untracked files and directories.
41    Normal,
42    /// `all` — list every individual untracked file, recursing into directories.
43    All,
44}
45
46/// How ignored files are reported (`git status --ignored[=<mode>]`).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum IgnoredMode {
49    /// Do not list ignored files (the default).
50    No,
51    /// `traditional` — list ignored files and directories.
52    Traditional,
53    /// `matching` — list only ignored paths that match an ignore pattern.
54    Matching,
55}
56
57/// Rename/copy detection settings for the status diffs (`status.renames` /
58/// `--find-renames`, `status.renameLimit`, copy detection).
59#[derive(Debug, Clone, Copy)]
60pub struct RenameDetection {
61    /// Rename similarity threshold, as a percentage (e.g. `50` for 50%).
62    pub threshold: u32,
63    /// Whether to also detect copies (`-C` / `--find-copies`).
64    pub copies: bool,
65}
66
67/// Inputs that drive **what** `status` computes.
68///
69/// The CLI translates clap arguments and config (`status.showUntrackedFiles`,
70/// `status.renames`, `status.aheadBehind`, submodule ignore settings, …) into
71/// this plain struct. Presentation choices — short vs. porcelain vs. long,
72/// colour, column layout, path quoting, `-z` — are deliberately absent; they
73/// belong to the renderer, not the computation.
74#[derive(Debug, Clone)]
75pub struct StatusOptions {
76    /// Untracked-file reporting mode.
77    pub untracked: UntrackedMode,
78    /// Ignored-file reporting mode.
79    pub ignored: IgnoredMode,
80    /// Rename/copy detection, or `None` to skip rename detection.
81    pub renames: Option<RenameDetection>,
82    /// Limit the report to paths matching these pathspecs (empty = whole tree).
83    pub pathspecs: Vec<String>,
84    /// Compute ahead/behind counts relative to the upstream branch.
85    pub ahead_behind: bool,
86}
87
88impl Default for StatusOptions {
89    fn default() -> Self {
90        Self {
91            untracked: UntrackedMode::Normal,
92            ignored: IgnoredMode::No,
93            renames: None,
94            pathspecs: Vec::new(),
95            ahead_behind: true,
96        }
97    }
98}
99
100/// The computed result of `git status`: everything the renderers need, with no
101/// presentation applied.
102///
103/// Fields mirror what the CLI's `format_porcelain_v2`, `format_short`, and
104/// `format_long` read today, so a renderer can be a pure function of this model
105/// plus the user's chosen output format.
106#[derive(Debug, Clone)]
107pub struct StatusModel {
108    /// The resolved HEAD (branch, detached, or unborn).
109    pub head: HeadState,
110    /// Tree OID of the HEAD commit, or `None` on an unborn branch.
111    pub head_tree: Option<ObjectId>,
112    /// In-progress operation state (merge, rebase, cherry-pick, bisect, …).
113    pub state: WtStatusState,
114    /// The loaded index, with sparse-directory placeholders expanded — the same
115    /// index the renderers query for per-stage entries.
116    pub index: Index,
117    /// Staged changes: the index-vs-HEAD-tree diff.
118    pub staged: Vec<DiffEntry>,
119    /// Unstaged changes: the index-vs-worktree diff.
120    pub unstaged: Vec<DiffEntry>,
121    /// Untracked paths (subject to [`StatusOptions::untracked`]).
122    pub untracked: Vec<String>,
123    /// Ignored paths (subject to [`StatusOptions::ignored`]).
124    pub ignored: Vec<String>,
125    /// Number of stash entries (for the optional stash footer / `--show-stash`).
126    pub stash_count: usize,
127    /// Whether the on-disk index used the sparse-directory format.
128    pub index_sparse_on_disk: bool,
129    /// Sparse-directory prefixes present in the on-disk index, if any.
130    pub sparse_directory_prefixes: Vec<Vec<u8>>,
131}
132
133// --- Untracked / ignored worktree walk -------------------------------------
134//
135// Moved verbatim out of `grit/src/commands/status.rs` (Phase 4, step 2). This is
136// pure domain logic: given the index + ignore rules, walk the work tree and
137// produce the untracked and ignored path lists. The CLI's fsmonitor query,
138// untracked-cache refresh, and trace2 emission wrap this — those stay in the CLI
139// because they are IPC / env / optimization concerns, not status computation.
140
141/// Walk the work tree and collect untracked and ignored paths.
142///
143/// `ignored_mode` selects whether (and how) ignored paths are reported;
144/// `show_all` corresponds to `--untracked-files=all`. Results are sorted.
145pub fn collect_untracked_and_ignored(
146    repo: &Repository,
147    index: &Index,
148    work_tree: &Path,
149    ignored_mode: IgnoredMode,
150    show_all: bool,
151    pathspecs: &[String],
152) -> Result<(Vec<String>, Vec<String>)> {
153    // Keep parity with historical status behavior in tests that rely on broad untracked scans
154    // (including detached-HEAD wtstatus cases): when no explicit pathspec is requested, avoid
155    // pathspec-based pruning entirely.
156    let effective_pathspecs: &[String] = if pathspecs.is_empty() { &[] } else { pathspecs };
157    let tracked: BTreeSet<String> = index
158        .entries
159        .iter()
160        .map(|ie| String::from_utf8_lossy(&ie.path).to_string())
161        .collect();
162
163    let gitlinks: BTreeSet<String> = index
164        .entries
165        .iter()
166        .filter(|e| e.stage() == 0 && e.mode == MODE_GITLINK)
167        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
168        .collect();
169
170    let mut matcher = IgnoreMatcher::from_repository(repo)?;
171    let mut untracked = Vec::new();
172    let mut ignored = Vec::new();
173
174    visit_untracked_node(
175        repo,
176        index,
177        work_tree,
178        &tracked,
179        &gitlinks,
180        &mut matcher,
181        ignored_mode,
182        show_all,
183        "",
184        work_tree,
185        effective_pathspecs,
186        &mut untracked,
187        &mut ignored,
188    )?;
189
190    untracked.sort();
191    ignored.sort();
192    Ok((untracked, ignored))
193}
194
195#[allow(clippy::too_many_arguments)]
196fn visit_untracked_node(
197    repo: &Repository,
198    index: &Index,
199    work_tree: &Path,
200    tracked: &BTreeSet<String>,
201    gitlinks: &BTreeSet<String>,
202    matcher: &mut IgnoreMatcher,
203    ignored_mode: IgnoredMode,
204    show_all: bool,
205    rel: &str,
206    abs: &Path,
207    pathspecs: &[String],
208    untracked_out: &mut Vec<String>,
209    ignored_out: &mut Vec<String>,
210) -> Result<()> {
211    if !rel.is_empty()
212        && abs.is_dir()
213        && dir_is_nested_submodule_worktree(&repo.git_dir, abs)
214        && has_tracked_under(tracked, gitlinks, rel)
215    {
216        return Ok(());
217    }
218
219    let entries = match fs::read_dir(abs) {
220        Ok(e) => e,
221        Err(_) => return Ok(()),
222    };
223    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
224    sorted.sort_by_key(|e| e.file_name());
225
226    for entry in sorted {
227        let name = entry.file_name().to_string_lossy().to_string();
228        if name == ".git" {
229            continue;
230        }
231        let path = entry.path();
232        let child_rel = relative_path(rel, &name);
233        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
234
235        if is_dir && gitlinks.contains(&child_rel) {
236            continue;
237        }
238
239        if tracked.contains(&child_rel) {
240            continue;
241        }
242
243        if is_dir {
244            if !pathspec_may_match_directory(&child_rel, pathspecs) {
245                continue;
246            }
247            visit_untracked_directory(
248                repo,
249                index,
250                work_tree,
251                tracked,
252                gitlinks,
253                matcher,
254                ignored_mode,
255                show_all,
256                &child_rel,
257                &path,
258                pathspecs,
259                untracked_out,
260                ignored_out,
261            )?;
262        } else {
263            if !status_path_matches_worktree(repo, index, work_tree, &child_rel, pathspecs) {
264                continue;
265            }
266            let (is_ign, _) = matcher.check_path(repo, Some(index), &child_rel, false)?;
267            if is_ign {
268                if ignored_mode != IgnoredMode::No {
269                    ignored_out.push(child_rel);
270                }
271            } else {
272                untracked_out.push(child_rel);
273            }
274        }
275    }
276
277    Ok(())
278}
279
280#[allow(clippy::too_many_arguments)]
281fn visit_untracked_directory(
282    repo: &Repository,
283    index: &Index,
284    work_tree: &Path,
285    tracked: &BTreeSet<String>,
286    gitlinks: &BTreeSet<String>,
287    matcher: &mut IgnoreMatcher,
288    ignored_mode: IgnoredMode,
289    show_all: bool,
290    rel: &str,
291    abs: &Path,
292    pathspecs: &[String],
293    untracked_out: &mut Vec<String>,
294    ignored_out: &mut Vec<String>,
295) -> Result<()> {
296    if has_tracked_under(tracked, gitlinks, rel) {
297        visit_untracked_node(
298            repo,
299            index,
300            work_tree,
301            tracked,
302            gitlinks,
303            matcher,
304            ignored_mode,
305            show_all,
306            rel,
307            abs,
308            pathspecs,
309            untracked_out,
310            ignored_out,
311        )?;
312        return Ok(());
313    }
314
315    // Fast prune: in default ignored mode, a directory excluded as a directory cannot contribute
316    // visible untracked paths (and tracked descendants were handled above).
317    if ignored_mode == IgnoredMode::No && matcher.check_path(repo, Some(index), rel, true)?.0 {
318        return Ok(());
319    }
320
321    // Git `dir.c`: with `--ignored=matching` and full untracked listing, an excluded
322    // directory is reported as a single path without enumerating children (unless
323    // tracked files force a full walk — handled above).
324    if ignored_mode == IgnoredMode::Matching
325        && show_all
326        && matcher.check_path(repo, Some(index), rel, true)?.0
327    {
328        ignored_out.push(format!("{rel}/"));
329        return Ok(());
330    }
331
332    if ignored_mode != IgnoredMode::No
333        && dir_is_nested_submodule_worktree(&repo.git_dir, abs)
334        && matcher.check_path(repo, Some(index), rel, true)?.0
335    {
336        ignored_out.push(format!("{rel}/"));
337        return Ok(());
338    }
339
340    if ignored_mode == IgnoredMode::Traditional
341        && !show_all
342        && directory_pathspec_matches_self(rel, pathspecs)
343    {
344        if let Some(dir_line) = traditional_normal_directory_only(
345            repo, index, work_tree, tracked, gitlinks, matcher, rel, abs, pathspecs,
346        )? {
347            ignored_out.push(dir_line);
348            return Ok(());
349        }
350    }
351
352    let mut sub_untracked = Vec::new();
353    let mut sub_ignored = Vec::new();
354    visit_untracked_node(
355        repo,
356        index,
357        work_tree,
358        tracked,
359        gitlinks,
360        matcher,
361        ignored_mode,
362        true,
363        rel,
364        abs,
365        pathspecs,
366        &mut sub_untracked,
367        &mut sub_ignored,
368    )?;
369
370    if show_all {
371        untracked_out.append(&mut sub_untracked);
372        ignored_out.append(&mut sub_ignored);
373        return Ok(());
374    }
375
376    // `--untracked-files=normal`: collapse subtrees like Git's `walk_for_untracked`.
377    if !sub_untracked.is_empty() && !sub_ignored.is_empty() {
378        if !rel.is_empty() && directory_pathspec_matches_self(rel, pathspecs) {
379            untracked_out.push(format!("{rel}/"));
380        } else {
381            untracked_out.append(&mut sub_untracked);
382        }
383        ignored_out.append(&mut sub_ignored);
384        return Ok(());
385    }
386
387    if sub_untracked.is_empty() && !sub_ignored.is_empty() {
388        let dir_excluded = matcher.check_path(repo, Some(index), rel, true)?.0;
389        let collapse_matching = ignored_mode == IgnoredMode::Matching && dir_excluded;
390        let collapse_traditional = ignored_mode == IgnoredMode::Traditional
391            && directory_pathspec_matches_self(rel, pathspecs);
392        if collapse_matching || collapse_traditional {
393            ignored_out.push(format!("{rel}/"));
394        } else {
395            ignored_out.append(&mut sub_ignored);
396        }
397        return Ok(());
398    }
399
400    if !sub_untracked.is_empty() && sub_ignored.is_empty() {
401        if rel.is_empty() || !directory_pathspec_matches_self(rel, pathspecs) {
402            untracked_out.append(&mut sub_untracked);
403        } else {
404            untracked_out.push(format!("{rel}/"));
405        }
406        return Ok(());
407    }
408
409    // Match Git's normal untracked mode: keep directories that are empty apart from an internal
410    // `.git` as collapsed `dir/` entries, but do not surface directories that only contain
411    // ignored entries (t7063 expects those to stay hidden).
412    if sub_untracked.is_empty()
413        && sub_ignored.is_empty()
414        && !rel.is_empty()
415        && directory_contains_only_dot_git(abs)
416    {
417        untracked_out.push(format!("{rel}/"));
418        return Ok(());
419    }
420
421    Ok(())
422}
423
424fn directory_contains_only_dot_git(dir: &Path) -> bool {
425    let entries: Vec<_> = match fs::read_dir(dir) {
426        Ok(entries) => entries.filter_map(|e| e.ok()).collect(),
427        Err(_) => return false,
428    };
429    !entries.is_empty()
430        && entries
431            .iter()
432            .all(|e| e.file_name().to_string_lossy() == ".git")
433}
434
435/// Full tree scan: true when every file under `abs` is ignored and nothing untracked is present.
436#[allow(clippy::too_many_arguments)]
437fn traditional_normal_directory_only(
438    repo: &Repository,
439    index: &Index,
440    work_tree: &Path,
441    tracked: &BTreeSet<String>,
442    gitlinks: &BTreeSet<String>,
443    matcher: &mut IgnoreMatcher,
444    rel: &str,
445    abs: &Path,
446    pathspecs: &[String],
447) -> Result<Option<String>> {
448    let mut any_file = false;
449    let mut stack = vec![abs.to_path_buf()];
450    while let Some(dir) = stack.pop() {
451        let entries = match fs::read_dir(&dir) {
452            Ok(e) => e,
453            Err(_) => continue,
454        };
455        let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
456        sorted.sort_by_key(|e| e.file_name());
457        for entry in sorted {
458            let name = entry.file_name().to_string_lossy().to_string();
459            if name == ".git" {
460                continue;
461            }
462            let path = entry.path();
463            let rel_child = path
464                .strip_prefix(work_tree)
465                .map(|p| p.to_string_lossy().to_string())
466                .unwrap_or_else(|_| name.clone());
467            if !pathspec_may_match_directory(&rel_child, pathspecs)
468                && !(entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
469                    && status_path_matches_worktree(repo, index, work_tree, &rel_child, pathspecs))
470            {
471                continue;
472            }
473            if tracked.contains(&rel_child) {
474                return Ok(None);
475            }
476            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
477            if is_dir && gitlinks.contains(&rel_child) {
478                continue;
479            }
480            if is_dir {
481                stack.push(path);
482            } else {
483                any_file = true;
484                let (ig, _) = matcher.check_path(repo, Some(index), &rel_child, false)?;
485                if !ig {
486                    return Ok(None);
487                }
488            }
489        }
490    }
491
492    let dir_ignored = matcher.check_path(repo, Some(index), rel, true)?.0;
493    if !any_file {
494        return Ok(if dir_ignored {
495            Some(format!("{rel}/"))
496        } else {
497            None
498        });
499    }
500
501    Ok(Some(format!("{rel}/")))
502}
503
504fn has_tracked_under(
505    tracked: &BTreeSet<String>,
506    gitlinks: &BTreeSet<String>,
507    rel_dir: &str,
508) -> bool {
509    let prefix = if rel_dir.is_empty() {
510        String::new()
511    } else {
512        format!("{rel_dir}/")
513    };
514    tracked
515        .range::<String, _>(prefix.clone()..)
516        .next()
517        .is_some_and(|t| t.starts_with(&prefix))
518        || gitlinks.iter().any(|g| {
519            g.as_str() == rel_dir || (!rel_dir.is_empty() && g.starts_with(&format!("{rel_dir}/")))
520        })
521}
522
523fn relative_path(parent: &str, name: &str) -> String {
524    if parent.is_empty() {
525        name.to_string()
526    } else {
527        format!("{parent}/{name}")
528    }
529}
530
531/// Whether `dir` is the work tree of a nested submodule of the superproject at
532/// `super_git_dir` (its `.git` resolves under `super_git_dir/modules`).
533pub fn dir_is_nested_submodule_worktree(super_git_dir: &Path, dir: &Path) -> bool {
534    let gitfile = dir.join(".git");
535    if gitfile.is_dir() {
536        return true;
537    }
538    let Ok(content) = fs::read_to_string(&gitfile) else {
539        return false;
540    };
541    let Some(rest) = content.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
542        return false;
543    };
544    let raw = rest.trim();
545    if raw.is_empty() {
546        return false;
547    }
548    let gitdir_path = Path::new(raw);
549    let resolved = if gitdir_path.is_absolute() {
550        gitdir_path.to_path_buf()
551    } else {
552        dir.join(gitdir_path)
553    };
554    let Ok(resolved_canon) = resolved.canonicalize() else {
555        return false;
556    };
557    let modules_root = super_git_dir.join("modules");
558    let Ok(modules_canon) = modules_root.canonicalize() else {
559        return false;
560    };
561    resolved_canon.starts_with(&modules_canon)
562}
563
564/// Pathspec match for status using git's exclude / OR-of-positives semantics.
565pub fn status_path_matches(path: &str, pathspecs: &[String]) -> bool {
566    if pathspecs.is_empty() {
567        return true;
568    }
569    let normalized = path.trim_end_matches('/');
570    let excluded = pathspecs.iter().any(|spec| {
571        crate::pathspec::pathspec_exclude_matches(spec, path)
572            || crate::pathspec::pathspec_exclude_matches(spec, normalized)
573    });
574    if excluded {
575        return false;
576    }
577    let mut has_positive = false;
578    let mut positive_match = false;
579    for spec in pathspecs {
580        if crate::pathspec::pathspec_is_exclude(spec) {
581            continue;
582        }
583        has_positive = true;
584        if crate::pathspec::pathspec_matches(spec, path)
585            || crate::pathspec::pathspec_matches(spec, normalized)
586        {
587            positive_match = true;
588        }
589    }
590    !has_positive || positive_match
591}
592
593fn pathspecs_use_attr_magic(pathspecs: &[String]) -> bool {
594    pathspecs
595        .iter()
596        .any(|spec| spec.starts_with(":(attr:") || spec.contains(",attr:"))
597}
598
599/// Pathspec match that also honors `:(attr:...)` magic against worktree contents.
600pub fn status_path_matches_worktree(
601    repo: &Repository,
602    index: &Index,
603    work_tree: &Path,
604    path: &str,
605    pathspecs: &[String],
606) -> bool {
607    if pathspecs.is_empty() {
608        return true;
609    }
610    if !pathspecs_use_attr_magic(pathspecs) {
611        return status_path_matches(path, pathspecs);
612    }
613
614    let normalized = path.trim_end_matches('/');
615    let attrs =
616        crate::crlf::load_gitattributes_for_checkout(work_tree, normalized, index, &repo.odb);
617    let mode = worktree_path_mode(&work_tree.join(normalized));
618    crate::pathspec::matches_pathspec_list_for_object(normalized, mode, &attrs, pathspecs)
619}
620
621fn worktree_path_mode(path: &Path) -> u32 {
622    let Ok(meta) = fs::symlink_metadata(path) else {
623        return 0;
624    };
625    if meta.file_type().is_symlink() {
626        return 0o120000;
627    }
628    if meta.is_dir() {
629        return MODE_TREE;
630    }
631    if is_executable_file(&meta) {
632        0o100755
633    } else {
634        0o100644
635    }
636}
637
638#[cfg(unix)]
639fn is_executable_file(meta: &fs::Metadata) -> bool {
640    use std::os::unix::fs::PermissionsExt;
641    meta.permissions().mode() & 0o111 != 0
642}
643
644#[cfg(not(unix))]
645fn is_executable_file(_meta: &fs::Metadata) -> bool {
646    false
647}
648
649/// Whether `rel_dir` could contain a path matching any of `pathspecs` (directory prune).
650pub fn pathspec_may_match_directory(rel_dir: &str, pathspecs: &[String]) -> bool {
651    if pathspecs.is_empty() {
652        return true;
653    }
654    if pathspecs_use_attr_magic(pathspecs) {
655        return true;
656    }
657    let rel_dir = rel_dir.trim_end_matches('/');
658    if rel_dir.is_empty() {
659        return true;
660    }
661    pathspecs.iter().any(|spec| {
662        if crate::pathspec::has_glob_chars(spec) {
663            return true;
664        }
665        let spec_norm = spec.trim_end_matches('/');
666        spec_norm == rel_dir
667            || spec_norm.starts_with(&format!("{rel_dir}/"))
668            || rel_dir.starts_with(&format!("{spec_norm}/"))
669            || crate::pathspec::pathspec_matches(spec, rel_dir)
670    })
671}
672
673fn directory_pathspec_matches_self(rel_dir: &str, pathspecs: &[String]) -> bool {
674    pathspecs.is_empty()
675        || status_path_matches(&format!("{}/", rel_dir.trim_end_matches('/')), pathspecs)
676}
677
678// --- The status operation ---------------------------------------------------
679
680use crate::progress::ProgressSink;
681
682/// Compute the status of `repo`'s work tree as a [`StatusModel`].
683///
684/// This is the clean library computation: load and sparse-expand the index,
685/// resolve HEAD and the in-progress operation [`state`](crate::state), compute
686/// the staged (index-vs-HEAD) and unstaged (index-vs-worktree) diffs with
687/// optional rename detection, walk the work tree for untracked/ignored paths,
688/// and count stash entries.
689///
690/// The `grit` CLI's performance and diagnostic layers — the fsmonitor query, the
691/// untracked cache, and trace2 emission — are intentionally *not* part of this;
692/// they wrap the call in the binary. A library consumer that just wants the
693/// status of a repository calls this directly.
694pub fn status(
695    repo: &Repository,
696    opts: &StatusOptions,
697    progress: &mut dyn ProgressSink,
698) -> Result<StatusModel> {
699    let work_tree = repo.work_tree.as_deref().ok_or_else(|| {
700        crate::error::Error::Message("this operation must be run in a work tree".into())
701    })?;
702
703    let head = crate::state::resolve_head(&repo.git_dir)?;
704    let state = crate::state::wt_status_get_state(&repo.git_dir, &head, true)?;
705
706    // Load the index, remembering whether it was sparse on disk, then expand
707    // sparse-directory placeholders so the diffs see real entries.
708    let index_path = repo.index_path();
709    let mut index = match Index::load(&index_path) {
710        Ok(i) => i,
711        Err(crate::error::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Index::new(),
712        Err(e) => return Err(e),
713    };
714    let sparse_directory_prefixes: Vec<Vec<u8>> = index
715        .entries
716        .iter()
717        .filter(|e| e.is_sparse_directory_placeholder())
718        .map(|e| e.path.clone())
719        .collect();
720    let index_sparse_on_disk =
721        index.sparse_directories || index.has_sparse_directory_placeholders();
722    let _ = index.expand_sparse_directory_placeholders(&repo.odb);
723
724    let head_tree = match head.oid() {
725        Some(oid) => {
726            let obj = repo.odb.read(oid)?;
727            Some(crate::objects::parse_commit(&obj.data)?.tree)
728        }
729        None => None,
730    };
731
732    progress.start("status", None);
733
734    // Staged: index vs HEAD tree, narrowed to pathspecs before rename detection.
735    let mut staged: Vec<DiffEntry> =
736        crate::diff::diff_index_to_tree(&repo.odb, &index, head_tree.as_ref(), false)?
737            .into_iter()
738            .filter(|e| status_path_matches(e.path(), &opts.pathspecs))
739            .collect();
740
741    // Unstaged: worktree vs index, narrowed before rename detection.
742    let mut unstaged: Vec<DiffEntry> = crate::diff::diff_index_to_worktree_with_options(
743        &repo.odb,
744        &index,
745        work_tree,
746        crate::diff::DiffIndexToWorktreeOptions {
747            ignore_submodule_untracked: opts.untracked == UntrackedMode::No,
748            ..Default::default()
749        },
750    )?
751    .into_iter()
752    .filter(|e| status_path_matches(e.path(), &opts.pathspecs))
753    .collect();
754
755    if let Some(rd) = opts.renames {
756        staged = apply_status_renames(&repo.odb, staged, rd, head_tree.as_ref())?;
757        unstaged = apply_status_renames(&repo.odb, unstaged, rd, head_tree.as_ref())?;
758    }
759
760    let (untracked, ignored) = if opts.untracked == UntrackedMode::No {
761        (Vec::new(), Vec::new())
762    } else {
763        collect_untracked_and_ignored(
764            repo,
765            &index,
766            work_tree,
767            opts.ignored,
768            opts.untracked == UntrackedMode::All,
769            &opts.pathspecs,
770        )?
771    };
772
773    let stash_count = crate::reflog::read_reflog(&repo.git_dir, "refs/stash")
774        .map(|e| e.len())
775        .unwrap_or(0);
776
777    progress.finish();
778
779    Ok(StatusModel {
780        head,
781        head_tree,
782        state,
783        index,
784        staged,
785        unstaged,
786        untracked,
787        ignored,
788        stash_count,
789        index_sparse_on_disk,
790        sparse_directory_prefixes,
791    })
792}
793
794/// Apply status rename (and optionally copy) detection, mirroring git's
795/// candidate-count guards so a huge add/delete set is left undetected.
796fn apply_status_renames(
797    odb: &crate::odb::Odb,
798    entries: Vec<DiffEntry>,
799    rd: RenameDetection,
800    head_tree: Option<&ObjectId>,
801) -> Result<Vec<DiffEntry>> {
802    use crate::diff::DiffStatus;
803    const MATRIX_BUDGET: usize = 50_000;
804    const CANDIDATE_LIMIT: usize = 2_000;
805
806    let mut deleted = 0usize;
807    let mut added = 0usize;
808    for entry in &entries {
809        match entry.status {
810            DiffStatus::Deleted => deleted += 1,
811            DiffStatus::Added => added += 1,
812            _ => {}
813        }
814    }
815    if deleted == 0 || added == 0 {
816        return Ok(entries);
817    }
818    if deleted.saturating_add(added) > CANDIDATE_LIMIT
819        || deleted.saturating_mul(added) > MATRIX_BUDGET
820    {
821        return Ok(entries);
822    }
823    if rd.copies {
824        return crate::diff::status_apply_rename_copy_detection(
825            odb,
826            entries,
827            rd.threshold,
828            true,
829            head_tree,
830        );
831    }
832    Ok(crate::diff::detect_renames(
833        odb,
834        None,
835        entries,
836        rd.threshold,
837    ))
838}
839
840#[cfg(test)]
841mod status_op_tests {
842    use super::*;
843    use crate::progress::NullProgress;
844    use std::fs;
845    use tempfile::TempDir;
846
847    fn init_min_repo(root: &std::path::Path) {
848        let git = root.join(".git");
849        fs::create_dir_all(git.join("objects")).unwrap();
850        fs::create_dir_all(git.join("refs/heads")).unwrap();
851        fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
852        fs::write(
853            git.join("config"),
854            "[core]\n\trepositoryformatversion = 0\n\tbare = false\n",
855        )
856        .unwrap();
857    }
858
859    #[test]
860    fn status_detects_untracked_file_on_unborn_branch() {
861        let tmp = TempDir::new().unwrap();
862        let root = tmp.path();
863        init_min_repo(root);
864        fs::write(root.join("foo.txt"), b"hello\n").unwrap();
865
866        let repo = Repository::open(&root.join(".git"), Some(root)).unwrap();
867        let model = status(&repo, &StatusOptions::default(), &mut NullProgress).unwrap();
868
869        assert!(model.head_tree.is_none(), "unborn HEAD has no tree");
870        assert!(
871            model.staged.is_empty(),
872            "nothing staged: {:?}",
873            model.staged
874        );
875        assert!(
876            model.unstaged.is_empty(),
877            "nothing unstaged: {:?}",
878            model.unstaged
879        );
880        assert!(
881            model.untracked.iter().any(|p| p == "foo.txt"),
882            "foo.txt should be untracked, got {:?}",
883            model.untracked
884        );
885    }
886
887    #[test]
888    fn status_untracked_mode_no_skips_walk() {
889        let tmp = TempDir::new().unwrap();
890        let root = tmp.path();
891        init_min_repo(root);
892        fs::write(root.join("foo.txt"), b"hi\n").unwrap();
893
894        let repo = Repository::open(&root.join(".git"), Some(root)).unwrap();
895        let opts = StatusOptions {
896            untracked: UntrackedMode::No,
897            ..StatusOptions::default()
898        };
899        let model = status(&repo, &opts, &mut NullProgress).unwrap();
900        assert!(
901            model.untracked.is_empty(),
902            "untracked=No must report nothing, got {:?}",
903            model.untracked
904        );
905    }
906}