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