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