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