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