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