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