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