git_traversal.rs

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