1use collections::HashMap;
2use git::status::GitSummary;
3use std::{ops::Deref, path::Path};
4use sum_tree::Cursor;
5use text::Bias;
6use worktree::{Entry, PathProgress, PathTarget, Traversal};
7
8use super::{RepositoryId, RepositorySnapshot, StatusEntry};
9
10/// Walks the worktree entries and their associated git statuses.
11pub struct GitTraversal<'a> {
12 traversal: Traversal<'a>,
13 current_entry_summary: Option<GitSummary>,
14 repo_snapshots: &'a HashMap<RepositoryId, RepositorySnapshot>,
15 repo_location: Option<(RepositoryId, Cursor<'a, StatusEntry, PathProgress<'a>>)>,
16}
17
18impl<'a> GitTraversal<'a> {
19 pub fn new(
20 repo_snapshots: &'a HashMap<RepositoryId, RepositorySnapshot>,
21 traversal: Traversal<'a>,
22 ) -> GitTraversal<'a> {
23 let mut this = GitTraversal {
24 traversal,
25 repo_snapshots,
26 current_entry_summary: None,
27 repo_location: None,
28 };
29 this.synchronize_statuses(true);
30 this
31 }
32
33 fn synchronize_statuses(&mut self, reset: bool) {
34 self.current_entry_summary = None;
35
36 let Some(entry) = self.entry() else {
37 return;
38 };
39
40 let Ok(abs_path) = self.traversal.snapshot().absolutize(&entry.path) else {
41 self.repo_location = None;
42 return;
43 };
44
45 let Some((repo, repo_path)) = self
46 .repo_snapshots
47 .values()
48 .filter_map(|repo_snapshot| {
49 let repo_path = repo_snapshot.abs_path_to_repo_path(&abs_path)?;
50 Some((repo_snapshot, repo_path))
51 })
52 .max_by_key(|(repo, _)| repo.work_directory_abs_path.clone())
53 else {
54 self.repo_location = None;
55 return;
56 };
57
58 // Update our state if we changed repositories.
59 if reset
60 || self
61 .repo_location
62 .as_ref()
63 .map(|(prev_repo_id, _)| *prev_repo_id)
64 != Some(repo.id)
65 {
66 self.repo_location = Some((repo.id, repo.statuses_by_path.cursor::<PathProgress>(&())));
67 }
68
69 let Some((_, statuses)) = &mut self.repo_location else {
70 return;
71 };
72
73 if entry.is_dir() {
74 let mut statuses = statuses.clone();
75 statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &());
76 let summary =
77 statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left, &());
78
79 self.current_entry_summary = Some(summary);
80 } else if entry.is_file() {
81 // For a file entry, park the cursor on the corresponding status
82 if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) {
83 // TODO: Investigate statuses.item() being None here.
84 self.current_entry_summary = statuses.item().map(|item| item.status.into());
85 } else {
86 self.current_entry_summary = Some(GitSummary::UNCHANGED);
87 }
88 }
89 }
90
91 pub fn advance(&mut self) -> bool {
92 self.advance_by(1)
93 }
94
95 pub fn advance_by(&mut self, count: usize) -> bool {
96 let found = self.traversal.advance_by(count);
97 self.synchronize_statuses(false);
98 found
99 }
100
101 pub fn advance_to_sibling(&mut self) -> bool {
102 let found = self.traversal.advance_to_sibling();
103 self.synchronize_statuses(false);
104 found
105 }
106
107 pub fn back_to_parent(&mut self) -> bool {
108 let found = self.traversal.back_to_parent();
109 self.synchronize_statuses(true);
110 found
111 }
112
113 pub fn start_offset(&self) -> usize {
114 self.traversal.start_offset()
115 }
116
117 pub fn end_offset(&self) -> usize {
118 self.traversal.end_offset()
119 }
120
121 pub fn entry(&self) -> Option<GitEntryRef<'a>> {
122 let entry = self.traversal.entry()?;
123 let git_summary = self.current_entry_summary.unwrap_or(GitSummary::UNCHANGED);
124 Some(GitEntryRef { entry, git_summary })
125 }
126}
127
128impl<'a> Iterator for GitTraversal<'a> {
129 type Item = GitEntryRef<'a>;
130
131 fn next(&mut self) -> Option<Self::Item> {
132 if let Some(item) = self.entry() {
133 self.advance();
134 Some(item)
135 } else {
136 None
137 }
138 }
139}
140
141pub struct ChildEntriesGitIter<'a> {
142 parent_path: &'a Path,
143 traversal: GitTraversal<'a>,
144}
145
146impl<'a> ChildEntriesGitIter<'a> {
147 pub fn new(
148 repo_snapshots: &'a HashMap<RepositoryId, RepositorySnapshot>,
149 worktree_snapshot: &'a worktree::Snapshot,
150 parent_path: &'a Path,
151 ) -> Self {
152 let mut traversal = GitTraversal::new(
153 repo_snapshots,
154 worktree_snapshot.traverse_from_path(true, true, true, parent_path),
155 );
156 traversal.advance();
157 ChildEntriesGitIter {
158 parent_path,
159 traversal,
160 }
161 }
162}
163
164impl<'a> Iterator for ChildEntriesGitIter<'a> {
165 type Item = GitEntryRef<'a>;
166
167 fn next(&mut self) -> Option<Self::Item> {
168 if let Some(item) = self.traversal.entry() {
169 if item.path.starts_with(self.parent_path) {
170 self.traversal.advance_to_sibling();
171 return Some(item);
172 }
173 }
174 None
175 }
176}
177
178#[derive(Debug, Clone, Copy)]
179pub struct GitEntryRef<'a> {
180 pub entry: &'a Entry,
181 pub git_summary: GitSummary,
182}
183
184impl GitEntryRef<'_> {
185 pub fn to_owned(&self) -> GitEntry {
186 GitEntry {
187 entry: self.entry.clone(),
188 git_summary: self.git_summary,
189 }
190 }
191}
192
193impl Deref for GitEntryRef<'_> {
194 type Target = Entry;
195
196 fn deref(&self) -> &Self::Target {
197 &self.entry
198 }
199}
200
201impl AsRef<Entry> for GitEntryRef<'_> {
202 fn as_ref(&self) -> &Entry {
203 self.entry
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct GitEntry {
209 pub entry: Entry,
210 pub git_summary: GitSummary,
211}
212
213impl GitEntry {
214 pub fn to_ref(&self) -> GitEntryRef {
215 GitEntryRef {
216 entry: &self.entry,
217 git_summary: self.git_summary,
218 }
219 }
220}
221
222impl Deref for GitEntry {
223 type Target = Entry;
224
225 fn deref(&self) -> &Self::Target {
226 &self.entry
227 }
228}
229
230impl AsRef<Entry> for GitEntry {
231 fn as_ref(&self) -> &Entry {
232 &self.entry
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use std::time::Duration;
239
240 use crate::Project;
241
242 use super::*;
243 use fs::FakeFs;
244 use git::status::{FileStatus, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode};
245 use gpui::TestAppContext;
246 use serde_json::json;
247 use settings::SettingsStore;
248 use util::path;
249
250 const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
251 first_head: UnmergedStatusCode::Updated,
252 second_head: UnmergedStatusCode::Updated,
253 });
254 const ADDED: GitSummary = GitSummary {
255 index: TrackedSummary::ADDED,
256 count: 1,
257 ..GitSummary::UNCHANGED
258 };
259 const MODIFIED: GitSummary = GitSummary {
260 index: TrackedSummary::MODIFIED,
261 count: 1,
262 ..GitSummary::UNCHANGED
263 };
264
265 #[gpui::test]
266 async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
267 init_test(cx);
268 let fs = FakeFs::new(cx.background_executor.clone());
269 fs.insert_tree(
270 path!("/root"),
271 json!({
272 "x": {
273 ".git": {},
274 "x1.txt": "foo",
275 "x2.txt": "bar",
276 "y": {
277 ".git": {},
278 "y1.txt": "baz",
279 "y2.txt": "qux"
280 },
281 "z.txt": "sneaky..."
282 },
283 "z": {
284 ".git": {},
285 "z1.txt": "quux",
286 "z2.txt": "quuux"
287 }
288 }),
289 )
290 .await;
291
292 fs.set_status_for_repo(
293 Path::new(path!("/root/x/.git")),
294 &[
295 (Path::new("x2.txt"), StatusCode::Modified.index()),
296 (Path::new("z.txt"), StatusCode::Added.index()),
297 ],
298 );
299 fs.set_status_for_repo(
300 Path::new(path!("/root/x/y/.git")),
301 &[(Path::new("y1.txt"), CONFLICT)],
302 );
303 fs.set_status_for_repo(
304 Path::new(path!("/root/z/.git")),
305 &[(Path::new("z2.txt"), StatusCode::Added.index())],
306 );
307
308 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
309 cx.executor().run_until_parked();
310
311 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
312 (
313 project.git_store().read(cx).repo_snapshots(cx),
314 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
315 )
316 });
317
318 let traversal = GitTraversal::new(
319 &repo_snapshots,
320 worktree_snapshot.traverse_from_path(true, false, true, Path::new("x")),
321 );
322 let entries = traversal
323 .map(|entry| (entry.path.clone(), entry.git_summary))
324 .collect::<Vec<_>>();
325 pretty_assertions::assert_eq!(
326 entries,
327 [
328 (Path::new("x/x1.txt").into(), GitSummary::UNCHANGED),
329 (Path::new("x/x2.txt").into(), MODIFIED),
330 (Path::new("x/y/y1.txt").into(), GitSummary::CONFLICT),
331 (Path::new("x/y/y2.txt").into(), GitSummary::UNCHANGED),
332 (Path::new("x/z.txt").into(), ADDED),
333 (Path::new("z/z1.txt").into(), GitSummary::UNCHANGED),
334 (Path::new("z/z2.txt").into(), ADDED),
335 ]
336 )
337 }
338
339 #[gpui::test]
340 async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
341 init_test(cx);
342 let fs = FakeFs::new(cx.background_executor.clone());
343 fs.insert_tree(
344 path!("/root"),
345 json!({
346 "x": {
347 ".git": {},
348 "x1.txt": "foo",
349 "x2.txt": "bar",
350 "y": {
351 ".git": {},
352 "y1.txt": "baz",
353 "y2.txt": "qux"
354 },
355 "z.txt": "sneaky..."
356 },
357 "z": {
358 ".git": {},
359 "z1.txt": "quux",
360 "z2.txt": "quuux"
361 }
362 }),
363 )
364 .await;
365
366 fs.set_status_for_repo(
367 Path::new(path!("/root/x/.git")),
368 &[
369 (Path::new("x2.txt"), StatusCode::Modified.index()),
370 (Path::new("z.txt"), StatusCode::Added.index()),
371 ],
372 );
373 fs.set_status_for_repo(
374 Path::new(path!("/root/x/y/.git")),
375 &[(Path::new("y1.txt"), CONFLICT)],
376 );
377
378 fs.set_status_for_repo(
379 Path::new(path!("/root/z/.git")),
380 &[(Path::new("z2.txt"), StatusCode::Added.index())],
381 );
382
383 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
384 cx.executor().run_until_parked();
385
386 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
387 (
388 project.git_store().read(cx).repo_snapshots(cx),
389 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
390 )
391 });
392
393 // Sanity check the propagation for x/y and z
394 check_git_statuses(
395 &repo_snapshots,
396 &worktree_snapshot,
397 &[
398 (Path::new("x/y"), GitSummary::CONFLICT),
399 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
400 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
401 ],
402 );
403 check_git_statuses(
404 &repo_snapshots,
405 &worktree_snapshot,
406 &[
407 (Path::new("z"), ADDED),
408 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
409 (Path::new("z/z2.txt"), ADDED),
410 ],
411 );
412
413 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
414 check_git_statuses(
415 &repo_snapshots,
416 &worktree_snapshot,
417 &[
418 (Path::new("x"), MODIFIED + ADDED),
419 (Path::new("x/y"), GitSummary::CONFLICT),
420 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
421 ],
422 );
423
424 // Sanity check everything around it
425 check_git_statuses(
426 &repo_snapshots,
427 &worktree_snapshot,
428 &[
429 (Path::new("x"), MODIFIED + ADDED),
430 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
431 (Path::new("x/x2.txt"), MODIFIED),
432 (Path::new("x/y"), GitSummary::CONFLICT),
433 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
434 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
435 (Path::new("x/z.txt"), ADDED),
436 ],
437 );
438
439 // Test the other fundamental case, transitioning from git repository to non-git repository
440 check_git_statuses(
441 &repo_snapshots,
442 &worktree_snapshot,
443 &[
444 (Path::new(""), GitSummary::UNCHANGED),
445 (Path::new("x"), MODIFIED + ADDED),
446 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
447 ],
448 );
449
450 // And all together now
451 check_git_statuses(
452 &repo_snapshots,
453 &worktree_snapshot,
454 &[
455 (Path::new(""), GitSummary::UNCHANGED),
456 (Path::new("x"), MODIFIED + ADDED),
457 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
458 (Path::new("x/x2.txt"), MODIFIED),
459 (Path::new("x/y"), GitSummary::CONFLICT),
460 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
461 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
462 (Path::new("x/z.txt"), ADDED),
463 (Path::new("z"), ADDED),
464 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
465 (Path::new("z/z2.txt"), ADDED),
466 ],
467 );
468 }
469
470 #[gpui::test]
471 async fn test_git_traversal_simple(cx: &mut TestAppContext) {
472 init_test(cx);
473 let fs = FakeFs::new(cx.background_executor.clone());
474 fs.insert_tree(
475 path!("/root"),
476 json!({
477 ".git": {},
478 "a": {
479 "b": {
480 "c1.txt": "",
481 "c2.txt": "",
482 },
483 "d": {
484 "e1.txt": "",
485 "e2.txt": "",
486 "e3.txt": "",
487 }
488 },
489 "f": {
490 "no-status.txt": ""
491 },
492 "g": {
493 "h1.txt": "",
494 "h2.txt": ""
495 },
496 }),
497 )
498 .await;
499
500 fs.set_status_for_repo(
501 Path::new(path!("/root/.git")),
502 &[
503 (Path::new("a/b/c1.txt"), StatusCode::Added.index()),
504 (Path::new("a/d/e2.txt"), StatusCode::Modified.index()),
505 (Path::new("g/h2.txt"), CONFLICT),
506 ],
507 );
508
509 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
510 cx.executor().run_until_parked();
511
512 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
513 (
514 project.git_store().read(cx).repo_snapshots(cx),
515 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
516 )
517 });
518
519 check_git_statuses(
520 &repo_snapshots,
521 &worktree_snapshot,
522 &[
523 (Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED),
524 (Path::new("g"), GitSummary::CONFLICT),
525 (Path::new("g/h2.txt"), GitSummary::CONFLICT),
526 ],
527 );
528
529 check_git_statuses(
530 &repo_snapshots,
531 &worktree_snapshot,
532 &[
533 (Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED),
534 (Path::new("a"), ADDED + MODIFIED),
535 (Path::new("a/b"), ADDED),
536 (Path::new("a/b/c1.txt"), ADDED),
537 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
538 (Path::new("a/d"), MODIFIED),
539 (Path::new("a/d/e2.txt"), MODIFIED),
540 (Path::new("f"), GitSummary::UNCHANGED),
541 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
542 (Path::new("g"), GitSummary::CONFLICT),
543 (Path::new("g/h2.txt"), GitSummary::CONFLICT),
544 ],
545 );
546
547 check_git_statuses(
548 &repo_snapshots,
549 &worktree_snapshot,
550 &[
551 (Path::new("a/b"), ADDED),
552 (Path::new("a/b/c1.txt"), ADDED),
553 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
554 (Path::new("a/d"), MODIFIED),
555 (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
556 (Path::new("a/d/e2.txt"), MODIFIED),
557 (Path::new("f"), GitSummary::UNCHANGED),
558 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
559 (Path::new("g"), GitSummary::CONFLICT),
560 ],
561 );
562
563 check_git_statuses(
564 &repo_snapshots,
565 &worktree_snapshot,
566 &[
567 (Path::new("a/b/c1.txt"), ADDED),
568 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
569 (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
570 (Path::new("a/d/e2.txt"), MODIFIED),
571 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
572 ],
573 );
574 }
575
576 #[gpui::test]
577 async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
578 init_test(cx);
579 let fs = FakeFs::new(cx.background_executor.clone());
580 fs.insert_tree(
581 path!("/root"),
582 json!({
583 "x": {
584 ".git": {},
585 "x1.txt": "foo",
586 "x2.txt": "bar"
587 },
588 "y": {
589 ".git": {},
590 "y1.txt": "baz",
591 "y2.txt": "qux"
592 },
593 "z": {
594 ".git": {},
595 "z1.txt": "quux",
596 "z2.txt": "quuux"
597 }
598 }),
599 )
600 .await;
601
602 fs.set_status_for_repo(
603 Path::new(path!("/root/x/.git")),
604 &[(Path::new("x1.txt"), StatusCode::Added.index())],
605 );
606 fs.set_status_for_repo(
607 Path::new(path!("/root/y/.git")),
608 &[
609 (Path::new("y1.txt"), CONFLICT),
610 (Path::new("y2.txt"), StatusCode::Modified.index()),
611 ],
612 );
613 fs.set_status_for_repo(
614 Path::new(path!("/root/z/.git")),
615 &[(Path::new("z2.txt"), StatusCode::Modified.index())],
616 );
617
618 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
619 cx.executor().run_until_parked();
620
621 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
622 (
623 project.git_store().read(cx).repo_snapshots(cx),
624 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
625 )
626 });
627
628 check_git_statuses(
629 &repo_snapshots,
630 &worktree_snapshot,
631 &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
632 );
633
634 check_git_statuses(
635 &repo_snapshots,
636 &worktree_snapshot,
637 &[
638 (Path::new("y"), GitSummary::CONFLICT + MODIFIED),
639 (Path::new("y/y1.txt"), GitSummary::CONFLICT),
640 (Path::new("y/y2.txt"), MODIFIED),
641 ],
642 );
643
644 check_git_statuses(
645 &repo_snapshots,
646 &worktree_snapshot,
647 &[
648 (Path::new("z"), MODIFIED),
649 (Path::new("z/z2.txt"), MODIFIED),
650 ],
651 );
652
653 check_git_statuses(
654 &repo_snapshots,
655 &worktree_snapshot,
656 &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
657 );
658
659 check_git_statuses(
660 &repo_snapshots,
661 &worktree_snapshot,
662 &[
663 (Path::new("x"), ADDED),
664 (Path::new("x/x1.txt"), ADDED),
665 (Path::new("x/x2.txt"), GitSummary::UNCHANGED),
666 (Path::new("y"), GitSummary::CONFLICT + MODIFIED),
667 (Path::new("y/y1.txt"), GitSummary::CONFLICT),
668 (Path::new("y/y2.txt"), MODIFIED),
669 (Path::new("z"), MODIFIED),
670 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
671 (Path::new("z/z2.txt"), MODIFIED),
672 ],
673 );
674 }
675
676 fn init_test(cx: &mut gpui::TestAppContext) {
677 zlog::init_test();
678
679 cx.update(|cx| {
680 let settings_store = SettingsStore::test(cx);
681 cx.set_global(settings_store);
682 Project::init_settings(cx);
683 });
684 }
685
686 #[gpui::test]
687 async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
688 init_test(cx);
689
690 // Create a worktree with a git directory.
691 let fs = FakeFs::new(cx.background_executor.clone());
692 fs.insert_tree(
693 path!("/root"),
694 json!({
695 ".git": {},
696 "a.txt": "",
697 "b": {
698 "c.txt": "",
699 },
700 }),
701 )
702 .await;
703 fs.set_head_and_index_for_repo(
704 path!("/root/.git").as_ref(),
705 &[("a.txt".into(), "".into()), ("b/c.txt".into(), "".into())],
706 );
707 cx.run_until_parked();
708
709 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
710 cx.executor().run_until_parked();
711
712 let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
713 let tree = project.worktrees(cx).next().unwrap().read(cx);
714 (
715 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
716 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
717 )
718 });
719
720 // Regression test: after the directory is scanned, touch the git repo's
721 // working directory, bumping its mtime. That directory keeps its project
722 // entry id after the directories are re-scanned.
723 fs.touch_path(path!("/root")).await;
724 cx.executor().run_until_parked();
725
726 let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
727 let tree = project.worktrees(cx).next().unwrap().read(cx);
728 (
729 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
730 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
731 )
732 });
733 assert_eq!(new_entry_ids, old_entry_ids);
734 assert_ne!(new_mtimes, old_mtimes);
735
736 // Regression test: changes to the git repository should still be
737 // detected.
738 fs.set_head_for_repo(
739 path!("/root/.git").as_ref(),
740 &[
741 ("a.txt".into(), "".into()),
742 ("b/c.txt".into(), "something-else".into()),
743 ],
744 );
745 cx.executor().run_until_parked();
746 cx.executor().advance_clock(Duration::from_secs(1));
747
748 let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
749 (
750 project.git_store().read(cx).repo_snapshots(cx),
751 project.worktrees(cx).next().unwrap().read(cx).snapshot(),
752 )
753 });
754
755 check_git_statuses(
756 &repo_snapshots,
757 &worktree_snapshot,
758 &[
759 (Path::new(""), MODIFIED),
760 (Path::new("a.txt"), GitSummary::UNCHANGED),
761 (Path::new("b/c.txt"), MODIFIED),
762 ],
763 );
764 }
765
766 #[track_caller]
767 fn check_git_statuses(
768 repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
769 worktree_snapshot: &worktree::Snapshot,
770 expected_statuses: &[(&Path, GitSummary)],
771 ) {
772 let mut traversal = GitTraversal::new(
773 repo_snapshots,
774 worktree_snapshot.traverse_from_path(true, true, false, "".as_ref()),
775 );
776 let found_statuses = expected_statuses
777 .iter()
778 .map(|&(path, _)| {
779 let git_entry = traversal
780 .find(|git_entry| &*git_entry.path == path)
781 .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
782 (path, git_entry.git_summary)
783 })
784 .collect::<Vec<_>>();
785 pretty_assertions::assert_eq!(found_statuses, expected_statuses);
786 }
787}