git_traversal.rs

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