1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum UntrackedMode {
38 No,
40 Normal,
42 All,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum IgnoredMode {
49 No,
51 Traditional,
53 Matching,
55}
56
57#[derive(Debug, Clone, Copy)]
60pub struct RenameDetection {
61 pub threshold: u32,
63 pub copies: bool,
65}
66
67#[derive(Debug, Clone)]
75pub struct StatusOptions {
76 pub untracked: UntrackedMode,
78 pub ignored: IgnoredMode,
80 pub renames: Option<RenameDetection>,
82 pub pathspecs: Vec<String>,
84 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#[derive(Debug, Clone)]
107pub struct StatusModel {
108 pub head: HeadState,
110 pub head_tree: Option<ObjectId>,
112 pub state: WtStatusState,
114 pub index: Index,
117 pub staged: Vec<DiffEntry>,
119 pub unstaged: Vec<DiffEntry>,
121 pub untracked: Vec<String>,
123 pub ignored: Vec<String>,
125 pub stash_count: usize,
127 pub index_sparse_on_disk: bool,
129 pub sparse_directory_prefixes: Vec<Vec<u8>>,
131}
132
133pub 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 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 if ignored_mode == IgnoredMode::No && matcher.check_path(repo, Some(index), rel, true)?.0 {
318 return Ok(());
319 }
320
321 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 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 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#[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
531pub 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
564pub 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
599pub 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
649pub 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
678use crate::progress::ProgressSink;
681
682pub 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 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 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 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
794fn 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}