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