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