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