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